A universal Terraform credentials helper with pluggable secret backends and per-file account isolation.
Terraform asks the helper for a token whenever it talks to a Terraform-native service — Terraform Cloud/Enterprise, Scalr, Spacelift, private module/provider registries — keyed by hostname. tfvault answers from the backend you configure:
| Backend | Storage | Writable |
|---|---|---|
keyring |
OS keyring (macOS Keychain, Linux Secret Service) | yes |
pass |
pass / gopass password store | yes |
op |
1Password via the op CLI |
yes |
env |
environment variables (TF_TOKEN_* encoding) |
read-only |
Planned: AWS Secrets Manager, SSM Parameter Store, HashiCorp Vault. Each backend is one Go package + one registration line.
Supported platforms: macOS and Linux, amd64 and arm64.
Homebrew (macOS and Linux):
brew install tedilabs/tap/tfvaultOr mise, via either the github or the aqua backend (both resolve the same release archives; the aqua backend adds checksum verification through the aqua registry):
mise use -g github:tedilabs/tfvault
# or
mise use -g aqua:tedilabs/tfvault
tfvault install # link the helper into ~/.terraform.d/pluginsFor mise installs, tfvault install detects the mise-managed path and
writes a small wrapper that execs the mise shim instead of symlinking
the versioned binary: upgrading tfvault via mise never leaves a stale
plugin link, and per-directory version pins are honored.
Or the install script:
curl -fsSL https://raw.githubusercontent.com/tedilabs/tfvault/main/install.sh | shThe script installs the tfvault binary into ~/.local/bin and runs
tfvault install, which symlinks it into ~/.terraform.d/plugins/ as
terraform-credentials-tfvault — the name Terraform discovers helpers
by. Or manually: download a release archive, verify checksums.txt,
put tfvault on your PATH and run tfvault install. From source:
go build ./cmd/tfvault
install -m 0755 tfvault ~/.local/bin/
tfvault installAdd to ~/.terraformrc:
credentials_helper "tfvault" {
args = []
}That's it — no config file needed. Tokens go into your OS keyring under
the service name tfvault:
terraform login app.terraform.io # store a token
terraform logout app.terraform.io # forget ittfvault status shows whether the plugin link and .terraformrc are
wired up and which profile and backend requests resolve to.
The helper works for any Terraform-native service hostname, not just Terraform Cloud.
The core feature: different .terraformrc files can use different
credential sets via profiles. Define profiles in
~/.config/tfvault/config.yaml:
default_profile: personal
profiles:
personal:
backend: keyring
options:
service: tfvault-personal
customer-a:
backend: keyring
options:
service: tfvault-customer-a
customer-b:
backend: pass
options:
binary: gopass
prefix: customers/b/terraform
store_dir: ~/.password-store-customer-b
ci:
backend: env
options:
prefix: CI_TF_TOKEN_Create one .terraformrc per account:
# ~/.terraformrc-customer-a
credentials_helper "tfvault" {
args = ["--profile", "customer-a"]
}Then select it per shell, per direnv, or per invocation:
export TF_CLI_CONFIG_FILE=~/.terraformrc-customer-a
terraform planOr run a single command against a profile without a per-account
.terraformrc at all:
tfvault exec --profile customer-a -- terraform planThe same hostname (e.g. app.terraform.io) resolves to different tokens
in different profiles because each profile points at its own storage
location.
Config file lookup order:
--config <path>(set viaargsin thecredentials_helperblock)$TFVAULT_CONFIG$XDG_CONFIG_HOME/tfvault/config.yaml, falling back to~/.config/tfvault/config.yaml
If no config file exists, the implicit default profile uses the
keyring backend with service: tfvault. Requesting any other named
profile without a config file is an error — a named profile implies
isolation you set up on purpose, so tfvault never falls back to shared
storage.
Top-level settings besides default_profile:
color: false # disable colored output from auxiliary commands (optional)
editor: vim # editor for "tfvault config edit"; takes precedence over $EDITOR (optional)Each entry under profiles names exactly one backend and passes the
keys under options to it:
profiles:
example:
backend: keyring
options:
service: tfvault # keyring service name (default "tfvault")Entries are stored as (service, hostname). On Linux this requires a
running Secret Service daemon (gnome-keyring, KWallet); on headless
machines use the pass backend instead.
profiles:
example:
backend: pass
options:
binary: pass # or "gopass", or an absolute path (default "pass")
prefix: terraform # entry path: <prefix>/<hostname> (default "terraform")
store_dir: ~/.password-store # sets PASSWORD_STORE_DIR for per-profile stores (optional)Tokens are exchanged with the child process via stdin/stdout only, never argv. Both pass and gopass are supported and integration-tested.
profiles:
example:
backend: op
options:
vault: Work # 1Password vault name (optional; default vault when omitted)
account: my.1password.com # for multiple 1Password accounts (optional)
prefix: tfvault/ # item title prefix: <prefix><hostname> (default "tfvault/")
binary: op # or an absolute path (default "op")Requires the 1Password CLI
(v2) with any of its auth methods: the desktop-app integration,
OP_SERVICE_ACCOUNT_TOKEN, or op signin. Entries are stored as
"API Credential" items tagged tfvault, and tokens are exchanged with
the op process via stdin/stdout only — never argv. Different profiles
can point at different vaults or accounts for per-client isolation.
profiles:
example:
backend: env
options:
prefix: TF_TOKEN_ # defaultLooks up <prefix><encoded-hostname> where . becomes _ and -
becomes __ (Terraform's native TF_TOKEN_* encoding), e.g.
TF_TOKEN_app_terraform_io. terraform login against an env profile
fails with a clear error since the backend cannot write.
Note: Terraform ≥ 1.2 reads TF_TOKEN_* variables natively without any
helper. The env backend is useful for the prefix override case
(CUSTOMER_A_TF_TOKEN_*) where profiles select among variable sets.
tfvault install # symlink the helper into ~/.terraform.d/plugins
tfvault status # plugin link, terraformrc and profile resolution
tfvault exec --profile a -- tf plan # run a command with the helper wired to a profile
tfvault config show # effective configuration and where each value comes from
tfvault config edit # open the config file in your editor
tfvault profiles # list profiles, default marked with *
tfvault --profile customer-b list # hostnames with stored credentials
tfvault completion bash # completion script (bash, zsh, fish)
tfvault versionShell completions complete commands, flags and profile names (the
latter by invoking tfvault profiles):
# bash (~/.bashrc)
eval "$(tfvault completion bash)"
# zsh
tfvault completion zsh > "${fpath[1]}/_tfvault"
# fish
tfvault completion fish > ~/.config/fish/completions/tfvault.fishinstall refuses to overwrite anything at the link path it does not
recognize as its own (a symlink or a tfvault-written shim wrapper —
e.g. a binary copied by an old installer); pass -f/--force to
replace it.
status reads the Terraform CLI config ($TF_CLI_CONFIG_FILE, else
~/.terraformrc) and reports the credentials_helper registration,
explicit credentials blocks that bypass the helper, and the profile,
backend and stored hostnames the current setup resolves to. It also
flags token sources Terraform consults before any helper — TF_TOKEN_*
environment variables and plaintext tokens left in
~/.terraform.d/credentials.tfrc.json by terraform login — and, for
backends that execute an external CLI (pass, op), checks the binary
is actually present.
exec runs one command against one profile. It writes a temporary
Terraform CLI config — a copy of the config that would otherwise apply,
with the credentials_helper block replaced by one carrying
--profile — points TF_CLI_CONFIG_FILE at it and runs the command,
then removes it. Terraform asks the helper per hostname exactly as with
a permanent registration, so no token ever enters the command's
environment, and the temporary file holds nothing but a profile name.
Copying the existing config keeps plugin_cache_dir,
provider_installation and everything else in effect. Unrelated helper
args and the profile registered in your .terraformrc are preserved
unless --profile overrides them; the command's exit code becomes
tfvault's, and SIGINT/SIGTERM/SIGHUP/SIGQUIT are forwarded to it.
For tools that cannot read the Terraform CLI config (anything built on
go-tfe, deploy scripts, curl), --export-host additionally puts a
token into the command's environment:
tfvault exec --profile customer-a --export-host app.terraform.io -- ./deploy.shThat sets TF_TOKEN_app_terraform_io, TFE_HOSTNAME and TFE_TOKEN. The
flag is repeatable; every host named gets its TF_TOKEN_* variable,
while the TFE_* pair follows the first one. Environment variables are
inherited by every process the command spawns — including Terraform's
provider plugins and any local-exec — so reach for --export-host
only when the tool leaves no choice.
config show prints the effective configuration — the config file with
command line flags applied on top — annotating each value with where it
came from. config edit opens the config file (created with mode 0600
when missing) in the config editor setting, else $EDITOR, else vi.
list is supported by the pass, op and env backends; OS keyrings
cannot enumerate entries. Token values are never printed by any
auxiliary command.
Auxiliary command output is colorized when stdout is a terminal.
Disable it with --no-color, the NO_COLOR
environment variable, or color: false in the config file. Protocol
(get/store/forget) and list output is always plain, since other
programs consume it.
- Explicit
credentialsblocks win. Terraform prefers acredentials "<host>" { token = ... }block in the CLI config over the helper. Remove such blocks for hosts the helper should manage. - The helper is asked about every Terraform-native service host.
Returning
{}(no credentials) is normal for public registries likeregistry.terraform.io; anonymous access continues to work. - gopass is CLI-compatible with pass and honors
PASSWORD_STORE_DIR; both were verified against real stores.
- Tokens never appear in logs, argv, or child process environments. The
one exception is opt-in and explicit:
tfvault exec --export-host <host>exports the token for the hosts you name. - On protocol commands, stdout carries only protocol JSON; all diagnostics go to stderr.
- Hostnames are validated before being used as paths, env var names or keyring accounts (no path traversal / argv injection).
- Ambiguous backend errors on
getfail with a nonzero exit instead of returning an empty{}that would mask a broken setup. - A world-readable config file produces a warning (it holds no secrets, but paths and service names are better kept private).
- On macOS, the
keyringbackend stores items through thesecurityCLI, whose items any process running as the same user can read back without a prompt — comparable to file permissions, weaker than a per-app keychain ACL. Migrating to the native Keychain API together with signed binaries is tracked in #14 and #13.
go test ./... # unit + protocol compliance tests
go test -tags integration ./... # real pass/gopass round trips (needs gpg)
go run github.com/goreleaser/goreleaser/v2@latest release --snapshot --cleanThe Terraform credentials helper protocol is documented at developer.hashicorp.com/terraform/internals/credentials-helpers.
tfvault is not the first helper in this space, and it is better for it — the maintainers below generously shared production lessons that directly shaped this project's roadmap:
- terracreds — the broadest helper around: Windows Credential Manager plus cloud vault providers (AWS, Azure, GCP, HashiCorp Vault) across TFC/TFE, Scalr, Spacelift and env0. If you need Windows or a cloud vault today, start there.
- terraform-credentials-keychain — a focused macOS Keychain helper with signed and notarized release binaries.
Reach for tfvault when you want pluggable local backends with per-profile multi-account isolation on macOS and Linux.
