How to Use External Secrets Operator with Vault on Kubernetes (2026)

Oct 20, 2026 · 14 min · Ajay Kumar

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.

External Secrets Operator: where the value flows Secrets backend Vault, OpenBao or AWS Secrets Manager source of truth ClusterSecretStore server, KV path, auth SA token, audience how to reach it ESO controller reads ExternalSecret refreshInterval 1h fetch and write Kubernetes Secret owned by the ExternalSecret plain v1 Secret Pod volume: updates eventually. env vars: need a restart You write one store per backend, one ExternalSecret per app Arrows point from the object that reads to the object it depends on, then down to the pod. Rotation = write a new value in the backend, wait for refreshInterval (or force-sync), then reload pods.
Components and defaults from the ESO v2.13 documentation and CRDs; refreshInterval defaults to 1h0m0s.

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