Since its first release, tokenex has focused on identity-first credential acquisition exchanging short-lived identity tokens (JWT) for cloud credentials just-in-time, without baking secrets into code, files, or images.
Today, we’re extending that model beyond cloud IAM.
We’re excited to announce native support for HashiCorp Vault and OpenBao as credential providers in tokenex. With this new capability, tokenex can exchange ID tokens (JWTs) for secrets stored in Vault or OpenBao, using their built-in JWT authentication flows; no static Vault tokens, no long-lived credentials, and no manual secret distribution.
This addition makes Vault and OpenBao first-class participants in tokenx’s identity-driven workflow, allowing applications to retrieve both:
using the same identity-based access pattern.
In the next section, we’ll explain why this integration matters, how it complements tokenex’s existing capabilities, and what it unlocks for teams building secure, scalable systems.
tokenex was originally created to provide a unified, consistent interface for obtaining and refreshing cloud credentials across multiple providers including AWS, GCP, Azure, and OCI — by exchanging identity tokens for temporary credentials and streaming those credentials over a channel so applications never have to implement bespoke refresh logic themselves.
It already supports:
This model enables developers to write single credential consumption logic, regardless of provider, while tokenex handles:
While this already removed much of the complexity around cloud authentication, there was still a clear gap: enterprise secrets management.
Many real‑world systems don’t only need cloud API credentials. They also rely on database credentials, API keys, and other sensitive configuration, which are typically managed by platforms like HashiCorp Vault or OpenBao. These systems excel at issuing dynamic, short‑lived secrets, enforcing least privilege, and providing strong auditability, but consuming those secrets safely still requires glue code and identity plumbing.
By adding Vault/OpenBao as a first‑class credentials provider, tokenex now allows workloads to:
With this addition, tokenex evolves from a cloud‑credential helper into a general identity‑to‑secret exchange layer. Applications authenticate once using identity, and tokenex handles the rest, regardless of whether the target is a cloud API or a centralized secrets manager.
This feature is a natural extension of tokenex’s core philosophy:
minimize credential handling in applications, centralize trust in identity, and let platforms issue short‑lived secrets on demand.
In this step, we deploy OpenBao and PostgreSQL as containers using Docker Compose.
OpenBao configuration:
Create a file named vault.hcl with the following contents:
ui = true
storage "file" {
path = "/vault/data"
}
listener "tcp" {
address = "0.0.0.0:8200"
tls_disable = 1
}
api_addr = "http://127.0.0.1:8200"
cluster_addr = "http://127.0.0.1:8201"
plugin_auto_register = true
plugin_auto_download = true
plugin_download_behavior = "fail"
plugin_directory = "/opt/openbao/plugins"
Note: For simplicity, TLS is disabled and file-based storage is used. This configuration is suitable for demos and local development only.
Docker Compose setup
Create a docker-compose.yml file:
services:
openbao:
restart: unless-stopped
image: openbao/openbao:2.4
container_name: openbao
ports:
- "8200:8200"
cap_add:
- IPC_LOCK
volumes:
- ./vault.hcl:/vault/config/vault.hcl:ro
- vault-data:/vault/data
environment:
VAULT_ADDR: "http://127.0.0.1:8200"
command: vault server -log-level=debug -config=/vault/config/vault.hcl
postgres:
image: postgres:18
container_name: postgres
restart: unless-stopped
shm_size: 128mb
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: mysecretpassword
ports:
- "5432:5432"
volumes:
- pg-data:/var/lib/postgresql
volumes:
vault-data:
pg-data:
docker exec openbao bao operator init -format=json > vault-init.json
docker exec openbao bao operator unseal $(jq -r '.unseal_keys_b64[0]' vault-init.json)
docker exec openbao bao operator unseal $(jq -r '.unseal_keys_b64[1]' vault-init.json)
docker exec openbao bao operator unseal $(jq -r '.unseal_keys_b64[2]' vault-init.json)
# Enable JWT authentication method
docker exec -e VAULT_TOKEN=$(jq -r '.root_token' vault-init.json) \
openbao bao auth enable jwt
# Configure the JWT authentication method
docker exec -e VAULT_TOKEN=$(jq -r '.root_token' vault-init.json) \
openbao bao write auth/jwt/config \
bound_issuer="<OIDC_ISSUER_URL>" \
oidc_discovery_url="<OIDC_DISCOVERY_URL>" \
oidc_client_id="" \
oidc_client_secret=""
bound_issuer: <OIDC_ISSUER_URL> – Issuer URL of your OIDC provider
oidc_discovery_url: <OIDC_DISCOVERY_URL> – URL for OIDC metadata discovery (keys, endpoints)
# Create a named role 'secret-reader' that authorizes JWTs with specific subject and audience claims
# Assign the 'read-secrets' policy to this role
docker exec -e VAULT_TOKEN=$(jq -r '.root_token' vault-init.json) openbao \
bao write auth/jwt/role/secret-reader \
user_claim="sub" \
bound_audiences="<JWT_AUDIENCE>" \
bound_subject="<JWT_SUB_CLAIM>" \
token_policies="read-secrets" \
token_type="service" \
expiration_leeway=150 \
not_before_leeway=150 \
role_type="jwt"
bound_audiences: <JWT_AUDIENCE> – Expected aud claim in JWT tokens
bound_subject: <JWT_SUB_CLAIM> – Expected sub claim in JWT tokens
# Create the 'read-secrets' policy
# Grants read access to the 'pg-dyn-dbuser' dynamic DB credential and the 'pg-dbuser1' static DB credential
docker exec -i -e VAULT_TOKEN=$(jq -r '.root_token' vault-init.json) openbao \
bao policy write read-secrets -<<EOF
path "database/creds/pg-dyn-dbuser" {
capabilities = ["read"]
}
path "database/static-creds/pg-dbuser1" {
capabilities = ["read"]
}
EOF
In this step, we enable the database secrets engine in OpenBAO and configure it to manage credentials for PostgreSQL, both dynamic (temporary) and static users.
# Enable database secrets engine
docker exec -e VAULT_TOKEN=$(jq -r '.root_token' vault-init.json) openbao \
bao secrets enable database
Configure the connection to PostgreSQL:
# Configure connection to PostgreSQL
docker exec -e VAULT_TOKEN=$(jq -r '.root_token' vault-init.json) openbao \
bao write database/config/my-postgresql-database \
plugin_name="postgresql-database-plugin" \
allowed_roles="pg-dyn-dbuser, pg-dbuser1" \
connection_url="postgresql://{{username}}:{{password}}@postgres:5432" \
username="postgres" \
password="mysecretpassword"
Configure a dynamic role for temporary PostgreSQL users Dynamic roles allow OpenBAO to generate short-lived database credentials automatically:
# Configure a role that maps a name in OpenBAO to an SQL statement to execute to create the database credential
docker exec -e VAULT_TOKEN=$(jq -r '.root_token' vault-init.json) openbao \
bao write database/roles/pg-dyn-dbuser \
db_name="my-postgresql-database" \
creation_statements="CREATE ROLE \"{{name}}\" WITH LOGIN PASSWORD '{{password}}' VALID UNTIL '{{expiration}}'; \
GRANT SELECT ON ALL TABLES IN SCHEMA public TO \"{{name}}\";" \
default_ttl="1h" \
max_ttl="24h"
{{name}}, {{password}}, and {{expiration}} are dynamically populated by OpenBAO.Create a static PostgreSQL user This user will be referenced by a static role in OpenBAO.
# Create a database user named 'dbuser1' in the PostgreSQL
docker exec -e PGPASSWORD=mysecretpassword postgres psql -U postgres -c "CREATE USER \"dbuser1\" WITH PASSWORD 'pwd1'; GRANT SELECT ON ALL TABLES IN SCHEMA public TO \"dbuser1\";"
Configure a static role in OpenBAO Static roles allow OpenBAO to manage existing database users without creating new ones:
# Configure a static role that creates a link to a user named 'dbuser1' in the PostgreSQL database
docker exec -e VAULT_TOKEN=$(jq -r '.root_token' vault-init.json) openbao \
bao write database/static-roles/pg-dbuser1 \
db_name="my-postgresql-database" \
username="dbuser1" \
rotation_period="1d"
pg-dbuser1 maps to the existing PostgreSQL user dbuser1 and rotation_period defines how often the password of the database user should be rotatedThis demo application logs both dynamic and static database credentials to stdout as they are issued and refreshed by OpenBAO.
package main
import (
"context"
"log"
"os"
"os/signal"
"sync"
"syscall"
"emperror.dev/errors"
"github.com/go-logr/logr"
"github.com/iand/logfmtr"
"go.riptides.io/tokenex/pkg/credential"
"go.riptides.io/tokenex/pkg/token"
"go.riptides.io/tokenex/pkg/vault"
)
func main() {
// Create a cancellable context
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
// Create a logger
opts := logfmtr.DefaultOptions()
opts.Humanize = true
opts.Colorize = true
opts.AddCaller = true
opts.CallerSkip = 2
logger := logfmtr.NewWithOptions(opts)
logfmtr.SetVerbosity(2)
// Create a wait group to track goroutines
var wg sync.WaitGroup
// Set up graceful shutdown
signalChan := make(chan os.Signal, 1)
signal.Notify(signalChan, syscall.SIGINT, syscall.SIGTERM)
logger.Info("Press Ctrl+C to stop...")
go func() {
sig := <-signalChan
logger.Info("Received signal, shutting down...", "sig", sig)
cancel()
}()
// Create an identity token provider
// under the hood the credential provider uses OCI workload identity federation to fetch user principal session tokens from OCI
// the credential provider exchanges an input ID token for an OCI user principal session token
// the input ID token can be obtained from any OIDC compliant IDP (e.g. Google, Microsoft, Auth0, Okta, etc.)
// for this example, we use a static ID token provider that returns a hardcoded ID token issued by an OIDC compliant IDP
// in a real application, you would implement the `token.IdentityTokenProvider` interface to create a dynamic ID token provider that fetches the ID token from an OIDC compliant IDP
idTokenProvider := token.NewStaticIdentityTokenProvider("<id-token-issued-by-idp>")
// Create the Vault credentials provider
vaultProvider, err := vault.NewCredentialsProvider(ctx, logger, "http://localhost:8200", nil)
if err != nil {
logger.Error(err, "Failed to create Vault credentials provider")
}
dynDBCredsChan, err := vaultProvider.GetCredentials(
ctx,
idTokenProvider,
vault.WithJWTAuthMethodPath("jwt"),
vault.WithJWTAuthRoleName("secret-reader"),
vault.WithSecretFullPath("database/creds/pg-dyn-dbuser"),
)
if err != nil {
logger.Error(err, "Failed to get dynamic database credentials")
}
// Process dynamic database credentials
wg.Add(1)
credentialsConsumer(logr.NewContext(ctx, logger.WithValues("secret_engine", "database", "credential_type", "dynamic", "secret_path", "/creds/pg-dyn-dbuser")), &wg, dynDBCredsChan, logDBCreds)
staticDBCredsChan, err := vaultProvider.GetCredentials(
ctx,
idTokenProvider,
vault.WithJWTAuthMethodPath("jwt"),
vault.WithJWTAuthRoleName("secret-reader"),
vault.WithSecretFullPath("database/static-creds/pg-dbuser1"),
)
if err != nil {
logger.Error(err, "Failed to get static database credentials")
}
// Process static database credentials
wg.Add(1)
credentialsConsumer(logr.NewContext(ctx, logger.WithValues("secret_engine", "database", "credential_type", "static", "secret_path", "/static-creds/pg-dbuser1")), &wg, staticDBCredsChan, logDBCreds)
// Wait for all goroutines to finish
wg.Wait()
}
func credentialsConsumer(ctx context.Context, wg *sync.WaitGroup, credsChan <-chan credential.Result, credsLogger func(logr.Logger, *credential.VaultSecret)) {
go func() {
defer wg.Done()
logger := logr.FromContextOrDiscard(ctx)
for {
select {
case creds, ok := <-credsChan:
if !ok {
logger.Info("credentials channel closed")
return
}
if creds.Err != nil {
logger.Error(creds.Err, "Error receiving credentials", errors.GetDetails(creds.Err)...)
return
}
dbSecret, ok := creds.Credential.(*credential.VaultSecret)
if !ok {
logger.Error(errors.New("invalid credential type"), "expected *credential.VaultSecret")
return
}
credsLogger(logger, dbSecret)
case <-ctx.Done():
log.Println("Context cancelled, shutting down credentials handler")
return
}
}
}()
}
func logDBCreds(logger logr.Logger, dbSecret *credential.VaultSecret) {
// Database secrets typically contain username and password
username := dbSecret.Data["username"].(string)
password := dbSecret.Data["password"].(string)
logger.Info("credential", "username", username, "password", password)
}
Sample output:
0 info | 11:00:29.398131 | Press Ctrl+C to stop... caller=main.go:41
0 info | 11:00:29.428805 | credential caller=main.go:220 secret_engine=database credential_type=static secret_path=/static-creds/pg-dbuser1 username=dbuser1 password=Qj-6l6OWghtonpucH6Vd
2 info | 11:00:29.428858 | Published Vault secret logger=vault_credentials caller=creds.go:245 secret_path=database/static-creds/pg-dbuser1 expiresAt="2026-01-15 13:51:17.428782 +0100 CET m=+6648.032605334"
2 info | 11:00:29.428879 | Using RefreshOn time from credentials logger=vault_credentials caller=creds.go:252 refreshOn="2026-01-15 13:51:22.428782 +0100 CET m=+6653.032605334"
1 info | 11:00:29.428885 | Scheduling credential refresh logger=vault_credentials caller=creds.go:260 refreshIn=1h50m52.999899041s refreshBuffer=0s secret_path=database/static-creds/pg-dbuser1
2 info | 11:00:29.446979 | Published Vault secret logger=vault_credentials caller=creds.go:245 secret_path=database/creds/pg-dyn-dbuser expiresAt="2026-01-15 12:15:29.446969 +0100 CET m=+900.050791918"
0 info | 11:00:29.446989 | credential caller=main.go:220 secret_engine=database credential_type=dynamic secret_path=/creds/pg-dyn-dbuser username=v-jwt-ript-pg-dyn-d-QKRDQydUME1zlQtgdyY6-1768474829 password=t7gZd5h-7BwZr2lB0iFO
1 info | 11:00:29.447002 | Scheduling credential refresh logger=vault_credentials caller=creds.go:260 refreshIn=11m57.937258345s refreshBuffer=3m2.06274128s secret_path=database/creds/pg-dyn-dbuser
What we see in the output:
For the static PostgreSQL user dbuser1, the password is managed and rotated by the OpenBAO database secrets engine.
The user was originally created with the password pwd1. OpenBAO rotates this password only when the rotation time is reached, at expiresAt="2026-01-15 13:51:17.428782 +0100 CET".
Because this is a static role, tokenex cannot retrieve a new password before the rotation occurs. It must wait until OpenBAO performs the rotation.
Once the password has been rotated, Tokenex re-fetches and publishes the updated credentials shortly after, at refreshOn="2026-01-15 13:51:22.428782 +0100 CET".
For the dynamic PostgreSQL user v-jwt-ript-pg-dyn-d-QKRDQydUME1zlQtgdyY6-1768474829, OpenBAO creates a brand-new database user with a unique password.
This credential is short-lived and expires in approximately 15 minutes.
Unlike static credentials, tokenex can proactively request a new dynamic credential before the current one expires.
In this example, a refresh is scheduled at refreshIn=11m57.937258345s with a refreshBuffer=3m2.06274128s, ensuring continuous access without relying on password rotation.
Workloads authenticate using a JWT and exchange it for secrets at runtime:
No secrets in config files, CI pipelines, or environment variables. No shared credentials between services.
A trusted orchestrator (Kubernetes controller, job runner, workflow engine) can:
By combining:
you end up with systems that are:
tokenex doesn’t replace Vault or OpenBao — it connects them seamlessly to modern identity systems, allowing secrets to be accessed only when and where identity has been verified.