Authenticate Kubernetes Pods to Vault via the Kubernetes Auth Method, then automate secret provisioning with the Vault Agent Sidecar Injector and the Vault Secrets Operator (VSO) that syncs secrets into native Kubernetes Secrets.

After covering direct application integration into Vault via the SDK in episode 16 — complete with retry, renewal, and fallback patterns — this episode covers the most widely used integration in the cloud-native world: Vault with Kubernetes.
Why is this topic important? Almost every company using Vault in production runs its applications on Kubernetes. The problem: the traditional way of giving secrets to Pods — writing secret values directly into a Kubernetes Secret — still stores secrets statically in etcd, is easily readable by anyone with access, and is never authenticated to Vault. In this episode we'll solve that problem with three main mechanisms: Kubernetes Auth Method for authentication, Vault Agent Sidecar Injector for automatic secret injection into Pods, and Vault Secrets Operator (VSO) for synchronizing secrets into native Kubernetes Secrets. Let's dissect them one by one.
auth/kubernetes)The Kubernetes Auth Method is how Pods authenticate to Vault without needing to store a token or secret ID. The concept: every Pod in Kubernetes automatically has a ServiceAccount, and every ServiceAccount has an attached JWT token. This JWT becomes the Pod's "identity proof" to Vault.
The authentication flow looks like this:
Pod (SA: web-sa)
│ 1. send ServiceAccount JWT to Vault: auth/kubernetes/login
▼
Vault ──2. verify JWT──▶ Kubernetes API (TokenReview)
▲ │ 3. valid / invalid + SA & namespace claims
└──4. issue Vault token ───┘Vault doesn't guess whether the JWT is authentic — it asks the Kubernetes API Server to verify the token via the TokenReview endpoint. That way, Vault fully trusts that the token really belongs to a specific ServiceAccount in a specific namespace.
The first step is enabling the auth method and connecting it to the cluster. Vault needs three things: the Kubernetes API address, the cluster's CA certificate, and the JWT of a service account authorized to perform TokenReview:
vault auth enable kubernetes
vault write auth/kubernetes/config \
kubernetes_host="https://kubernetes.default.svc" \
token_reviewer_jwt="$(cat /var/run/secrets/kubernetes.io/serviceaccount/token)" \
kubernetes_ca_cert=@/var/run/secrets/kubernetes.io/serviceaccount/ca.crtImportant
The config command above is run from inside the cluster (e.g. via kubectl exec or a bootstrap Job), so token_reviewer_jwt is taken from the active service account. In a more secure setup, token_reviewer_jwt is generated once then stored as a secret — or use tooling like the vault-kubernetes-auth helper run when the cluster is first built.
After configuration, we create a role that binds a specific ServiceAccount to Vault policies. This is the crucial access control point: only Pods with an SA named web-sa in the default namespace may log in through the web role:
vault write auth/kubernetes/role/web \
bound_service_account_names="web-sa" \
bound_service_account_namespaces="default" \
policies="web-app" \
ttl="1h"Also note the Vault policy attached to the role must allow access to the paths the app needs:
path "secret/data/myapp" {
capabilities = ["read"]
}Warning
Always specify bound_service_account_names and bound_service_account_namespaces. A role without these bindings is like a door without a lock — every ServiceAccount in the cluster can log in using that role and inherit its policies. This is one of the most dangerous misconfigurations in production.
The Kubernetes Auth Method lets Pods authenticate, but we still need a way to deliver secrets into the Pod. That's where the Vault Agent Sidecar Injector comes in: a mutating admission webhook that automatically adds a Vault Agent sidecar container to Pods based on annotations.
How it works:
vault.hashicorp.com/... annotations.vault-agent sidecar container and a /vault/secrets memory volume./vault/secrets/config.Before the deployment runs, there are two mandatory preparations. First, create the ServiceAccount web-sa — the identity that will prove itself to Vault:
apiVersion: v1
kind: ServiceAccount
metadata:
name: web-sa
namespace: defaultSecond, install the Vault Agent Injector in the cluster. The most common way is via the Helm chart hashicorp/vault with the injector sub-chart (or installing the standalone injector chart):
helm repo add hashicorp https://helm.releases.hashicorp.com
helm install vault hashicorp/vault \
--set injector.enabled=true \
--set injector.logLevel=infokubectl get mutatingwebhookconfiguration
kubectl get pods -n vaultTip
The injector only processes Pods that have the annotation vault.hashicorp.com/agent-inject: "true" — other Pods in the cluster are unaffected. This is a huge design advantage: you can enable Vault per-application gradually without changing the whole cluster.
apiVersion: apps/v1
kind: Deployment
metadata:
name: myapp
namespace: default
spec:
replicas: 1
selector:
matchLabels:
app: myapp
template:
metadata:
labels:
app: myapp
annotations:
vault.hashicorp.com/agent-inject: "true"
vault.hashicorp.com/role: "web"
vault.hashicorp.com/agent-inject-secret-config: "secret/data/myapp"
vault.hashicorp.com/agent-inject-template-config: |
{{- with secret "secret/data/myapp" }}
DB_HOST={{ .Data.data.DB_HOST }}
DB_PASSWORD={{ .Data.data.DB_PASSWORD }}
{{- end }}
spec:
serviceAccountName: web-sa
containers:
- name: myapp
image: myapp:1.0.0
command: ["sh", "-c", "sleep infinity"]Explanation of the key annotations:
vault.hashicorp.com/agent-inject: "true" — marker that the injector must process this Pod.vault.hashicorp.com/role: "web" — the K8s auth role the sidecar uses to log in (must match bound_service_account_names).vault.hashicorp.com/agent-inject-secret-config — the destination file name (config) separated from the secret path (secret/data/myapp) by a -. The result is the secret rendered to /vault/secrets/config.agent-inject-template-config — the custom template that controls the file contents. Without this template, the injector uses the built-in template (YAML format) which is often not a good fit.After the Pod runs, let's look at what the sidecar produced:
kubectl exec deploy/myapp -- cat /vault/secrets/configDB_HOST=postgres.internal.local
DB_PASSWORD=a9f3!sVx#2kQTip
The /vault/secrets volume is an emptyDir of type memory (medium: Memory) — secret files live in RAM, not on disk, so secrets are never written to node persistent storage. This is one reason the sidecar injector is safer than a plain static Secret.
This is the injector's main advantage: if the secret in Vault changes, the sidecar agent detects the change (via polling Vault) and re-renders the file automatically. An app that reads the file every time it needs a connection will automatically use the latest value. But note — an app that already holds old values in memory (e.g. a connection pool built at startup) won't be affected until it re-reads the file or is restarted. For apps that need to respond to rotation quickly, add a reloader like automatic restart when the file changes.
The sidecar injector solves the injection problem, but there's another case: applications (or tooling like Helm charts, ingress, etc.) actually need a native Kubernetes Secret — not a file inside the Pod. That's where the Vault Secrets Operator (VSO) comes in: this operator continuously syncs secrets from Vault into native Kubernetes Secrets, complete with automatic rotation.
Its architecture consists of several CRDs (Custom Resource Definitions):
VaultConnection — the Vault address to connect to.VaultAuth — authentication credentials (e.g. via the Kubernetes Auth Method).VaultStaticSecret — the secret source and sync destination (destination).VaultDynamicSecret for dynamic credentials, and VaultPKISecret for certificates — let's focus on static first.)apiVersion: secrets.hashicorp.com/v1beta1
kind: VaultConnection
metadata:
name: vault
namespace: default
spec:
address: http://vault:8200
---
apiVersion: secrets.hashicorp.com/v1beta1
kind: VaultAuth
metadata:
name: vault-auth
namespace: default
spec:
method: kubernetes
mount: kubernetes
kubernetes:
role: web
serviceAccount: web-sa
---
apiVersion: secrets.hashicorp.com/v1beta1
kind: VaultStaticSecret
metadata:
name: myapp-secret
namespace: default
spec:
vaultAuthRef: vault-auth
mount: secret
type: kv-v2
path: myapp
refreshAfter: 1h
destination:
name: myapp-secrets
create: true
overwrite: trueExplanation of the important parts:
vaultAuthRef points to the VaultAuth that uses Kubernetes Auth with the role web and service account web-sa.mount: secret + type: kv-v2 + path: myapp means the source is secret/data/myapp on the KV v2 secrets engine.refreshAfter: 1h determines the sync interval — every hour the operator compares the secret version in Vault and updates the Kubernetes Secret if there are changes.destination defines the Secret named myapp-secrets that will be created/updated.VSO also supports dynamic credentials via the VaultDynamicSecret CRD. For example, fetching temporary database credentials from the database secrets engine and syncing them to a Secret:
apiVersion: secrets.hashicorp.com/v1beta1
kind: VaultDynamicSecret
metadata:
name: db-creds
namespace: default
spec:
vaultAuthRef: vault-auth
mount: database
role: web-role
destination:
name: db-creds
create: true
revoke: true
renewalPercent: 60Important
On VaultDynamicSecret, the revoke: true attribute ensures dynamic credentials are revoked when the Kubernetes Secret is deleted or the CRD is removed — keeping the short-lived credentials principle. Meanwhile renewalPercent: 60 makes the operator renew the lease once it reaches 60% of its TTL, so the app never uses near-expired credentials.
The result: a native Kubernetes Secret ready for the app to use:
kubectl get secret myapp-secrets -o jsonpath='{.data}'{
"DB_HOST": "cG9zdGdyZXMuaW50ZXJuYWwubG9jYWw=",
"DB_PASSWORD": "YTlmMyFzVngjMmtR"
}Note
VSO handles rotation intelligently: when the secret in Vault changes (a new KV v2 version), the operator automatically updates the Kubernetes Secret and — if enabled via refreshAfter/events — triggers a deployment rollout consuming that secret. This eliminates the manual "update secret, restart pod" work.
| Aspect | Agent Injector (Sidecar) | Vault Secrets Operator (VSO) |
|---|---|---|
| Deliverable form | File at /vault/secrets/... inside the Pod | Native Kubernetes Secret |
| Secret stored in etcd | No (RAM emptyDir) | Yes (as a regular Secret) |
| App access | Read a local file | Standard K8s secret mount / env |
| Ideal for | Apps that need secrets as files/config | Helm charts, ingress TLS, tooling that needs a Secret |
| Vault API involvement | Handled by the sidecar | Handled by the operator (transparent) |
| Rotation | File re-rendered; app needs a reload | Secret updated; can trigger rollout |
Warning
Because VSO stores secrets as a regular Kubernetes Secret, all values ultimately reside in etcd (in base64, not strongly encrypted). If your regulations require secrets to never sit in storage, use the Sidecar Injector. VSO is better suited for tooling that genuinely needs native Secret objects.
| Mistake | Symptom | Solution |
|---|---|---|
Role without bound_service_account_* | All SAs can log in — security hole | Always specify SA name + namespace |
| Annotation template wrongly indented (block scalar ` | `) | Rendered file invalid / empty |
| Vault policy doesn't allow the secret path | Sidecar error permission denied | Make sure the web role policy covers secret/data/myapp |
| SA not created or wrong namespace | Pod login fails: service account not found | Verify serviceAccountName & create the SA via RBAC |
token_reviewer_jwt from an SA without TokenReview permission | Login always rejected by Vault | Make sure the bootstrap SA has the ClusterRole system:auth-delegator |
Forgetting serviceAccountName in the pod spec | Injector uses the default SA — bound fails | Explicitly set serviceAccountName |
Wrong KV v2 path (secret/myapp vs secret/data/myapp) | Secret not found | For kv-v2 always use secret/data/... |
| Assuming VSO also provisions dynamic credentials | Only VaultStaticSecret gets synced | Use VaultDynamicSecret for dynamic creds |
| Native secret from VSO mounted as env | Value not updated until pod restart | Use a volume mount + reloader if you need live rotation |
Caution
The correct troubleshooting order when a sidecar fails: (1) check kubectl logs <pod> vault-agent for login/policy errors, (2) verify the SA↔namespace↔policy role binding, (3) test path access with vault read secret/data/myapp using a token from that role. Don't immediately change configuration without knowing which layer the problem is in.
In this episode 17 we've covered the Vault-Kubernetes integration thoroughly: Kubernetes Auth Method which verifies the ServiceAccount JWT through the TokenReview API, Vault Agent Sidecar Injector which automatically injects a sidecar to render secrets to /vault/secrets/config in RAM, and Vault Secrets Operator (VSO) which syncs secrets into native Kubernetes Secrets with automatic rotation.
The core lesson of this episode: a Pod's identity in Kubernetes (ServiceAccount) is a strong authentication key to Vault — no more storing tokens anywhere. Choose the injector when you want secrets to end up in the Pod's memory, choose VSO when your tooling needs native Secret objects.
In episode 18 we move into the world of development automation: Vault integration in CI/CD pipelines (GitHub Actions & GitLab CI) — how pipelines fetch temporary credentials from Vault without storing static secrets in the repository. Keep your enthusiasm up!