Reviewed-on: #1
vault-plugin-secrets-apptoken
A HashiCorp Vault / OpenBao secrets engine that issues short-lived, signed
JWT "app tokens" for self-made services (keaapi, encapi, artifactapi,
tomswallapi, bootapi, ...). It replaces per-app static bearer Secrets with one
mechanism: define a per-app role, read creds/<app> to mint a token, and let
the app validate it offline against the engine's public JWKS — no Vault
round-trip per request.
Any principal allowed to read creds/<role> (for example the agents approle)
can mint a token for that app. Signing keys are generated inside Vault's barrier
(seal-wrapped) and never leave it; only the public JWKS is exposed.
issuer (this engine) consumer app (keaapi)
├── creds/keaapi ──▶ signed JWT ──────▶ Authorization: Bearer <jwt>
│ (aud=keaapi, exp, sub=caller) │
└── .well-known/jwks.json ◀── fetch ───────┘ verify signature + aud, offline
Signing
Tokens are signed with EdDSA (Ed25519) by default — compact signatures,
fast verification, first-class support in Go's stdlib and go-jose, which the
whole ecosystem (and Vault itself) uses. RS256 is available via
config algorithm=RS256 for consumers that need RSA interop. The kid header
is the RFC 7638 JWK thumbprint of the signing key, so it is stable and matches
the JWKS entry.
Paths
| Path | Op | Purpose |
|---|---|---|
config |
R/W | issuer (external mount URL, becomes iss + base of jwks_uri), algorithm (EdDSA|RS256), retained_keys |
config/keys |
R | current signing kid + all retained kids (keys are generated lazily on first use) |
config/keys/rotate |
W | generate a new current signing key; up to retained_keys previous keys stay in the JWKS |
roles/<name> |
R/W/D, list roles/ |
per-app role: audience (defaults to role name), ttl, max_ttl, allowed_subjects, claims |
creds/<name> |
R | mint a signed JWT for the role |
.well-known/jwks.json |
R, unauthenticated | public JSON Web Key Set for offline validation |
.well-known/openid-configuration |
R, unauthenticated | OIDC issuer metadata (issuer, jwks_uri) |
Token claims: iss (issuer), aud (role audience), sub (requesting Vault
entity id or token display name), exp/nbf/iat, jti, plus any role
claims. Registered claims cannot be overridden by a role.
allowed_subjects, when set, restricts which callers (matched on Vault entity
id or token display name) may mint from a role. retained_keys keeps N previous
signing keys published after a rotation so tokens signed just before it keep
validating during the grace window.
Usage
# register + enable (the plugin_directory must contain the binary)
sha=$(sha256sum /opt/vault-plugins/vault-plugin-secrets-apptoken | cut -d' ' -f1)
vault plugin register -sha256=$sha secret vault-plugin-secrets-apptoken
vault secrets enable -path=apptoken vault-plugin-secrets-apptoken
# configure the issuer to this mount's external URL
vault write apptoken/config issuer="https://vault.k8s.syd1.au.unkin.net/v1/apptoken"
# define a per-app role and mint a token
vault write apptoken/roles/keaapi ttl=15m max_ttl=1h claims="scope=leases"
vault read apptoken/creds/keaapi # -> token, aud=keaapi, exp, ...
# public validation material (no token required)
curl -s "$VAULT_ADDR/v1/apptoken/.well-known/jwks.json"
curl -s "$VAULT_ADDR/v1/apptoken/.well-known/openid-configuration"
# rotate signing keys; tokens signed before rotation still validate during grace
vault write -f apptoken/config/keys/rotate
Validating tokens in a service (Go)
Consumers only need the JWKS URL and their expected audience. Fetch and cache the JWKS (honouring cache headers / periodic refresh), then verify offline:
// keaapi middleware sketch — do NOT hit Vault per request.
import (
"github.com/coreos/go-oidc/v3/oidc" // or MicahParks/keyfunc + golang-jwt
)
// jwks_uri = "https://vault.../v1/apptoken/.well-known/jwks.json"
keySet := oidc.NewRemoteKeySet(ctx, jwksURL)
verifier := func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
raw := strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ")
payload, err := keySet.VerifySignature(r.Context(), raw)
// then decode payload, check: iss == expected, aud contains "keaapi",
// exp in the future. Reject otherwise.
...
})
}
kea-operator's internal/keaapi is the first intended consumer — see
Follow-ups.
Why not the built-in OIDC identity-token provider?
Vault ships an identity-token provider (identity/oidc) that also issues signed
JWTs with a JWKS. This engine is deliberately parallel to it because:
- Roles are issuable by other principals.
creds/<role>is a normal secret path, so the agents approle (or CI) can mint an app's token without being the token's own identity. The identity provider ties issuance to the caller's entity and its role/client model is heavier. - Simpler audience model. One role = one app = one audience, no separate OIDC client/assignment objects to wire up.
- OpenBao parity + house pattern. Same plugin/Makefile/
.woodpecker/RPM shape as the othervault-plugin-secrets-*engines, and the identical binary runs on OpenBao.
If you only need tokens whose subject is always the calling entity, the built-in provider may suffice; for per-app service tokens minted by shared automation, this engine is the better fit.
Build
make build # -> dist/vault-plugin-secrets-apptoken
make test lint # go test -race / go vet
make rpm # vault + openbao RPM flavours
CI (Woodpecker) runs pre-commit/build/lint/test on PRs and, on a v* tag,
builds the vault + openbao RPM flavours and uploads them to the artifactapi
rpm-internal repo. Cut a release with make patch|minor|major (tags + pushes).