External Secrets Operator: sync Vault into Kubernetes

Keep the source of truth in Vault and let the operator materialize Kubernetes Secrets your pods can mount.

Jul 29, 2025·Updated ·5 min readIntermediate·By SecOpsLog · documentation-verified

The application does not care where the database password came from; it reads DB_PASSWORD from a mounted Secret and connects. That indifference is the whole case for External Secrets Operator. The authoritative value stays in Vault, where it is versioned, audited and rotated, and the operator writes an ordinary Kubernetes Secret from it on a schedule, so a Deployment written before anyone said the word Vault keeps working unchanged. What ESO does not do is make the copy in etcd any less of a copy, and most of the decisions in this setup are about that.

External store → Kubernetes Secret

ESO syncs copies into the cluster. Rotate at the source of truth.

Vault / AWS external store External Secrets Operator SecretStore + ExternalSecret K8s Secret → Pod mount 1 SecretStore how to reach the backend auth: IRSA / Vault SA / keys cluster- or namespace-scoped 2 ExternalSecret which keys + refreshInterval maps remote → Secret data controller reconciles loop 3 Synced Secret native K8s Secret object mount / env as usual rotate at the store, not YAML ESO copies values into the cluster. The source of truth stays outside Kubernetes. store → SecretStore → ExternalSecret → Secret → Pod
Store — sourceESO — syncSecret — consume

The SecretStore is the trust boundary

secretstore.yaml
apiVersion: external-secrets.io/v1
kind: SecretStore # namespaced: this team's Vault role, nobody else's
metadata:
name: vault-kv
namespace: payments
spec:
provider:
vault:
server: https://vault.acme.internal:8200
path: secret # the KV mount
version: v2
auth:
kubernetes:
mountPath: kubernetes
role: payments-eso # bound to this namespace and this service account in Vault
serviceAccountRef:
name: payments-eso
audiences:
- vault

A SecretStore lives in one namespace and names the Vault role that namespace is allowed to use; a ClusterSecretStore is the same object cluster-wide, and belongs to the platform team for the few secrets every namespace needs. With serviceAccountRef, the operator requests a token for that service account and logs in to Vault with it, so the Vault role's bound_service_account_names and bound_service_account_namespaces are what stop the payments store from reading the billing team's paths. Leave the reference out and ESO authenticates with its own service account, which turns the operator into one identity that can read everything any store can read. The audiences list matches the audience on the Vault role; Vault 1.20 warns when a role has none, and HashiCorp's notes say the warning will not become a hard requirement, so treat it as good practice rather than an upgrade blocker.

The ExternalSecret decides what lands in etcd

externalsecret.yaml
apiVersion: external-secrets.io/v1
kind: ExternalSecret
metadata:
name: payments-db
namespace: payments
spec:
refreshInterval: 1h # how often ESO re-reads Vault and rewrites the Secret
secretStoreRef:
name: vault-kv
kind: SecretStore
target:
name: db-credentials # the Kubernetes Secret the Deployment already mounts
creationPolicy: Owner # deleted with the ExternalSecret
data:
- secretKey: DB_USERNAME
remoteRef:
key: payments/db # secret/data/payments/db in KV v2
property: username
- secretKey: DB_PASSWORD
remoteRef:
key: payments/db
property: password

Each data entry is one key in the resulting Secret, so the manifest doubles as an inventory of what is now readable by anyone with get on Secrets in the namespace. dataFrom with extract copies every property under a Vault path in one line, which is convenient for a legacy service that expects a dozen variables and is exactly the wrong default for a new one, because the inventory disappears. refreshInterval is the rotation budget: a value rotated in Vault reaches etcd on the next tick, and reaches a running process only if that process re-reads the mount or is restarted; that last hop is the reloader question in rotation without an outage.

target.template is the feature that removes the last reason to keep a hand-made Secret: it renders the fetched values into whatever shape the consumer needs, a kubernetes.io/dockerconfigjson for an image pull secret, a full application.yaml for a service that only reads one file, or a tls Secret from a certificate stored in KV. The template lives in Git with the ExternalSecret and the values do not, the division GitOps wanted all along.

kubectl — synced, then read back the way an attacker would
kubectl -n payments get externalsecret payments-db
NAME STORE REFRESH STATUS READY
payments-db vault-kv 1h SecretSynced True
kubectl -n payments get secret db-credentials -o jsonpath="{.data.DB_PASSWORD}" | base64 -d
Xk9…
the copy is a real Secret: RBAC on secrets, encryption at rest and audit logging apply to it, not to Vault
A synced Secret is still a Secret
ESO replaces copy-paste and stale values, not the Kubernetes threat model. The materialised object is base64 in etcd until encryption at rest is configured, readable by any principal with get on Secrets in the namespace, and present in every etcd backup taken while it exists. Scope RBAC and encrypt etcd before syncing anything you would be unhappy to see in a ConfigMap, and use short-lived dynamic credentials for the workloads that can consume them.

How it fails, and what each failure looks like

ExternalSecret status conditions worth alerting on

SymptomUsual causeWhere to look
READY False, SecretSyncedError, message mentions permission deniedthe Vault policy does not cover the path, or the role is bound to a different namespace or service accountkubectl describe externalsecret; Vault audit log for the login and the read
READY False, message mentions 403 at loginthe service account token audience does not match the Vault role, or the role name is wrongthe audiences list and vault read auth/kubernetes/role/<name>
Secret exists but holds an old valuethe value rotated in Vault less than refreshInterval ago, or the pod cached it at startthe status.refreshTime field; then the reloader question
Secret deleted, then recreated with a new resourceVersionsomeone deleted the Secret by hand; creationPolicy: Owner recreates it on the next reconcileaudit log; this is working as designed

The operator is one of three delivery patterns, and it is the right one when GitOps manifests, existing Deployments and Secret-shaped consumers (a Helm chart's existingSecret value, an ingress TLS secret) are what you have. The Vault Agent injector avoids the etcd copy at the cost of a sidecar per pod, and both depend on the same Kubernetes auth role being bound tightly. What lands in etcd, and how that copy is protected, is the subject of Secrets at rest in etcd.

Related posts

Quick reference