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.
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 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.
What each link verifies
| Link | Checks | Failure it prevents |
|---|---|---|
| projected token | signed by the cluster, bound to the pod, expires (expirationSeconds), carries an aud claim | a token copied out of a pod being replayed months later |
| TokenReview | the token is valid now and names this service account in this namespace | a forged or revoked token |
| role binding | bound_service_account_names and _namespaces match; audience matches the aud claim | a same-named service account in another namespace assuming the role |
| policies and TTL | the returned Vault token can read only the listed paths and dies on schedule | one compromised pod becoming a reader of every secret |
Configure the mount: less than the tutorials say
vault auth enable kubernetes# in a pod, kubernetes_ca_cert and token_reviewer_jwt default to the pod's own CA and tokenvault 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 policy write payments-db - <<'EOF'path "database/creds/payments" { capabilities = ["read"] }EOFvault 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
volumes:- name: vault-tokenprojected:sources:- serviceAccountToken:audience: vault # matches the role's audienceexpirationSeconds: 600path: 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.
Two ways to consume the login
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 -}}
kubectl -n shop exec deploy/payments -c payments -- cat /vault/secrets/dbDB_USER=v-kubernetes-payments-5Kz3DB_PASS=…vault list sys/leases/lookup/database/creds/payments | head -3Keys----h7Qw…the database user exists for the lease; the agent renews it and rewrites the file before it expiresThe 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