Vault Kubernetes auth: secrets without static tokens

Let pods authenticate to Vault with their service account token and get short-lived secrets — no bootstrap secret to leak.

Jun 9, 2026·Updated ·5 min readAdvanced·By SecOpsLog · documentation-verified

The pod needs a credential to fetch its credentials, and putting a Vault token in a Kubernetes Secret only moves the problem one object to the left. Kubernetes auth removes the bootstrap secret by using the identity the pod already has: the service account token the kubelet projects into it. The pod sends that token to Vault; Vault asks the cluster's TokenReview API whether it is genuine and who it belongs to; a Vault role maps that service account to policies; and the pod gets back a Vault token that lives for minutes. Each link in that chain checks one thing, and the security of the whole depends on the weakest role definition in the mount.

The chain a login walks, and where it is refused

The SA JWT is identity, TokenReview is the cluster vouching for it, the role is permission. Each refusal in the bottom card is one link doing its job: the token, the namespace binding, the audience, then the policy on the path.

Vault Kubernetes auth trust chain: the Pod presents its projected service account token to Vault, Vault asks the kube-apiserver TokenReview whether it is valid and which service account in which namespace it names, the role binds that name, namespace and audience to policies and a TTL, and the Pod receives a short-lived Vault token. An expired or wrongly-audienced token, a same-named service account in another namespace, a missing audience or a path outside the policy is refused Podprojected SA tokenaud: vault, 10 min1Vaultauth/kubernetes/loginrole=payments2kube-apiserverTokenReview:valid? which SA, ns?354 The role is the boundarynamespayments (bound_service_account_names)namespacesshop (bound_service_account_namespaces)audiencevault (= the token’s aud claim)policiespayments-db (one path, read only)ttl20m batch token (dies on schedule)Vault tokenpolicies: payments-dbttl: 20mthen: read the secret,lease Vault can revokeWhere the same login is refusedtokenexpired or aud=API server: TokenReview says invalid; login deniednamespaceSA payments in ns dev: name matches, namespace does not; deniedaudiencerole audience=vault, token minted without one; denied before policypathlogin ok, then read database/creds/orders: 403, not in payments-db

What each link verifies

LinkChecksFailure it prevents
projected tokensigned by the cluster, bound to the pod, expires (expirationSeconds), carries an aud claima token copied out of a pod being replayed months later
TokenReviewthe token is valid now and names this service account in this namespacea forged or revoked token
role bindingbound_service_account_names and _namespaces match; audience matches the aud claima same-named service account in another namespace assuming the role
policies and TTLthe returned Vault token can read only the listed paths and dies on scheduleone compromised pod becoming a reader of every secret

Configure the mount: less than the tutorials say

vault-kubernetes-config.sh (Vault running inside the cluster)
vault auth enable kubernetes
# in a pod, kubernetes_ca_cert and token_reviewer_jwt default to the pod's own CA and token
vault write auth/kubernetes/config \
kubernetes_host="https://kubernetes.default.svc:443"
# Vault outside the cluster: a dedicated reviewer service account, nothing wider
# kubectl create clusterrolebinding vault-reviewer --clusterrole=system:auth-delegator --serviceaccount=vault:vault-reviewer
# vault write auth/kubernetes/config kubernetes_host=https://api.cluster:6443 \
# kubernetes_ca_cert=@ca.crt token_reviewer_jwt=@reviewer.jwt

When Vault runs in the cluster, the API docs are explicit that the CA certificate and reviewer JWT default to the pod's own, so the config is one line. When Vault runs elsewhere, the reviewer token is the credential that lets Vault call TokenReview, and system:auth-delegator is the whole permission it needs; a cluster-admin token here is the classic over-grant. If the reviewer JWT is omitted entirely, Vault uses the logging-in pod's token to call TokenReview, which works only when that token itself carries permission to review tokens, and is not the design to rely on.

The role is the boundary

vault-role.sh
vault policy write payments-db - <<'EOF'
path "database/creds/payments" { capabilities = ["read"] }
EOF
vault write auth/kubernetes/role/payments \
bound_service_account_names=payments \
bound_service_account_namespaces=shop \
audience=vault \
token_policies=payments-db \
token_ttl=20m \
token_type=batch # the docs' recommendation for machine logins: no lease, cheaper at scale
deployment.yaml (excerpt): a token minted for Vault, not the default one
volumes:
- name: vault-token
projected:
sources:
- serviceAccountToken:
audience: vault # matches the role's audience
expirationSeconds: 600
path: token

Both bound fields accept *, and a wildcard namespace is the mistake that turns a dev-namespace compromise into a production database read, because service account names repeat across namespaces by design. bound_service_account_namespace_selector exists for the label-driven case and needs Vault to read namespaces. The audience closes a subtler hole: the default token the kubelet projects is intended for the API server, and a role that accepts it accepts a token that every admission webhook and sidecar in the pod can also read. A projected token with audience: vault is usable only against a Vault role expecting it. Vault 1.20 logs a warning for roles without an audience; HashiCorp's release notes say the warning will not turn into a requirement, so set it because it is correct, not because an upgrade will force it.

Each login creates or reuses a Vault identity entity, and by default the alias is the service account's UID rather than its name (alias_name_source). That is the safer default: deleting and recreating a service account produces a new identity, so entity-level policies and audit history do not silently transfer to whatever reuses the name. The audit log then records which service account, in which namespace, read which path, which is the evidence a shared token never produced.

Short Vault tokens do not shorten the secret they fetch
A 20-minute Vault token that reads a static KV password returns a value with no expiry, and a pod compromised for one second has it for as long as the password lasts. The auth method buys short-lived identity; only the secrets engine decides whether what it hands out is short-lived too. Prefer database, cloud and PKI engines for anything a pod holds, and keep static KV for the values nothing can mint.

Two ways to consume the login

deployment.yaml (excerpt): Agent injector
metadata:
annotations:
vault.hashicorp.com/agent-inject: "true"
vault.hashicorp.com/role: "payments"
vault.hashicorp.com/agent-inject-secret-db: "database/creds/payments"
vault.hashicorp.com/agent-inject-template-db: |
{{- with secret "database/creds/payments" -}}
DB_USER={{ .Data.username }}
DB_PASS={{ .Data.password }}
{{- end -}}
bash — the pod sees a file; Vault sees a lease
kubectl -n shop exec deploy/payments -c payments -- cat /vault/secrets/db
DB_USER=v-kubernetes-payments-5Kz3
DB_PASS=…
vault list sys/leases/lookup/database/creds/payments | head -3
Keys
----
h7Qw…
the database user exists for the lease; the agent renews it and rewrites the file before it expires

The injector's sidecar logs in with the pod's token, renders the template to a shared in-memory volume, and keeps the lease renewed; the application reads a file and never holds a Vault token. External Secrets Operator uses the same auth method from the operator's side and produces a Kubernetes Secret instead, which suits GitOps manifests and Secret-shaped consumers at the cost of a copy in etcd. Either way, the login is the same role, and the audit trail in Vault shows a service account name, not a shared token.

Go deeper in a courseVault from dev to productionAuth methods, policies, dynamic secrets engines and the operational side of running Vault.View course

Related posts

Quick reference