Skip to content

OpenBao Provider

The OpenBao provider integrates with OpenBao’s KV (Key-Value) secrets engine using OpenBao’s own provider identity and configuration conventions.

Provideropenbao (0.17+)
URIopenbao://[namespace@]host[:port][/mount][?options]
AccessRead, write, and delete; secret references are read-only
Best forOpen-source, policy-controlled secret infrastructure
AuthenticationToken, AppRole, or JWT/OIDC
Build featureopenbao (0.17+)
Default storageKV path secretspec/{project}/{profile}/{key}, field value
Terminal window
$ export BAO_TOKEN=hvs.your-token-here
$ secretspec set DATABASE_URL --provider openbao://bao.example.com:8200
Enter value for DATABASE_URL: postgresql://localhost/mydb
Secret 'DATABASE_URL' saved to openbao (profile: default)
  • A running OpenBao server
  • Authentication credentials
  • KV secrets engine enabled (v1 or v2)
  • Build with --features openbao

For the variables defined by the OpenBao CLI, the provider follows its documented convention: BAO_ADDR, BAO_NAMESPACE, BAO_TOKEN, and BAO_TOKEN_PATH take precedence over their VAULT_* counterparts.

SecretSpec additionally defines OpenBao-prefixed provider inputs for AppRole and JWT authentication. These are consumed by SecretSpec, not by the bao CLI, and retain the corresponding VAULT_* names as compatibility fallbacks.

Token authentication is the default. SecretSpec checks these sources in order:

  1. The alias’s token provider credential
  2. BAO_TOKEN, then VAULT_TOKEN
  3. The file selected by BAO_TOKEN_PATH, then VAULT_TOKEN_PATH
  4. The OpenBao CLI’s default ~/.vault-token
Terminal window
$ export BAO_TOKEN=hvs.your-token-here

Select AppRole with ?auth=approle. OpenBao roles bind a SecretID by default, so the usual configuration provides both inputs:

Terminal window
$ export BAO_ROLE_ID=your-role-id
$ export BAO_SECRET_ID=your-secret-id

These are SecretSpec provider inputs, not OpenBao CLI variables. VAULT_ROLE_ID and VAULT_SECRET_ID remain accepted as fallbacks. Prefer semantic provider credentials when configuring an alias:

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

Disabling SecretID binding removes AppRole’s usual second credential. Keep the server default unless the workload deliberately relies on another trust boundary, such as a tightly controlled Agent host and network constraints.

AppRole and JWT methods mounted somewhere other than their defaults can be selected with ?auth_mount=. The value is relative to /v1/auth:

openbao://bao.example.com:8200/secret?auth=approle&auth_mount=platform-approle
openbao://bao.example.com:8200/secret?auth=jwt&auth_mount=ci-jwt&role=ci

The provider logs in at /v1/auth/platform-approle/login and /v1/auth/ci-jwt/login, respectively. The KV mount remains the provider URI path (secret in these examples).

Select JWT with ?auth=jwt. The provider performs the auth/jwt/login exchange itself. The JWT comes from SecretSpec’s BAO_JWT input, then the VAULT_JWT compatibility fallback. Otherwise, in a GitHub Actions or Forgejo job with id-token: write, the provider mints one from the runner’s OIDC identity.

Starting with SecretSpec 0.18, the role may be omitted when the JWT auth mount has a default_role; OpenBao then selects that role during login. An explicit SecretSpec role still takes precedence.

  • ?role=, BAO_JWT_ROLE, or VAULT_JWT_ROLE; optional with a server-configured default_role (0.18+)
  • ?audience=, BAO_JWT_AUDIENCE, or VAULT_JWT_AUDIENCE
Credential Environment fallback Available since
role_id BAO_ROLE_IDVAULT_ROLE_ID 0.17+
secret_id BAO_SECRET_IDVAULT_SECRET_ID 0.17+
token BAO_TOKENVAULT_TOKEN 0.17+

See the complete provider credential reference for all supported providers and environment fallbacks.

openbao://[namespace@]host[:port][/mount][?key=value&...]
  • host[:port]: OpenBao address (falls back through BAO_ADDR, VAULT_ADDR)
  • mount: KV engine mount path (default: secret)
  • namespace@: Optional namespace (falls back through BAO_NAMESPACE, VAULT_NAMESPACE)
  • ?auth=approle: Use AppRole authentication (default: token)
  • ?auth=jwt: Use JWT/OIDC authentication; a server-configured default_role can supply the role when using SecretSpec 0.18+
  • ?auth_mount= (0.18+): Non-default AppRole or JWT mount beneath /v1/auth
  • ?role=: OpenBao role for JWT auth
  • ?audience=: Audience requested from the CI OIDC issuer
  • ?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 OpenBao proxy tolerates more or less parallel load.
openbao://bao.example.com:8200/secret
openbao://team-a@bao.example.com:8200/secret
openbao://bao.example.com:8200/secret?auth=approle
# SecretSpec 0.18+
openbao://bao.example.com:8200/secret?auth=approle&auth_mount=platform-approle
openbao://bao.example.com:8200/secret?auth=jwt&role=ci
# SecretSpec 0.18+, with default_role configured on the JWT auth mount
openbao://bao.example.com:8200/secret?auth=jwt
secretspec.toml
[providers]
bao_prod = "openbao://bao.example.com:8200/secret"
[profiles.production]
DATABASE_URL = { description = "Database URL", providers = ["bao_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. OpenBao 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. This needs write access to the path’s metadata as well as its data, and KV v1 is refused as a cache because it has no expiry.

Deleting — cache clear and automatic invalidation — removes the KV path’s metadata and every version, and 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 path relative to the mount, and field selects the field to read. References are read-only so a single-field write cannot overwrite the entry’s other fields.

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

AppRole avoids placing a user token in the CI environment:

Terminal window
$ export BAO_ROLE_ID="$CI_ROLE_ID"
$ export BAO_SECRET_ID="$CI_SECRET_ID"
$ secretspec export --format gha --provider "openbao://bao.example.com:8200/secret?auth=approle"

With GitHub Actions or Forgejo Actions id-token: write, JWT/OIDC avoids a static authentication credential:

Terminal window
$ secretspec export --format gha --provider "openbao://bao.example.com:8200/secret?auth=jwt&role=ci"
Terminal window
$ secretspec set DATABASE_URL --provider "openbao://bao.example.com:8200/secret?kv=1"
Terminal window
$ secretspec check --provider openbao://team-a@bao.example.com:8200/secret
$ export BAO_NAMESPACE=team-a
$ secretspec check --provider openbao://bao.example.com:8200/secret
Terminal window
$ bao server -dev -dev-root-token-id="dev-only-token"
$ export BAO_TOKEN="dev-only-token"
$ secretspec check --provider "openbao://127.0.0.1:8200/secret?tls=false"