How to Use External Secrets Operator with Vault on Kubernetes (2026)
This guide is for platform engineers who want Kubernetes Secrets to come from a real secrets manager instead of from YAML in Git or values pasted into CI. By the end you will have External Secrets Operator (ESO) syncing a secret from HashiCorp Vault into a namespace using Kubernetes auth with no static token, a rotation path that actually reaches your pods, and the same setup adapted for OpenBao and AWS Secrets Manager. Versions checked on 20 October 2026: ESO v2.13.0 (Helm chart 2.13.0, tested by the project against Kubernetes 1.36), the HashiCorp Vault Helm chart 0.34.1 (Vault 2.0.4 image by default), and OpenBao 2.7.1. You need a test cluster where you are cluster-admin, kubectl, and Helm 3.
Is External Secrets Operator still maintained in 2026?
Worth answering first, because a lot of search results from late 2025 say no. On 13 August 2025 the maintainers merged a README notice pausing all official releases, patches and images until the project had at least five consistent long-term maintainers. The notice was removed on 12 September 2025 after new maintainers joined, v0.20.0 shipped on 22 September, v1.0.0 on 7 November 2025, and v2.0.0 on 6 February 2026 (its breaking change was removing the unmaintained Alibaba and Device42 providers). Since then the project has shipped a minor roughly every two to three weeks, up to v2.13.0 on 16 October 2026.
The catch is the support policy: only the current minor is supported, and each minor reaches end of life when the next one ships. Plan to upgrade ESO often, or pin and accept that you are on your own. That episode is also a reason to keep your manifests close to the standard external-secrets.io/v1 API so you could move to a fork or alternative without rewriting everything.
Step 1: Install External Secrets Operator with Helm
The chart installs the CRDs by default. They exceed the 256 KB annotation limit, so if you ever apply them outside Helm, use server-side apply.
helm repo add external-secrets https://charts.external-secrets.io
helm repo update
helm install external-secrets external-secrets/external-secrets \
--namespace external-secrets --create-namespace \
--version 2.13.0 \
--wait
kubectl get pods -n external-secrets
Expected output is similar to this, three Deployments (controller, webhook, cert controller):
NAME READY STATUS RESTARTS AGE
external-secrets-7c9f8d6b5d-x2k4q 1/1 Running 0 40s
external-secrets-cert-controller-5d8c7f9b8-9wq7m 1/1 Running 0 40s
external-secrets-webhook-6b7d9c8f4-lp2zn 1/1 Running 0 40s
Check that the API you will write against is v1. ESO stopped serving v1beta1 in v0.17.0, so old blog posts with v1beta1 manifests will fail.
kubectl api-resources --api-group=external-secrets.io
# Expect externalsecrets (es), secretstores (ss), clustersecretstores (css),
# clusterexternalsecrets (ces), pushsecrets (ps) ... all at external-secrets.io/v1
Step 2: Run Vault and write a test secret
For a lab, run Vault in dev mode: in-memory storage, auto-unsealed, root token root, a KV v2 engine already mounted at secret/. Never use dev mode for anything real. The chart grants Vault's service account system:auth-delegator by default (server.authDelegator.enabled: true), which Vault needs to call the TokenReview API.
helm repo add hashicorp https://helm.releases.hashicorp.com
helm install vault hashicorp/vault \
--namespace vault --create-namespace \
--version 0.34.1 \
--set server.dev.enabled=true \
--set injector.enabled=false
kubectl wait -n vault --for=condition=Ready pod/vault-0 --timeout=120s
# Helper so every command runs inside the Vault pod with the dev token
vlt() { kubectl exec -n vault vault-0 -- env VAULT_TOKEN=root vault "$@"; }
vlt kv put secret/myapp/db username=app password='s3cr3t-v1'
vlt kv get secret/myapp/db
Vault 2.2.0 shipped on 15 October 2026 with changes to the container's IPC_LOCK capability and mlock behaviour, so read its release notes before overriding server.image.tag in production.
Step 3: Configure Vault Kubernetes auth for ESO
ESO will present a short-lived service account token; Vault validates it with TokenReview and returns a Vault token with a read-only policy. Create the policy and role. KV v2 paths need the data/ and metadata/ prefixes in policies even though the CLI hides them.
kubectl exec -n vault vault-0 -i -- env VAULT_TOKEN=root vault policy write eso-read - <<'EOF'
path "secret/data/myapp/*" {
capabilities = ["read"]
}
path "secret/metadata/myapp/*" {
capabilities = ["read", "list"]
}
EOF
vlt auth enable kubernetes
# Vault runs in-cluster, so it reads its own SA token and CA from the pod
kubectl exec -n vault vault-0 -- sh -c \
'VAULT_TOKEN=root vault write auth/kubernetes/config \
kubernetes_host="https://${KUBERNETES_SERVICE_HOST}:${KUBERNETES_SERVICE_PORT}"'
vlt write auth/kubernetes/role/eso \
bound_service_account_names=eso-vault \
bound_service_account_namespaces=external-secrets \
audience=vault \
policies=eso-read \
ttl=1h
I ran the policy and role commands against a local Vault dev server (CLI 1.18.3); reading the role back returns audience vault, bound_service_account_names [eso-vault] and token_policies [eso-read]. Set the audience explicitly: Vault 1.20 started warning about roles without one, and the ESO Vault provider docs say 1.21 and later require it. HashiCorp staff later said on their forum that the 1.21 enforcement was rolled back, so it may only warn on your version, but setting it costs nothing.
Step 4: Create a ClusterSecretStore
Create a dedicated service account for ESO to impersonate (ESO mints tokens for it through the TokenRequest API, which its chart RBAC already allows), then the store. A ClusterSecretStore can be referenced from any namespace; use a namespaced SecretStore instead if each team should bring its own Vault role.
# cluster-secret-store.yaml
apiVersion: v1
kind: ServiceAccount
metadata:
name: eso-vault
namespace: external-secrets
---
apiVersion: external-secrets.io/v1
kind: ClusterSecretStore
metadata:
name: vault-backend
spec:
provider:
vault:
server: "http://vault.vault.svc:8200" # use https and caBundle outside a lab
path: "secret" # KV mount
version: "v2" # KV engine version
auth:
kubernetes:
mountPath: "kubernetes"
role: "eso"
serviceAccountRef:
name: "eso-vault"
namespace: "external-secrets" # required for ClusterSecretStore
audiences:
- vault # must match the Vault role
kubectl apply -f cluster-secret-store.yaml
kubectl get clustersecretstore vault-backend
NAME AGE STATUS CAPABILITIES READY
vault-backend 5s Valid ReadWrite True
If READY is False, stop here and run kubectl describe css vault-backend; the event message carries Vault's error verbatim. An ExternalSecret will never sync against a store that is not valid.
Step 5: Create an ExternalSecret and check the Kubernetes Secret
# external-secret.yaml
apiVersion: external-secrets.io/v1
kind: ExternalSecret
metadata:
name: myapp-db
namespace: myapp
spec:
refreshInterval: 5m # default is 1h0m0s
secretStoreRef:
kind: ClusterSecretStore
name: vault-backend
target:
name: myapp-db # the Secret ESO will create
creationPolicy: Owner # Secret is deleted with the ExternalSecret
data:
- secretKey: DB_USERNAME
remoteRef:
key: myapp/db # path under the store's KV mount, no data/ prefix
property: username
- secretKey: DB_PASSWORD
remoteRef:
key: myapp/db
property: password
kubectl create namespace myapp
kubectl apply -f external-secret.yaml
kubectl get externalsecret -n myapp
kubectl get secret myapp-db -n myapp -o jsonpath='{.data.DB_PASSWORD}' | base64 -d; echo
NAME STORETYPE STORE REFRESH INTERVAL STATUS READY
myapp-db ClusterSecretStore vault-backend 5m SecretSynced True
s3cr3t-v1
To pull every key at a path instead of listing them, replace data with dataFrom: [{extract: {key: myapp/db}}]. Remember the result is an ordinary Kubernetes Secret: base64, readable by anyone with get secrets in that namespace, and stored in etcd. Keep RBAC on Secrets tight and turn on encryption at rest.
Step 6: Rotate a secret and get pods to notice
Rotation has two halves, and most incidents happen in the second one. ESO handles the first: change the value in Vault and the Secret updates on the next refresh. You can skip the wait with the documented force-sync annotation.
vlt kv put secret/myapp/db username=app password='s3cr3t-v2'
# Do not wait 5 minutes: change any annotation to trigger a reconcile
kubectl annotate es myapp-db -n myapp force-sync="$(date +%s)" --overwrite
kubectl get secret myapp-db -n myapp -o jsonpath='{.data.DB_PASSWORD}' | base64 -d; echo
# s3cr3t-v2
The second half is the pod. Per the Kubernetes Secret docs, Secrets mounted as volumes are updated eventually by the kubelet (not with subPath), but environment variables are fixed at container start. If your app reads env vars, something has to restart it. Stakater Reloader does that by watching referenced Secrets:
apiVersion: apps/v1
kind: Deployment
metadata:
name: myapp
namespace: myapp
annotations:
secret.reloader.stakater.com/auto: "true" # rolling restart when a referenced Secret changes
spec:
# ... selector, template, envFrom: [{secretRef: {name: myapp-db}}]
Without Reloader, kubectl rollout restart deployment/myapp -n myapp after the sync does the same by hand. For database passwords, keep the old credential valid until every pod has restarted, or you will rotate yourself into an outage.
Step 7: Use OpenBao or AWS Secrets Manager instead
OpenBao. OpenBao keeps Vault's API, and the ESO docs say to use the Vault provider for it. Install the OpenBao chart (0.30.4, OpenBao 2.7.1), repeat Steps 2 and 3 with the bao CLI in place of vault, and point the same ClusterSecretStore at http://openbao.openbao.svc:8200. A native openbao provider is being built in the ESO tree (Kubernetes auth landed in August 2026), but the documented path in v2.13 is still the Vault provider.
helm repo add openbao https://openbao.github.io/openbao-helm
helm install openbao openbao/openbao --namespace openbao --create-namespace \
--version 0.30.4 --set server.dev.enabled=true --set injector.enabled=false
AWS Secrets Manager on EKS. Give a service account an IAM role through IRSA, scope the policy to a name prefix, and reference it with auth.jwt, following the ESO AWS access docs. ESO then holds no AWS keys at all.
apiVersion: v1
kind: ServiceAccount
metadata:
name: eso-aws
namespace: external-secrets
annotations:
eks.amazonaws.com/role-arn: arn:aws:iam::123456789012:role/eso-read-prod # role allows secretsmanager:GetSecretValue on prod/*
---
apiVersion: external-secrets.io/v1
kind: ClusterSecretStore
metadata:
name: aws-sm
spec:
provider:
aws:
service: SecretsManager
region: eu-west-1
auth:
jwt:
serviceAccountRef:
name: eso-aws
namespace: external-secrets
An ExternalSecret against this store uses remoteRef.key: prod/myapp/db (the secret name) and property for a JSON field.
External Secrets Operator alternatives
ESO is not the only answer, and for some teams it is the wrong one.
| Tool | Latest release | How it works | Pick it when |
|---|---|---|---|
| External Secrets Operator | v2.13.0 (16 Oct 2026) | Controller syncs backend values into Kubernetes Secrets | Many backends, apps expect normal Secrets |
| Secrets Store CSI Driver | v1.6.1 (9 Sep 2026) | Mounts values as files at pod start via a provider (Vault, AWS, Azure, GCP) | You want no Secret object in etcd by default |
| Sealed Secrets | v0.40.0 (10 Sep 2026) | Encrypt with a cluster public key, commit ciphertext, controller decrypts | Git is the source of truth and there is no external store |
| SOPS | v3.13.3 (23 Jul 2026) | Encrypts files with KMS, age or PGP; decrypted by Flux, Argo CD plugins or CI | GitOps with KMS and no in-cluster controller |
My rule of thumb: if a secrets manager already exists in the organisation, ESO or the CSI driver; if it does not, Sealed Secrets or SOPS are honest about the fact that Git is your vault, and that is fine for small teams.
Troubleshooting
Start with kubectl describe es <name> -n <ns>, then kubectl describe css <store>, then kubectl logs -n external-secrets deploy/external-secrets. The Ready condition's reason (SecretSynced, SecretSyncedError, SecretMissing, SecretOwnedByOther) and the warning events tell you which layer failed.
| Symptom | Likely cause | Fix |
|---|---|---|
SecretSyncedError, event says permission denied from Vault |
Policy path missing data/ (KV v2), or role bound to the wrong SA or namespace |
Policy on secret/data/<path>; check bound_service_account_names and _namespaces match serviceAccountRef |
Store not Ready, Vault login error mentioning the audience or aud claim |
audiences in the store does not match the Vault role's audience |
Set both to the same value (vault in this guide) |
no matches for kind "ExternalSecret" in version "external-secrets.io/v1beta1" |
Manifest from before v0.17.0 | Change apiVersion to external-secrets.io/v1; no other change needed |
SecretSyncedError, cannot find secret data for key |
Wrong remoteRef.key (included secret/ or data/), or wrong property |
Use the path relative to the store's path; confirm with vault kv get |
Store fails with serviceaccounts "eso-vault" not found |
ClusterSecretStore ref without namespace, so ESO looked in the wrong place |
Add namespace to serviceAccountRef (and to every secretRef in cluster stores) |
Vault login returns 403; error mentions tokenreviews.authentication.k8s.io forbidden |
Vault's SA lacks system:auth-delegator |
Keep server.authDelegator.enabled: true in the Vault chart or create the ClusterRoleBinding |
SecretOwnedByOther or event secrets "x" already exists |
A Secret with the target name exists and is owned by something else | Delete or rename it, or use creationPolicy: Merge deliberately |
| Secret updated, app still uses the old value | Env vars are read once at container start | Reloader annotation or kubectl rollout restart; or mount as a volume without subPath |
CRD install fails with metadata.annotations: Too long |
Client-side apply of the 256 KB CRDs | Let Helm manage CRDs, or kubectl apply --server-side |
| Value changes in Vault never arrive | refreshInterval: 0, or refreshPolicy: CreatedOnce or OnChange |
Set a non-zero interval with Periodic, or force-sync after each change |
FAQ
What is the difference between SecretStore and ClusterSecretStore?
A SecretStore is namespaced and can only be used by ExternalSecret objects in the same namespace, which suits teams owning their own backend credentials. A ClusterSecretStore is cluster-scoped and usable from any namespace (optionally restricted with spec.conditions). Any secret reference inside a cluster store must state its namespace explicitly.
How often does External Secrets Operator refresh secrets?
The default refreshInterval is one hour (1h0m0s) with the default Periodic refresh policy. Set a shorter interval per ExternalSecret, set 0 to create once, or trigger an immediate sync by changing an annotation such as force-sync. Very short intervals across thousands of objects mean a lot of backend API calls, which costs money on AWS and load on Vault.
Is External Secrets Operator safe to use after the 2025 pause?
The pause lasted from 13 August to 12 September 2025, releases resumed with v0.20.0, and the project has shipped frequently since, reaching v2.13.0 in October 2026. The real operational risk is the policy of supporting only the latest minor, so budget for regular upgrades and read release notes for breaking changes such as provider removals.
Does ESO make Kubernetes Secrets encrypted?
No. ESO fetches from an encrypted store and writes a normal Kubernetes Secret, which is base64 in the API and only encrypted in etcd if you configure encryption at rest. If you want values never to land in etcd, look at the Secrets Store CSI Driver, keeping in mind its optional sync-to-Secret feature brings the same trade-off back.
In production, the first thing I check when a sync breaks is the store, not the ExternalSecret: usually the store is not Ready and every downstream object is failing for the same reason. The second thing I check is whether the app restarts when the Secret changes, because a rotation that updates etcd but not the running process is not a rotation. Why bother with all this? Because, as the numbers in Secrets Sprawl 2026 show, most leaked keys stay valid for years, and the teams that can rotate quickly are the ones with one store and an automated delivery path. If your workloads run on a cloud with workload identity, read Workload Identity Federation Explained too, since the best secret is the one you never had to store. Official references: the ESO documentation, the Vault Kubernetes auth method, and the Kubernetes Secrets concept page.
Related: Who Is This Agent? Non-Human Identity in 2026, From the RFCs to the Breaches, Kubernetes as the Agent Control Plane in 2026: Agent Sandbox Hit v1.0, 300 Claims a Second, and DRA in Every Supported Release and Infrastructure as Code in 2026: Two Forks at 1.16 and 1.13, a $6.4B Owner, 912 Public State Files, and One Agent That Ran terraform destroy.
I'm Ajay Kumar — I build and operate PandaStack, an open-source Firecracker microVM cloud for AI agents, and write about the infrastructure and tooling around it.
Need this kind of infrastructure work? See what I do or email hello@ajayk.sh.
Related
How to Set Up cert-manager with Let's Encrypt and Fix Errors (2026)
Install cert-manager v1.21 with Helm, create Let's Encrypt ClusterIssuers for HTTP-01 (Gateway API or Ingress) and DNS-01, then debug a Certificate stuck not ready by walking CertificateRequest, Order and Challenge, with rate limits, ARI and alerts.
14 minOct 20, 2026How to Detect Runtime Threats with Falco on Kubernetes (2026)
Step-by-step Falco 0.45 setup on Kubernetes: Helm chart 9.2.0 with the modern eBPF driver, default and custom rules, Falcosidekick alerts to Slack, tuning noise, Falco Talon response actions, drop metrics, and a troubleshooting table for driver errors.
15 minOct 20, 2026How to Write Kubernetes NetworkPolicies: Default Deny to Egress (2026)
Step-by-step Kubernetes NetworkPolicy guide for 2026: default deny, allowing DNS, namespace and pod selectors, egress control, Cilium and Calico support, ClusterNetworkPolicy v1alpha2, testing with netshoot, and a troubleshooting table for blocked traffic.
14 min