Skip to content

Vault Provider

The Vault provider integrates with HashiCorp Vault for centralized secret management using the KV (Key-Value) secrets engine.

Providervault
URIvault://[namespace@]host[:port][/mount][?options]
AccessRead, write, and delete (0.17+); secret references are read-only
Best forSelf-managed, policy-controlled secret infrastructure
AuthenticationToken or AppRole; JWT/OIDC (0.17+)
Build featurevault
Default storageKV path secretspec/{project}/{profile}/{key}, field value
Terminal window
# With default "secret" mount
$ secretspec set DATABASE_URL --provider vault://vault.example.com:8200
Enter value for DATABASE_URL: postgresql://localhost/mydb
Secret 'DATABASE_URL' saved to vault (profile: default)
  • A running Vault server
  • Authentication credentials
  • KV secrets engine enabled (v1 or v2)
  • Build with --features vault

Token authentication is the default. SecretSpec reads VAULT_TOKEN or ~/.vault-token:

Terminal window
$ export VAULT_TOKEN=hvs.your-token-here

Select AppRole with ?auth=approle and provide both environment variables:

Terminal window
$ export VAULT_ROLE_ID=your-role-id
$ export VAULT_SECRET_ID=your-secret-id

Starting with SecretSpec 0.15, these credentials can instead be read from another provider so they do not live in a shell profile:

secretspec.toml
[providers.vault_approle]
uri = "vault://vault.example.com:8200/secret?auth=approle"
[providers.vault_approle.credentials]
role_id = { provider = "onepassword", ref = { vault = "Infra", item = "vault-approle", field = "role_id" } }
secret_id = { provider = "onepassword", ref = { vault = "Infra", item = "vault-approle", field = "secret_id" } }

SecretSpec 0.14 supports only VAULT_ROLE_ID and VAULT_SECRET_ID.

Select JWT with ?auth=jwt and a role. The provider performs the auth/jwt/login exchange itself. The JWT comes from VAULT_JWT when set. Otherwise, in a GitHub Actions or Forgejo job with id-token: write, the provider mints one from the runner’s OIDC identity, so CI stores no static secret.

Both role and audience accept a URI query parameter or an environment variable:

  • ?role= or VAULT_JWT_ROLE (required)
  • ?audience= or VAULT_JWT_AUDIENCE, matched against the role’s bound_audiences
vault://[namespace@]host[:port][/mount][?key=value&...]
  • host[:port]: Vault server address (falls back to VAULT_ADDR)
  • mount: KV engine mount path (default: secret)
  • namespace@: Optional Vault namespace (also reads VAULT_NAMESPACE)
  • ?auth=approle: Use AppRole authentication (default: token)
  • ?auth=jwt (0.17+): Use JWT/OIDC authentication (requires ?role=)
  • ?role= (0.17+): Vault role for JWT auth (or VAULT_JWT_ROLE)
  • ?audience= (0.17+): OIDC audience (or VAULT_JWT_AUDIENCE)
  • ?kv=1: Use KV v1 (default: v2)
  • ?tls=false: Disable TLS for development servers
  • One HTTP client is reused per provider instance (connection pool / h2 reuse).
  • Concurrent unique-address fetches are capped at 8 by default.
  • Override the cap with SECRETSPEC_PROVIDER_CONCURRENCY (integer ≥ 1) when your Vault proxy tolerates more or less parallel load.
vault://vault.example.com:8200/secret
vault://team-a@vault.example.com:8200/secret
vault://vault.example.com:8200/secret?auth=approle
# SecretSpec 0.17+
vault://vault.example.com:8200/secret?auth=jwt&role=ci
secretspec.toml
[providers]
vault_prod = "vault://vault.example.com:8200/secret"
[profiles.production]
DATABASE_URL = { description = "Database URL", providers = ["vault_prod"] }

Each secret is stored at secretspec/{project}/{profile}/{key} under the configured mount, with its value in a field named value.

For KV v2, DATABASE_URL for project myapp and profile production is read from GET /v1/secret/data/secretspec/myapp/production/DATABASE_URL.

A KV v2 mount can hold a cached provider route’s entries. Vault expires them itself: the cache’s max_age is written to the path’s delete_version_after metadata, so a cached copy of another store’s secret stops existing at that age even if SecretSpec never runs again.

secretspec.toml
[providers]
slow = "onepassword://Production"
shared_cache = "vault://vault.example.com:8200/secret"
myprovider = { fallback = ["slow"], cache = { provider = "shared_cache", max_age = "8h" } }

This needs write access to the path’s metadata as well as its data. KV v1 has no expiry and is refused as a cache, rather than storing a copy that would never expire.

Deleting — cache clear and automatic invalidation — removes the KV path’s metadata and every version, so no soft-deleted version keeps the value recoverable. It is confined to entries SecretSpec owns: a secret reference is never deleted, since the path it names is managed outside SecretSpec.

A secret’s ref field names an existing KV entry: item is the KV path relative to the mount, and field selects the field to read. field is required because KV entries are maps. References are read-only in this provider.

[profiles.production]
DATABASE_URL = { description = "DB", ref = { item = "myapp/config", field = "db_url" }, providers = ["vault://vault.example.com:8200/secret"] }

The mount is not a ref coordinate: it comes from the provider URI (secret in the example). To read one secret from a different mount, give that secret a provider entry whose URI names the mount.

SecretSpec 0.16 can use AppRole to keep a user token out of the environment by logging in from VAULT_ROLE_ID and VAULT_SECRET_ID:

Terminal window
$ export VAULT_ROLE_ID="$CI_VAULT_ROLE_ID"
$ export VAULT_SECRET_ID="$CI_VAULT_SECRET_ID"
$ secretspec export --format gha --provider "vault://vault.example.com:8200/secret?auth=approle"

SecretSpec 0.17 adds a tokenless JWT/OIDC path. Under GitHub Actions or Forgejo Actions with id-token: write, the provider mints the job’s OIDC token and logs in with a role bound to the workflow’s claims:

Terminal window
$ secretspec export --format gha --provider "vault://vault.example.com:8200/secret?auth=jwt&role=ci"
Terminal window
$ secretspec set DATABASE_URL --provider "vault://vault.example.com:8200/secret?kv=1"
Terminal window
$ secretspec check --provider vault://team-a@vault.example.com:8200/secret
$ export VAULT_NAMESPACE=team-a
$ secretspec check --provider vault://vault.example.com:8200/secret
Terminal window
$ vault server -dev
$ export VAULT_TOKEN=hvs.dev-root-token
$ secretspec check --provider "vault://127.0.0.1:8200/secret?tls=false"