Security Multi-cloud

Set Up External Secrets Operator to Sync Vault and AWS Secrets into Kubernetes

A payments team runs about forty microservices on an EKS cluster, and right now their secrets are a mess: some database passwords are committed to a private repo’s values.yaml (the kind of thing that ends with a 3 a.m. rotation and a postmortem), API keys for a third-party fraud feed are pasted into a Helm chart, and the platform team has no idea which workloads hold which credentials. Two source-of-truth systems already exist in the org — HashiCorp Vault holds the dynamic database credentials and the internal PKI, and AWS Secrets Manager holds the managed-service secrets (RDS rotation, the fraud-feed token). The ask from security is simple to state and annoying to deliver: “stop putting secrets in Git, pull them from the systems that already own them, and make them rotate.” This guide wires External Secrets Operator (ESO) to do exactly that — sync values from both Vault and AWS Secrets Manager into native Kubernetes Secret objects that applications consume normally, with no secret material ever sitting in a manifest or a chart.

ESO is the right tool because it inverts the usual problem. Instead of pushing secrets into the cluster (which is what Sealed Secrets and SOPS do — they encrypt material and commit the ciphertext), it runs a controller that pulls from an external store on a schedule, reconciles the result into a Kubernetes Secret, and re-pulls on a refresh interval so a rotation upstream propagates automatically. Your apps keep reading from a Secret (via env var or mounted file) and never learn there is a Vault or an AWS account behind it. The source of truth stays where it already lives; the cluster holds only a short-lived, narrowly-scoped projection of it.

By the end of this guide you will have the operator installed, a namespaced SecretStore for AWS Secrets Manager authenticated by IRSA (IAM Roles for Service Accounts), a SecretStore for Vault authenticated by the Kubernetes auth backend, a ClusterSecretStore shared across tenants, several ExternalSecret objects that template real config files, a PushSecret that writes a cluster-generated value back to the store, RBAC that stops one team reading another team’s secrets, and a troubleshooting playbook for the auth and sync failures that eat the most time. Every step has the real CLI and YAML, expected output, a validation check, and a teardown.

What problem this solves

The pain is concrete and universal: secrets sprawl into Git, charts, CI variables, and engineers’ laptops, and once they are there nobody can prove they are gone. A password in a values.yaml survives in the repo’s history forever even after you “delete” it; a fraud-feed token pasted into a chart is now in every developer’s clone and every CI cache. When security mandates rotation, the team faces a manual, error-prone redeploy of every affected workload — exactly the kind of change that goes out at 3 a.m. and breaks something. Meanwhile the platform team cannot answer the auditor’s two questions: which workloads hold which credentials, and when were they last rotated.

What breaks without a tool like ESO: rotations are deferred because they are scary, so credentials live for years; a leaked password requires hunting through repos and CI logs to find every copy; and there is no single, reviewable place that says “service X reads secret Y.” Teams that try to solve this by hand-writing a sidecar that calls Vault on startup end up with bespoke, untested glue in every service, no central health signal, and no rotation story.

Who hits this: anyone running stateful or integration-heavy workloads on Kubernetes with an existing secrets backend (Vault, AWS/GCP/Azure secret managers, 1Password, CyberArk) they are required to use — which in a regulated shop is everyone. It bites hardest on multi-tenant clusters where many teams share one control plane and you must guarantee tenant A cannot read tenant B’s secrets, and on multi-cloud estates where the same cluster pulls from two or three different stores. ESO’s whole value is that it makes “secret material never enters Git” the default, makes rotation a config change upstream rather than a redeploy, and gives the platform one CRD-shaped, GitOps-friendly, observable surface for all of it.

To frame the field before the deep dive, here is what ESO replaces and what it does not:

You currently… The failure mode What ESO changes What ESO does NOT do
Commit secrets to values.yaml Plaintext in Git history forever Only a reference (path/key) is in Git; material stays in the store It is not a secret store — you still need Vault/SM behind it
Paste tokens into Helm charts Copied to every clone and CI cache The chart references an ExternalSecret, not the value It does not encrypt the resulting Secret (etcd encryption is your job)
Hand-roll a Vault sidecar per app Bespoke, untested, no health signal One controller, one CRD, Prometheus metrics It does not inject directly into the process — it writes a Secret
Rotate by redeploying every workload Manual, error-prone, deferred refreshInterval re-pulls; rotation is a store-side change It does not restart pods on change (pair with Reloader)
Encrypt-and-commit (Sealed Secrets/SOPS) Ciphertext in Git; rotation = re-encrypt + commit Pull model; no ciphertext in Git at all It requires a reachable external store; the others are self-contained

Learning objectives

By the end of this guide you can:

Prerequisites & where this fits

You should be comfortable with core Kubernetes objects (Secret, ServiceAccount, Deployment, RBAC Role/RoleBinding), with kubectl and helm, and with the idea of a CRD + controller (ESO is a controller that reconciles custom resources into native Secrets). On the cloud side you should know that EKS can expose an OIDC provider that lets a Kubernetes ServiceAccount assume an IAM role (IRSA), and that Vault has pluggable auth backends, one of which (kubernetes) trusts a cluster’s ServiceAccount tokens. You do not need to be a Vault administrator, but you need an admin token once to configure the auth role and policy.

This is the implementation layer of a larger secrets story. Upstream of it sits the policy decision — keep secrets out of Git — covered in Eliminating Secret Sprawl: Pipeline Scanning, Push Protection, and Leaked-Credential Remediation. It pairs with workload-identity foundations from Set Up SPIFFE/SPIRE for Workload Identity and mTLS Across Heterogeneous Clusters (ESO’s auth is a workload-identity problem) and with the CI/CD-side pattern in Configure CyberArk Conjur for Secretless Application Credential Injection in CI/CD. The manifests below are designed to ship through GitOps — see Deploy Argo CD on Kubernetes with OIDC SSO, RBAC, and ApplicationSets for Multi-Cluster GitOps for the delivery layer. If you run on AKS or need the cluster fundamentals, AKS Architecture Explained: Managed Control Plane, Node Pools, and the Azure Integrations That Make It Tick is the adjacent platform read.

Versions assumed throughout (real, current at time of writing):

Component Version assumed Why it matters
Kubernetes / EKS 1.27+ (lab uses EKS 1.30) IRSA + projected ServiceAccount tokens require a modern cluster
External Secrets Operator 0.10.x (chart and app) v1beta1 API; PushSecret, find, generators are GA-ish here
Helm v3.14+ OCI + dependency handling for the chart
HashiCorp Vault 1.16+ KV v2, kubernetes auth, short-TTL roles
AWS CLI v2 eksctl/IAM/OIDC operations
vault CLI 1.16+ policy + role + KV operations

A note on the ESO API version: this guide uses external-secrets.io/v1beta1 because it is the version most clusters run today and what the 0.10.x chart serves. ESO has since promoted a stable v1 for the core kinds; the field shapes shown here are unchanged across that promotion, so the manifests are forward-portable — only the apiVersion string moves.

Core concepts

Five mental models make every later step obvious.

ESO is a pull-based reconciler, not a push or an injector. A controller watches ExternalSecret objects. For each one it authenticates to the named store, fetches the requested keys, renders them (optionally through a template), and writes a native Kubernetes Secret. It then re-runs on the refreshInterval. Nothing is pushed into the cluster from outside; the cluster pulls. That is the structural difference from Sealed Secrets/SOPS (which commit ciphertext) and from the CSI driver (which mounts secrets as a volume without ever creating a Secret object).

A SecretStore is the connection; an ExternalSecret is the request. The SecretStore (or ClusterSecretStore) holds how to reach a backend and how to authenticate — provider type, endpoint, region, mount path, and an auth block that points at a ServiceAccount (never a literal credential, if you do it right). The ExternalSecret holds what to fetch and where to put it — which keys/paths, which target Secret, refresh cadence, and any templating. One store is referenced by many ExternalSecrets.

Namespaced vs cluster scope is a blast-radius decision. A SecretStore and an ExternalSecret live in one namespace and can only be referenced there. A ClusterSecretStore and a ClusterExternalSecret are cluster-scoped: one definition, usable from (or pushed into) many namespaces. Cluster scope is convenient for a shared store but widens blast radius — an over-broad ClusterSecretStore plus a ClusterExternalSecret can fan a secret into namespaces that should never see it. The rule: namespaced by default, cluster-scoped only with a namespaceSelector and a tightly-scoped backend role.

Authentication is workload identity, not your login. ESO never uses your Vault token or AWS session. For AWS it uses IRSA: a ServiceAccount annotated with an IAM role ARN, whose projected token (audience sts.amazonaws.com) the controller exchanges for temporary AWS credentials via STS. For Vault it uses the Kubernetes auth backend: ESO mints a ServiceAccount token, Vault’s token_reviewer validates it against the cluster API, matches a role bound to that ServiceAccount, and returns a short-lived Vault token carrying a scoped policy. In both cases the long-lived secret of record never enters the cluster — only an ephemeral, least-privilege credential does.

The target Secret is still a normal Secret. ESO writes a bog-standard Kubernetes Secret, base64-encoded at rest in etcd. It is not magically encrypted. Apps consume it via secretKeyRef env vars or a mounted volume exactly as before — which is the point (zero app changes) — but it means etcd encryption-at-rest (KMS envelope encryption on EKS) and RBAC on the Secret are still your responsibility. ESO removes the Git exposure; it does not remove the need to protect the cluster copy.

The object model in one table

Pin down every kind before the deep sections. The glossary repeats these for lookup; this is the model side by side:

Kind Scope What it declares Auth lives here? Referenced by
SecretStore Namespace How to reach + auth to a backend Yes ExternalSecret (same ns)
ClusterSecretStore Cluster Same, shared across namespaces Yes ExternalSecret / ClusterExternalSecret anywhere
ExternalSecret Namespace What to pull → which target Secret No (references a store) The workload (reads the Secret)
ClusterExternalSecret Cluster One ExternalSecret fanned to many namespaces No Generates ExternalSecrets per matched ns
PushSecret Namespace Write a cluster Secret back to a store Yes (or references a store) — (writes upstream)
(generated) Secret Namespace The native Secret ESO owns n/a App via env/volume

The control loop, step by step

What the controller actually does on each pass, and where each step can fail (the troubleshooting section maps to these):

Step What happens Inputs Failure if it breaks
1. Resolve store Read the referenced SecretStore/ClusterSecretStore secretStoreRef SecretStoreNotFound / store not Ready
2. Authenticate IRSA STS exchange, or Vault k8s-auth login ServiceAccount token AccessDenied / Vault permission denied
3. Fetch GetSecret/GetAllSecrets per data/dataFrom Path/key, property SecretSyncedError (key not found)
4. Render Apply template/decoding, build the data map Fetched values, template Template parse/exec error
5. Reconcile Create/update the target Secret, set ownership target, creationPolicy Conflict (not owner), RBAC denied
6. Schedule Requeue after refreshInterval refreshInterval (no error; just cadence)

Installing External Secrets Operator with Helm

Add the chart repo and install the operator into its own namespace. The CRDs ship with the chart, so install them in the same step.

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 0.10.4 \
  --set installCRDs=true \
  --set webhook.port=9443 \
  --set serviceMonitor.enabled=true \
  --wait

serviceMonitor.enabled=true exposes the controller’s Prometheus metrics so your monitoring stack (Dynatrace, Datadog, or Prometheus/Grafana) can scrape sync health. Confirm the three components and the CRDs:

kubectl -n external-secrets get pods
kubectl get crd | grep external-secrets.io

You should see externalsecrets.external-secrets.io, secretstores.external-secrets.io, clustersecretstores.external-secrets.io, and pushsecrets.external-secrets.io, plus three Running pods. The three components and what each does:

Component Role Scale notes If it’s down
controller (external-secrets) Reconciles ExternalSecret/PushSecret; talks to providers Leader-elected; run ≥2 replicas in prod No secrets sync at all
webhook (external-secrets-webhook) Validating/converting admission for the CRDs 2+ replicas; needs a valid cert kubectl apply of CRs is rejected
cert-controller Issues/rotates the webhook’s TLS cert Single is fine Webhook cert expires → webhook fails

The Helm values worth knowing for a production install — defaults are fine for a lab, but these matter at scale:

Value Default What it controls When to change
installCRDs true Whether the chart manages CRDs Set false if CRDs are managed separately (GitOps)
replicaCount 1 Controller replicas 2+ in prod (leader election handles HA)
concurrent 1 Reconciles per controller worker Raise for thousands of ExternalSecrets
serviceMonitor.enabled false Emit a Prometheus ServiceMonitor true to alert on sync health
webhook.port 9443 Webhook listen port Avoid conflicts on hardened nodes
extraEnv [] Env for the controller Proxy settings, AWS region defaults
resources unset CPU/memory requests/limits Always set in prod

A note on installing the CRDs via GitOps: if Argo CD or Flux manages your cluster, set installCRDs=false in the release and apply the CRDs as a separate, earlier sync-wave so the chart upgrade does not race the CRD upgrade. Mixing Helm-managed CRDs with a GitOps-managed chart is the most common reason a CRD field “disappears” after an upgrade.

The AWS path: IRSA, IAM policy, and the AWS SecretStore

ESO must read Secrets Manager as an AWS principal, not with static keys. IRSA maps a Kubernetes ServiceAccount to an IAM role via the cluster’s OIDC provider — no keys land in the cluster. The chain is: ServiceAccount (annotated with the role ARN) → projected token with audience sts.amazonaws.com → ESO calls sts:AssumeRoleWithWebIdentity → temporary credentials → secretsmanager:GetSecretValue.

Step A — least-privilege IAM policy

Scope to the specific secret ARNs (never *). The trailing -* matches the random 6-character suffix AWS appends to every secret ARN:

cat > /tmp/eso-sm-policy.json <<'EOF'
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": [
        "secretsmanager:GetSecretValue",
        "secretsmanager:DescribeSecret"
      ],
      "Resource": [
        "arn:aws:secretsmanager:ap-south-1:111122223333:secret:payments/fraud-feed-*",
        "arn:aws:secretsmanager:ap-south-1:111122223333:secret:payments/rds-app-*"
      ]
    }
  ]
}
EOF

aws iam create-policy \
  --policy-name eso-secretsmanager-read \
  --policy-document file:///tmp/eso-sm-policy.json

The IAM actions ESO actually needs, by capability — grant only what the workload uses:

IAM action Needed for Grant when
secretsmanager:GetSecretValue Reading a secret’s value Always (the core read)
secretsmanager:DescribeSecret Version metadata, tags Recommended (used by find/version logic)
secretsmanager:ListSecrets dataFrom.find by tag/name regex Only if you use find discovery
secretsmanager:PutSecretValue PushSecret writing a new version Only on stores that accept pushes
secretsmanager:CreateSecret PushSecret creating a new secret Only if push may create secrets
kms:Decrypt Secret encrypted with a CMK If the secret uses a customer-managed KMS key

Step B — the IRSA role bound to a ServiceAccount

eksctl writes the OIDC trust policy correctly for you (in production this block lives in Terraform):

eksctl create iamserviceaccount \
  --cluster payments-prod \
  --namespace payments \
  --name eso-aws-sa \
  --role-name eso-aws-secretsmanager \
  --attach-policy-arn arn:aws:iam::111122223333:policy/eso-secretsmanager-read \
  --approve

That produces a ServiceAccount annotated with eks.amazonaws.com/role-arn. Verify it:

kubectl -n payments get sa eso-aws-sa -o jsonpath='{.metadata.annotations}'; echo

Step C — the AWS SecretStore

A SecretStore is namespaced and tells ESO how to reach a backend. This one points at Secrets Manager and authenticates via the IRSA ServiceAccount from Step B — note there are zero credentials in the manifest, only a reference to eso-aws-sa.

# aws-secretstore.yaml
apiVersion: external-secrets.io/v1beta1
kind: SecretStore
metadata:
  name: aws-secretsmanager
  namespace: payments
spec:
  provider:
    aws:
      service: SecretsManager
      region: ap-south-1
      auth:
        jwt:
          serviceAccountRef:
            name: eso-aws-sa

Apply it and confirm ESO can authenticate — a healthy store reports Valid:

kubectl apply -f aws-secretstore.yaml
kubectl -n payments get secretstore aws-secretsmanager
# NAME                 AGE   STATUS   CAPABILITIES   READY
# aws-secretsmanager   8s    Valid    ReadWrite      True

If READY is False, run kubectl -n payments describe secretstore aws-secretsmanager — the events almost always show an IAM AccessDenied (policy scope) or an OIDC trust mismatch. The AWS provider supports three auth modes; pick jwt (IRSA) unless you genuinely cannot use it:

AWS auth mode How it works Keys in cluster? Use when
jwt (IRSA) SA token → AssumeRoleWithWebIdentity No Default on EKS — always prefer this
secretRef (static) Access key + secret in a K8s Secret Yes (bad) Only off-EKS with no other identity option
Pod Identity (EKS Pod Identity Agent) SA → role via the Pod Identity association No Newer EKS alternative to IRSA, no OIDC URL needed

The Vault path: Kubernetes auth, policy, and the Vault SecretStore

Vault needs to trust the cluster so ESO can log in with a ServiceAccount token. The chain mirrors AWS but runs through Vault: ESO mints a token for eso-vault-sa → Vault’s kubernetes auth backend validates it with its token_reviewer_jwt → matches the eso-payments role bound to that SA → returns a short-lived Vault token carrying the payments-read policy.

Step A — enable and configure the Kubernetes auth backend

Run these against Vault with an admin token obtained through your workforce IdP-brokered login (you need this once):

vault auth enable kubernetes

vault write auth/kubernetes/config \
  kubernetes_host="https://kubernetes.default.svc" \
  token_reviewer_jwt="$(kubectl create token vault-auth -n payments)" \
  kubernetes_ca_cert=@/tmp/k8s-ca.crt \
  disable_iss_validation=true

Step B — a read-only policy scoped to exact paths

Scope to just the secrets the payments app needs under the kv mount. With KV v2, the policy path includes data/:

vault policy write payments-read - <<'EOF'
path "kv/data/payments/db" {
  capabilities = ["read"]
}
path "kv/data/payments/internal-api" {
  capabilities = ["read"]
}
EOF

Step C — the role bound to a ServiceAccount, with a short TTL

vault write auth/kubernetes/role/eso-payments \
  bound_service_account_names=eso-vault-sa \
  bound_service_account_namespaces=payments \
  policies=payments-read \
  ttl=15m

kubectl -n payments create serviceaccount eso-vault-sa

The eso-vault-sa needs no IRSA annotation — Vault, not AWS, authenticates it. A short ttl (15m here) means a leaked Vault token expires fast.

Step D — the Vault SecretStore

This SecretStore targets the KV v2 engine and logs in at the eso-payments role. version: "v2" matters — pointing v2 config at a v1 mount is the single most common Vault-side mistake here, and it fails with a 403 or an empty secret.

# vault-secretstore.yaml
apiVersion: external-secrets.io/v1beta1
kind: SecretStore
metadata:
  name: vault-backend
  namespace: payments
spec:
  provider:
    vault:
      server: "https://vault.internal.kloudvin.com:8200"
      path: "kv"
      version: "v2"
      auth:
        kubernetes:
          mountPath: "kubernetes"
          role: "eso-payments"
          serviceAccountRef:
            name: eso-vault-sa
kubectl apply -f vault-secretstore.yaml
kubectl -n payments get secretstore vault-backend
# NAME            STATUS   CAPABILITIES   READY
# vault-backend   Valid    ReadWrite      True

A READY=True here means the full chain works: ESO minted a token for eso-vault-sa, Vault’s Kubernetes auth backend reviewed it, matched the eso-payments role, and granted the payments-read policy. The KV-v1-vs-v2 path rules, which trip everyone exactly once:

Where KV v2 value KV v1 value Note
SecretStore.spec.provider.vault.path kv (the mount only) kv (the mount only) Same in both
SecretStore...vault.version "v2" "v1" (or omit) Wrong value = 403 / empty
Vault policy path kv/data/payments/db kv/payments/db v2 inserts data/
ExternalSecret.remoteRef.key payments/db payments/db No data/ here — ESO adds it

Vault offers several auth backends ESO can use; kubernetes is the right default inside a cluster:

Vault auth method How ESO authenticates Use when
kubernetes SA token reviewed by Vault Default — ESO runs in a cluster Vault trusts
approle RoleID + SecretID from a K8s Secret Vault doesn’t trust the cluster’s SA tokens
jwt/OIDC A projected SA token as a JWT Bound-audience JWT auth without the k8s backend
tokenSecretRef A static Vault token in a Secret Last resort; token sits in the cluster

ExternalSecrets: pulling values into native Secrets

Now the payoff. An ExternalSecret declares what to pull and where to put it. The refreshInterval is the rotation engine — ESO re-pulls on that cadence and updates the target Secret in place.

Pulling specific keys with data

The first reads two named keys from Vault into one Secret, mapping each remote property to a chosen Secret key:

# externalsecret-vault-db.yaml
apiVersion: external-secrets.io/v1beta1
kind: ExternalSecret
metadata:
  name: payments-db
  namespace: payments
spec:
  refreshInterval: 1h
  secretStoreRef:
    name: vault-backend
    kind: SecretStore
  target:
    name: payments-db-secret      # the K8s Secret ESO will create/own
    creationPolicy: Owner
    deletionPolicy: Retain
  data:
    - secretKey: DB_USERNAME
      remoteRef:
        key: payments/db          # path under kv/data/
        property: username
    - secretKey: DB_PASSWORD
      remoteRef:
        key: payments/db
        property: password

Pulling a whole secret with dataFrom.extract

The second pulls a JSON fraud-feed secret from AWS and flattens every top-level key into the target Secret:

# externalsecret-aws-fraud.yaml
apiVersion: external-secrets.io/v1beta1
kind: ExternalSecret
metadata:
  name: fraud-feed
  namespace: payments
spec:
  refreshInterval: 15m
  secretStoreRef:
    name: aws-secretsmanager
    kind: SecretStore
  target:
    name: fraud-feed-secret
    creationPolicy: Owner
    template:
      type: Opaque
  dataFrom:
    - extract:
        key: payments/fraud-feed  # a JSON secret in Secrets Manager

dataFrom.extract flattens a JSON secret {"api_key":"...","endpoint":"..."} into Secret keys api_key and endpoint. Apply both:

kubectl apply -f externalsecret-vault-db.yaml
kubectl apply -f externalsecret-aws-fraud.yaml

The three ways to specify what to pull, side by side:

Field What it pulls Result in the Secret Use when
data[].remoteRef One property of one remote secret The secretKey you name You want explicit, renamed keys
dataFrom[].extract All top-level keys of a JSON secret Each JSON key as-is The remote is a JSON blob you want whole
dataFrom[].find Many secrets matched by name regex or tag Each matched secret’s keys Bulk discovery (e.g. all prod/*)

The remoteRef knobs that change which version and how a value is read:

remoteRef field Meaning Default Note
key Path/name of the remote secret (required) KV path without data/
property A single sub-key within the secret (whole value) Maps to one secretKey
version Pin a specific version latest AWS version-id / Vault version number
decodingStrategy None / Base64 / Base64URL / Auto None Decode a base64-stored value on the way in
conversionStrategy How to sanitise keys into valid Secret keys Default Unicode for non-ASCII key names

Lifecycle policies: who owns the Secret, what happens on delete

Two policies govern the target Secret’s lifecycle, and getting them wrong is a teardown footgun:

creationPolicy Behaviour Use when
Owner (default) ESO creates the Secret and sets an owner reference — deleting the ExternalSecret deletes the Secret Normal case; ESO fully manages it
Merge The Secret must already exist; ESO merges keys in, does not own it A pre-existing Secret you augment
Orphan ESO creates the Secret but sets no owner ref — it survives the ExternalSecret You want the Secret to outlive the CR
None ESO does not create a Secret (used with PushSecret-style flows) Rare
deletionPolicy Behaviour when the remote key disappears Use when
Retain Keep the last-synced value in the Secret Default-safe; a deleted remote shouldn’t break running pods
Delete Remove the key/Secret when the remote is gone You want the Secret to track the remote exactly
Merge Remove only the keys that vanished, keep the rest Partial JSON secrets

Templating: shaping the Secret your app actually wants

Raw key-value pulls are often not the shape the app expects. ESO’s Go template engine (under target.template) renders the fetched values into config files, connection strings, typed secrets (TLS, dockerconfigjson), or any text — so the app gets exactly what it reads.

A connection string assembled from two Vault keys, plus a rendered .properties file:

# externalsecret-vault-db-templated.yaml
apiVersion: external-secrets.io/v1beta1
kind: ExternalSecret
metadata:
  name: payments-db-templated
  namespace: payments
spec:
  refreshInterval: 1h
  secretStoreRef:
    name: vault-backend
    kind: SecretStore
  target:
    name: payments-db-config
    creationPolicy: Owner
    template:
      engineVersion: v2
      type: Opaque
      data:
        # Build a libpq connection string the app reads as one env var
        DATABASE_URL: "postgres://{{ .user }}:{{ .pass }}@payments-db.internal:5432/payments?sslmode=require"
        # Render a full config file consumed via a mounted volume
        app.properties: |
          db.user={{ .user }}
          db.password={{ .pass }}
          db.pool.max=20
  data:
    - secretKey: user
      remoteRef: { key: payments/db, property: username }
    - secretKey: pass
      remoteRef: { key: payments/db, property: password }

The data keys (user, pass) become the template’s variables; only the rendered template.data ends up in the final Secret. ESO ships template helper functions for the common transforms:

Template helper What it does Example use
{{ .key }} Insert a fetched value password={{ .pass }}
{{ .x | b64enc }} Base64-encode Building a dockerconfigjson
{{ .x | b64dec }} Base64-decode A value stored encoded upstream
{{ .x | toString }} Bytes → string Before string ops
{{ .json | fromJson }} Parse JSON, then index {{ (.blob | fromJson).host }}
pkcs12key / pkcs12cert Extract key/cert from a PKCS#12 bundle TLS from a single upstream blob
{{ .ca | pemFormat }} Normalise PEM Cert chains

A common typed-secret pattern — produce a real kubernetes.io/tls secret from a PEM cert+key stored in Vault:

  target:
    name: payments-tls
    template:
      type: kubernetes.io/tls
      data:
        tls.crt: "{{ .cert }}"
        tls.key: "{{ .key }}"

The template behaviours worth knowing:

Behaviour Detail
engineVersion: v2 The current engine; v1 is legacy — always use v2
templateFrom Pull a template body from a ConfigMap/Secret instead of inline
mergePolicy: Merge Keep keys from the source that the template doesn’t override
Metadata templating Set target.template.metadata.labels/annotations (e.g. a Reloader annotation) dynamically
Missing key A referenced .key that wasn’t fetched fails the render → SecretSyncedError

PushSecret: writing cluster values back to the store

Sometimes the cluster generates the secret — a freshly created service account password, a cert from cert-manager, a value from an ESO generator — and you want it landed back in Vault or Secrets Manager so other systems can read it. PushSecret does the reverse direction: it takes an existing Kubernetes Secret and writes it upstream.

# pushsecret-generated-token.yaml
apiVersion: external-secrets.io/v1beta1
kind: PushSecret
metadata:
  name: push-webhook-token
  namespace: payments
spec:
  refreshInterval: 1h
  updatePolicy: Replace          # overwrite the remote value
  deletionPolicy: Delete         # remove upstream if this PushSecret is deleted
  secretStoreRefs:
    - name: vault-backend
      kind: SecretStore
  selector:
    secret:
      name: webhook-token-secret  # an existing K8s Secret to push
  data:
    - match:
        secretKey: token          # key in the source Secret
        remoteRef:
          remoteKey: payments/webhook-token   # destination path/key
          property: token

PushSecret requires write permission on the store — secretsmanager:PutSecretValue (and possibly CreateSecret) for AWS, or ["create","update"] on the Vault policy path. The policy and update knobs:

Field Values Effect
updatePolicy Replace / IfNotExists Overwrite vs only-create
deletionPolicy Delete / Retain Remove or keep the upstream value when the PushSecret is deleted
secretStoreRefs list Push the same value to multiple stores
selector.secret.name a Secret name The source to push
selector.generatorRef a generator Push a generated value (e.g. a password) instead of a static Secret

A worked use case: cert-manager issues a TLS cert into a Secret; a PushSecret mirrors it into Secrets Manager so a non-Kubernetes load balancer in the same account can consume the identical cert — one source, two consumers, no manual copy.

Multi-tenancy and RBAC: keeping tenants out of each other’s secrets

On a shared cluster, the guarantee security cares about is tenant A cannot read tenant B’s secrets. ESO gives you several controls; use them together.

Prefer namespaced SecretStores. A namespaced store can only be referenced by ExternalSecrets in the same namespace, so a tenant physically cannot point at another tenant’s store. Reserve ClusterSecretStore for genuinely shared, low-sensitivity material, and gate it.

Scope the backend role to the namespace. This is the real boundary — even if RBAC slips, the Vault role and IAM policy must only grant the paths/ARNs that tenant owns. The Vault role’s bound_service_account_namespaces and the IAM policy’s Resource list are the hard stops.

Lock down who can read the resulting Secret. ESO writes a normal Secret; restrict get/list on it to the owning namespace’s workloads and the operator:

# rbac-tenant-secret-read.yaml
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
  name: payments-secret-reader
  namespace: payments
rules:
  - apiGroups: [""]
    resources: ["secrets"]
    resourceNames: ["payments-db-secret", "fraud-feed-secret"]
    verbs: ["get"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
  name: payments-secret-reader
  namespace: payments
subjects:
  - kind: ServiceAccount
    name: payments-api
    namespace: payments
roleRef:
  kind: Role
  name: payments-secret-reader
  apiGroup: rbac.authorization.k8s.io

Gate ClusterSecretStore with a namespaceSelector. A ClusterExternalSecret (or the consuming ExternalSecret) should only target namespaces a selector allows, so a shared store does not fan secrets everywhere. The tenancy controls and what each one stops:

Control Layer Stops Limit
Namespaced SecretStore ESO API Cross-namespace store reference None for shared stores
Backend role/policy scope Vault / IAM Reading paths/ARNs you don’t own The real boundary — get this right
RBAC on the target Secret Kubernetes RBAC Pods/users reading another team’s Secret Doesn’t stop a namespace-admin
namespaceSelector on ClusterSecretStore ESO API Unlisted namespaces using a shared store Selector must be maintained
Separate IRSA role per namespace AWS IAM One namespace assuming another’s role One role+policy per tenant to manage

A ClusterExternalSecret that fans a shared, low-sensitivity value into selected namespaces:

# clusterexternalsecret-shared-ca.yaml
apiVersion: external-secrets.io/v1beta1
kind: ClusterExternalSecret
metadata:
  name: shared-internal-ca
spec:
  externalSecretName: internal-ca
  namespaceSelector:
    matchLabels:
      eso-shared-ca: "true"     # only namespaces with this label get it
  externalSecretSpec:
    refreshInterval: 24h
    secretStoreRef:
      name: shared-pki
      kind: ClusterSecretStore
    target:
      name: internal-ca
    data:
      - secretKey: ca.crt
        remoteRef: { key: pki/internal-ca, property: certificate }

Rotation, refresh, and the restart problem

Rotation is the whole reason teams adopt ESO, and it has two halves: ESO updating the Secret, and the pod picking up the new value.

ESO updates the Secret on refreshInterval. Set it per secret class: long for slow-moving DB creds (1h), short for short-lived tokens (15m). A flat 10s across hundreds of ExternalSecrets will rate-limit you at the AWS Secrets Manager API or hammer Vault — tune it. To force an immediate re-pull without waiting, bump an annotation:

kubectl -n payments annotate externalsecret payments-db \
  force-sync=$(date +%s) --overwrite

The pod does not automatically see the new value. An env-var consumer (secretKeyRef) reads the value once at process start — a Secret change does not restart the pod. Two robust options: pair ESO with Reloader (stakater/reloader) so a Secret change triggers a rolling restart, or consume the secret as a mounted file, which the kubelet refreshes in place (no restart, but the app must re-read the file). Choose by consumer type:

Consumer pattern Sees rotation without restart? How to make rotation work
Env var via secretKeyRef No Reloader annotation, or checksum/secret on the pod template
Mounted volume (secret) Yes (file refreshed) App must re-read the file periodically
App reads Secret via API Yes App watches/polls the Secret
Connection pool already open No App must reconnect with the new credential

The refreshInterval trade-offs in one view:

refreshInterval Rotation lag API load Use for
15m ≤15 min Higher Short-lived tokens, fraud feeds
1h ≤1 h Moderate DB passwords, API keys (default-ish)
24h ≤1 day Low Slow-moving CA certs, config
0 Never re-syncs One pull Immutable bootstrap values only

A worked rotation flow, end to end:

# Action Where Result
1 Rotate the value Vault / Secrets Manager New version upstream
2 ESO re-pulls On refreshInterval (or forced) Target Secret updated
3 Reloader detects change Cluster Annotates the Deployment
4 Rolling restart Deployment Pods pick up the new value
5 Old credential revoked Upstream (after grace) Leaked old value is useless

ESO vs Sealed Secrets vs SOPS vs the Secrets Store CSI driver

These four tools solve overlapping problems differently. The decisive question is where the source of truth lives and what ends up in Git.

Dimension ESO Sealed Secrets SOPS Secrets Store CSI driver
Model Pull from external store Encrypt → commit ciphertext Encrypt → commit ciphertext Mount from external store as a volume
Source of truth Vault / SM / etc. Git (the SealedSecret) Git (the encrypted file) Vault / SM / etc.
In Git Only a reference Ciphertext Ciphertext Only the SecretProviderClass
Creates a K8s Secret? Yes (native) Yes (controller decrypts) Yes (via kubectl/operator) Optional (secretObjects) — primarily a tmpfs mount
Auto-rotation Yes (refreshInterval) No (re-seal + commit) No (re-encrypt + commit) Yes (volume re-fetch)
Needs an external store? Yes No (self-contained) No (KMS key) Yes
Multi-backend Many providers n/a KMS/age/PGP Vault/AWS/Azure/GCP
Best at Centralised stores + rotation Simple GitOps, no external store Encrypting whole config files in Git Mounting secrets without a Secret object

The decision rule:

If you… Choose
Already run Vault/Secrets Manager and want rotation ESO
Want GitOps with no external store, secrets self-contained Sealed Secrets
Want to encrypt whole files (incl. non-secret config) in Git SOPS
Must avoid a Secret object entirely (mount-only, compliance) CSI driver
Want pull-based rotation and a Secret object apps already use ESO

ESO and the CSI driver are not mutually exclusive — some shops use the CSI driver for mount-only workloads and ESO for everything that expects a real Secret. ESO and Sealed Secrets/SOPS rarely coexist because they answer the same “where’s the truth” question oppositely.

Architecture at a glance

The flow has three planes. Upstream, two stores of record hold the real secrets: HashiCorp Vault (dynamic DB creds, PKI, static KV) and AWS Secrets Manager (RDS credentials, the fraud-feed token). In-cluster, the ESO controller authenticates to each store using workload identity — a Kubernetes ServiceAccount token exchanged at Vault’s Kubernetes auth backend, and an IRSA-annotated ServiceAccount that assumes an IAM role for AWS — then reconciles each ExternalSecret it watches into a standard Kubernetes Secret. Downstream, the payments microservices mount or env-inject those Secrets exactly as they always have. A refreshInterval on each ExternalSecret is what turns an upstream rotation into a fresh Secret without a redeploy.

Read the diagram left to right. On the left, the two stores of record. In the middle, the ESO controller with its two ServiceAccounts — eso-vault-sa (no annotation; Vault authenticates it) and eso-aws-sa (IRSA-annotated; STS authenticates it) — sitting in the external-secrets/payments namespaces, watching SecretStore and ExternalSecret objects. On the right, the native Secrets ESO writes and the pods that consume them via env vars and mounted files. Around the edges, the platform team’s existing tools observe the result: Wiz / Wiz Code scans the repo and the cluster to confirm no plaintext secret is committed and that SecretStore configs are sane, CrowdStrike Falcon runs runtime protection on the nodes so a process that exfiltrates a mounted secret gets flagged, and Dynatrace (or Datadog) scrapes the ESO controller’s /metrics so a sync failure pages someone. Provisioning of the IAM role, the Vault policy, and the namespaces is codified in Terraform, and the manifests ship through Argo CD with GitHub Actions running the pipeline that lints and applies them.

External Secrets Operator topology: HashiCorp Vault and AWS Secrets Manager as upstream stores of record on the left feed the ESO controller in the middle, which authenticates via the eso-vault-sa ServiceAccount (Kubernetes auth) and the IRSA-annotated eso-aws-sa ServiceAccount (STS AssumeRoleWithWebIdentity), watches SecretStore and ExternalSecret custom resources, and reconciles them into native Kubernetes Secrets on the right that payments microservices consume via env vars and mounted files, with a refreshInterval driving rotation and Wiz, CrowdStrike Falcon and Dynatrace observing the result

Real-world scenario

Northwind Pay runs the forty-microservice EKS estate from the intro: EKS 1.30 in ap-south-1, 18 nodes, a four-engineer platform team. Before ESO, an audit had flagged 31 distinct secrets committed across nine repos — including the production RDS password, which had not been rotated in 14 months because rotating it meant a coordinated redeploy of six services. The fraud-feed token was in a Helm chart that lived in 40+ developer clones. Monthly spend on the secrets backends was about ₹3,500 (a small Secrets Manager footprint plus the existing Vault cluster, which was already paid for by another team).

The rollout took two sprints. Sprint one: install ESO, stand up the IRSA role and the Vault kubernetes auth role, and migrate the least risky secret — an internal-API key in Vault — to prove the pattern. They hit the classic snag immediately: the first SecretStore for Vault sat at READY=False. kubectl describe showed permission denied; the cause was a v1-style policy path (kv/payments/internal-api) against a KV v2 mount, which needs kv/data/payments/internal-api. One-line policy fix, store went Valid, the first ExternalSecret synced in seconds. That single failure became the team’s onboarding doc.

Sprint two: migrate the RDS password and the fraud-feed token. The RDS password was the high-stakes one. They created payments-rds as an ExternalSecret against Secrets Manager with refreshInterval: 1h, templated it into a DATABASE_URL connection string so no service code changed, and wired Reloader so a rotation triggered a rolling restart. Then the test that mattered: they rotated the RDS password in Secrets Manager (via the managed rotation Lambda), watched ESO update payments-rds-secret within the hour, watched Reloader roll the six consuming Deployments, and confirmed every service reconnected with zero manual steps. The 14-month-stale password problem was now a fully automated, observable event.

The fraud-feed migration exposed a second lesson. They initially set refreshInterval: 10s “to be safe,” and within a day Secrets Manager started throttling — GetSecretValue was being called thousands of times an hour across the namespace. They corrected to 15m (the token rotates daily; 15-minute lag is irrelevant) and the throttling vanished. The fix was free; the lesson — match the interval to the rotation cadence, not to anxiety — went on the wall next to the KV-v2 one.

Outcome: all 31 committed secrets were removed from the live charts and replaced with ExternalSecret references (the Git history was separately scrubbed and the credentials rotated, since history can’t be un-leaked). The auditor’s two questions now had answers: a single kubectl get externalsecret -A listed every workload’s secrets and their sync status, and rotation was demonstrably automatic. Spend was unchanged (~₹3,500); the saving was operational — the next RDS rotation took zero engineer-hours instead of a planned-change window. The line on the wall: “The secret never lived in the cluster — only a fifteen-minute lease on it did.”

Advantages and disadvantages

ESO’s pull-from-store model both solves the Git-exposure problem cleanly and introduces a hard dependency on the store being reachable. Weigh it honestly:

Advantages Disadvantages
No secret material in Git/charts — only references The resulting K8s Secret is still base64, not encrypted (etcd encryption is your job)
Rotation is a store-side change; refreshInterval propagates it Env-var consumers don’t auto-restart — needs Reloader or mounted files
Zero app changes — apps read a normal Secret Hard dependency: if the store/auth is down, secrets don’t refresh
One CRD-shaped, GitOps-friendly, observable surface for all secrets Another controller to run, monitor, upgrade (CRD/version skew risk)
Multi-provider — Vault, AWS/GCP/Azure SM, and many more in one cluster Per-provider auth quirks (IRSA audience, KV v2 paths) cost setup time
Templating produces exactly the shape the app wants Templating errors fail silently into SecretSyncedError until you look
PushSecret covers the reverse direction (cluster → store) Push needs write perms — a wider blast radius to manage carefully
Per-namespace stores + scoped backend roles give real multi-tenant isolation Cluster-scoped stores, if misused, widen blast radius across tenants

The model is right when you already run an external store and want rotation without bespoke glue. It is the wrong tool if you have no external store and want secrets self-contained in Git (Sealed Secrets), if you need mount-only delivery with no Secret object (CSI driver), or if you cannot tolerate a runtime dependency on the store for secret refresh.

Hands-on lab

This lab is the centerpiece: install ESO on a local kind cluster, stand up a dev-mode Vault in-cluster (so you need no external Vault), wire the Kubernetes auth chain, sync a secret, prove rotation, then add a templated secret — entirely free and self-contained. Allow ~20 minutes. (Dev-mode Vault is for the lab only; it is in-memory, unsealed, and insecure by design — never use it in production.)

Step 1 — Create a kind cluster.

kind create cluster --name eso-lab
kubectl cluster-info --context kind-eso-lab

Expected: Kubernetes control plane is running at https://127.0.0.1:<port>.

Step 2 — Install External Secrets Operator.

helm repo add external-secrets https://charts.external-secrets.io
helm repo update
helm install external-secrets external-secrets/external-secrets \
  -n external-secrets --create-namespace --set installCRDs=true --wait
kubectl -n external-secrets get pods

Expected: three Running pods (controller, webhook, cert-controller).

Step 3 — Run a dev-mode Vault in the cluster.

helm repo add hashicorp https://helm.releases.hashicorp.com
helm install vault hashicorp/vault -n vault --create-namespace \
  --set "server.dev.enabled=true" \
  --set "server.dev.devRootToken=root" --wait
kubectl -n vault get pod vault-0

Expected: vault-0 is Running (dev mode auto-unseals with root token root).

Step 4 — Put a secret in Vault and enable Kubernetes auth. Exec into the Vault pod:

kubectl -n vault exec -it vault-0 -- sh -c '
  export VAULT_TOKEN=root
  vault kv put secret/payments/db username=app_user password=Initial-P@ss-1
  vault auth enable kubernetes
  vault policy write payments-read - <<EOF
path "secret/data/payments/db" { capabilities = ["read"] }
EOF
  vault write auth/kubernetes/config \
    kubernetes_host="https://kubernetes.default.svc:443"
  vault write auth/kubernetes/role/eso-payments \
    bound_service_account_names=eso-vault-sa \
    bound_service_account_namespaces=payments \
    policies=payments-read ttl=20m
'

Expected: Success! Data written for each command. (The Vault Helm chart’s dev mode mounts secret/ as KV v2 and auto-configures token_reviewer_jwt from the pod’s own SA, so the minimal config above is enough here.)

Step 5 — Create the namespace, ServiceAccount, and the Vault SecretStore.

kubectl create namespace payments
kubectl -n payments create serviceaccount eso-vault-sa

cat <<'EOF' | kubectl apply -f -
apiVersion: external-secrets.io/v1beta1
kind: SecretStore
metadata:
  name: vault-backend
  namespace: payments
spec:
  provider:
    vault:
      server: "http://vault.vault.svc:8200"
      path: "secret"
      version: "v2"
      auth:
        kubernetes:
          mountPath: "kubernetes"
          role: "eso-payments"
          serviceAccountRef:
            name: eso-vault-sa
EOF

kubectl -n payments get secretstore vault-backend

Expected: READY becomes True within a few seconds. If it stays False, run kubectl -n payments describe secretstore vault-backend and check the events (almost always an auth/path issue).

Step 6 — Create the ExternalSecret and watch the Secret appear.

cat <<'EOF' | kubectl apply -f -
apiVersion: external-secrets.io/v1beta1
kind: ExternalSecret
metadata:
  name: payments-db
  namespace: payments
spec:
  refreshInterval: 1m
  secretStoreRef:
    name: vault-backend
    kind: SecretStore
  target:
    name: payments-db-secret
    creationPolicy: Owner
  data:
    - secretKey: DB_USERNAME
      remoteRef: { key: payments/db, property: username }
    - secretKey: DB_PASSWORD
      remoteRef: { key: payments/db, property: password }
EOF

kubectl -n payments get externalsecret payments-db
# NAME          STORE           STATUS         READY
# payments-db   vault-backend   SecretSynced   True
kubectl -n payments get secret payments-db-secret

Expected: STATUS=SecretSynced, READY=True, and a payments-db-secret of type Opaque with 2 data keys.

Step 7 — Verify the data landed (scratch shell only — never in CI logs).

kubectl -n payments get secret payments-db-secret \
  -o jsonpath='{.data.DB_PASSWORD}' | base64 -d; echo
# Initial-P@ss-1

Step 8 — Prove rotation end to end. Change the value in Vault, force a re-pull, re-read:

kubectl -n vault exec -it vault-0 -- sh -c \
  'VAULT_TOKEN=root vault kv put secret/payments/db username=app_user password=Rotated-P@ss-2'

kubectl -n payments annotate externalsecret payments-db \
  force-sync=$(date +%s) --overwrite

sleep 3
kubectl -n payments get secret payments-db-secret \
  -o jsonpath='{.data.DB_PASSWORD}' | base64 -d; echo
# Rotated-P@ss-2

Expected: the Secret now holds Rotated-P@ss-2. You just rotated a secret with no redeploy and no manifest change.

Step 9 — Add a templated secret (assemble a connection string).

cat <<'EOF' | kubectl apply -f -
apiVersion: external-secrets.io/v1beta1
kind: ExternalSecret
metadata:
  name: payments-db-url
  namespace: payments
spec:
  refreshInterval: 1m
  secretStoreRef:
    name: vault-backend
    kind: SecretStore
  target:
    name: payments-db-url-secret
    template:
      engineVersion: v2
      data:
        DATABASE_URL: "postgres://{{ .user }}:{{ .pass }}@db.internal:5432/payments?sslmode=require"
  data:
    - secretKey: user
      remoteRef: { key: payments/db, property: username }
    - secretKey: pass
      remoteRef: { key: payments/db, property: password }
EOF

kubectl -n payments get secret payments-db-url-secret \
  -o jsonpath='{.data.DATABASE_URL}' | base64 -d; echo
# postgres://app_user:Rotated-P@ss-2@db.internal:5432/payments?sslmode=require

Expected: a single DATABASE_URL key built from two Vault values — the shape an app reads directly.

Validation checklist. What you proved, mapped to the real-world analogue:

Step What you did What it proves Real-world analogue
5 Vault SecretStore via k8s auth The auth chain works end to end Wiring prod Vault
6 ExternalSecret → native Secret Pull model produces a normal Secret Migrating a real credential
7 Decode the value The data actually landed Spot-checking a sync
8 Rotate + force-sync Rotation needs no redeploy The 3 a.m. rotation, automated
9 Templated DATABASE_URL App gets exactly the shape it wants Zero-code migration

Teardown.

kind delete cluster --name eso-lab

That deletes everything — cluster, ESO, Vault, all secrets — in one command, at zero ongoing cost.

Common mistakes & troubleshooting

The auth and sync failures below eat the most time. First the scannable playbook, then the confirm-command detail for the ones that bite hardest.

# Symptom Root cause Confirm (exact cmd) Fix
1 SecretStore stuck READY=False, events say permission denied KV v2 policy path missing data/ kubectl -n payments describe secretstore vault-backend Policy path kv/data/<p>; set version: "v2"
2 Vault store READY=False, role not found / service account not authorized Role’s bound_service_account_* doesn’t match the SA/ns vault read auth/kubernetes/role/eso-payments Match bound_service_account_names/namespaces to the real SA
3 AWS store READY=False, AccessDenied IAM policy doesn’t cover the secret ARN kubectl describe secretstore aws-secretsmanager; CloudTrail Add the exact ARN (with -*) to the policy Resource
4 AWS store fails silently; no STS call SA token lacks sts.amazonaws.com audience kubectl -n payments describe sa eso-aws-sa Ensure IRSA annotation; don’t strip token audiences
5 ExternalSecret SecretSyncedError, “key not found” Wrong remoteRef.key/property kubectl -n payments describe externalsecret <name> Fix path; KV v2 key has no data/; check property name
6 Secret exists but is empty / missing a key property typo, or extract on a non-JSON secret Decode the Secret; check the remote shape Use the right property, or dataFrom.extract only on JSON
7 App still sees the old value after rotation Env-var consumer not restarted kubectl get secret -o jsonpath shows new value; pod env old Add Reloader annotation, or use a mounted volume
8 AWS throttling / ThrottlingException under load refreshInterval too aggressive across many secrets Controller logs; SM API metrics Lengthen interval; batch keys into one JSON secret
9 kubectl apply of a CR rejected by admission Webhook down or cert expired kubectl -n external-secrets get pods; describe webhook Restart webhook; check cert-controller is healthy
10 Deleting an ExternalSecret deleted the app’s Secret creationPolicy: Owner (owner ref) kubectl get secret <n> -o yaml (ownerReferences) Set creationPolicy: Orphan before delete to keep it
11 ClusterSecretStore works for one ns, not another namespaceSelector / conditions exclude the ns kubectl get clustersecretstore <n> -o yaml Add the ns label / fix the selector
12 PushSecret fails with AccessDenied / permission denied No write perm on the store Controller logs Add PutSecretValue (AWS) or create,update (Vault policy)
13 Secret syncs once then never updates refreshInterval: 0 (or unset to a long value) kubectl get externalsecret -o yaml Set a sane non-zero refreshInterval
14 Everything Ready but no Secret appears RBAC: controller can’t create Secrets in the ns Controller logs secrets is forbidden Reinstall chart RBAC; check ns isn’t restricted by policy

The expanded reasoning for the ones that cost the most time:

1. Vault SecretStore stuck READY=False with permission denied. Root cause: KV v2 splits the data path — the policy must reference kv/data/payments/db, not kv/payments/db, and the SecretStore must set version: "v2". Confirm: kubectl -n payments describe secretstore vault-backend shows permission denied in the events; vault policy read payments-read shows a v1-style path. Fix: Rewrite the policy path with data/ inserted; set version: "v2". The ExternalSecret’s remoteRef.key stays payments/db (no data/) — ESO inserts it.

3. AWS SecretStore READY=False with AccessDenied. Root cause: The IAM policy’s Resource doesn’t include the secret’s full ARN (every Secrets Manager ARN has a random 6-char suffix, so you must end the pattern with -*). Confirm: kubectl describe secretstore aws-secretsmanager shows AccessDenied; CloudTrail’s GetSecretValue event shows the denied ARN. Fix: Add arn:aws:secretsmanager:<region>:<acct>:secret:<name>-* to the policy. If the secret uses a customer-managed KMS key, also grant kms:Decrypt.

4. AWS auth fails silently — no STS call appears. Root cause: ESO’s jwt auth needs the projected ServiceAccount token to carry the sts.amazonaws.com audience. EKS sets this by default, but a hardened cluster that strips audiences breaks AWS auth with no obvious error. Confirm: kubectl -n payments describe sa eso-aws-sa should show the eks.amazonaws.com/role-arn annotation; check the pod’s projected token audience. Fix: Ensure the IRSA annotation is present and the cluster isn’t stripping token audiences. Consider EKS Pod Identity as an alternative that doesn’t depend on the OIDC URL.

5. ExternalSecret shows SecretSyncedError, “key not found.” Root cause: The remoteRef.key or property doesn’t match the remote. With KV v2 the key is the path without data/; property must be an actual sub-key of the secret. Confirm: kubectl -n payments describe externalsecret payments-db shows the exact missing key/property in the status condition. Fix: Correct the path/property. To list what’s actually in the Vault secret: vault kv get kv/payments/db.

7. App still reads the old value after rotation. Root cause: An env-var (secretKeyRef) consumer reads the value once at process start; updating the Secret does not restart the pod. Confirm: kubectl get secret payments-db-secret -o jsonpath='{.data.DB_PASSWORD}' | base64 -d shows the new value, but kubectl exec into the pod shows the old env var. Fix: Add a Reloader annotation (reloader.stakater.com/auto: "true") to the Deployment so a Secret change triggers a rolling restart, or consume the secret as a mounted volume (kubelet refreshes the file in place).

8. AWS throttling under load. Root cause: A short refreshInterval multiplied across many ExternalSecrets exceeds the Secrets Manager API rate. Confirm: Controller logs show ThrottlingException; SM CloudWatch metrics show a spike in GetSecretValue. Fix: Lengthen the interval to match the rotation cadence (a daily-rotating token needs 15m, not 10s); batch related values into one JSON secret consumed via dataFrom.extract so one API call returns many keys.

Best practices

Security notes

ESO removes plaintext secrets from Git and Helm entirely — the source of record stays in Vault and AWS, and only short-lived, narrowly-scoped tokens reach the cluster. But the cluster copy still needs protection:

Control Mechanism Secures against
etcd encryption-at-rest EKS KMS envelope encryption Plaintext Secret in an etcd backup
RBAC on the Secret Role/RoleBinding, resourceNames One team reading another’s secret
Scoped backend role IAM Resource / Vault policy paths A namespace reading paths it doesn’t own
Short token TTL Vault role ttl A leaked token staying valid
Restrict SecretStore edit rights RBAC on the CRD A malicious store pointing auth elsewhere
Controller isolation + image scanning Dedicated namespace, Wiz/Falcon Compromise of the credential-holding controller

Cost & sizing

ESO itself is open-source and free; the only direct cost is API calls to the backends and the controller’s modest compute footprint.

Cost driver What you pay Rough figure Control
ESO compute Controller/webhook pods Negligible (<256 MB) N/A
Secrets Manager storage Per secret / month ~$0.40 each Batch related keys into one JSON secret
Secrets Manager API Per 10,000 calls ~$0.05 Lengthen refreshInterval; dataFrom.extract
Vault leases Free, but bounded N/A Short role TTL keeps active leases low
Operational (rotation) Engineer-hours Was: a change window ESO + Reloader → zero-touch

Rule of thumb: refresh aggressively only what genuinely rotates fast. The default mistake is uniform 10s/30s intervals “to be safe,” which turns a free tool into a throttled, billed one.

Interview & exam questions

1. What problem does ESO solve that Sealed Secrets and SOPS do not? ESO keeps the source of truth in an external store (Vault, Secrets Manager) and pulls from it on a refresh interval, so secret material never enters Git at all and rotation propagates automatically. Sealed Secrets and SOPS encrypt material and commit the ciphertext to Git — the truth lives in Git, and rotation means re-encrypting and re-committing. ESO is the right choice when you already run a store and want rotation; the others suit a self-contained, no-external-store GitOps setup.

2. Walk through how ESO authenticates to AWS Secrets Manager. It uses IRSA: a ServiceAccount annotated with an IAM role ARN gets a projected token with audience sts.amazonaws.com; ESO calls sts:AssumeRoleWithWebIdentity with that token to get temporary credentials, then calls secretsmanager:GetSecretValue. No static keys ever land in the cluster. Confirm a healthy store with kubectl get secretstore showing READY=True.

3. Walk through how ESO authenticates to Vault with Kubernetes auth. ESO mints a token for the configured ServiceAccount; Vault’s kubernetes auth backend validates it via its token_reviewer_jwt against the cluster API, matches a role whose bound_service_account_names/namespaces include that SA, and returns a short-lived Vault token carrying the role’s policy. The long-lived secret never enters the cluster — only an ephemeral, scoped token does.

4. With Vault KV v2, why does a wrong path cause a permission denied or empty secret? KV v2 stores data under a data/ sub-path, so the Vault policy must reference kv/data/payments/db (not kv/payments/db), and the SecretStore must set version: "v2". The ExternalSecret’s remoteRef.key stays payments/db — ESO inserts data/ itself. Mismatching these is the single most common Vault-side ESO error.

5. The Secret updates after rotation but the app keeps using the old value. Why and how do you fix it? An env-var consumer (secretKeyRef) reads the value once at process start; updating the underlying Secret does not restart the pod. Fix it by pairing ESO with Reloader (which rolls the Deployment when the Secret changes) or by mounting the secret as a volume (the kubelet refreshes the file in place, though the app must re-read it).

6. What is the difference between data, dataFrom.extract, and dataFrom.find? data pulls a named property of one remote secret into a named Secret key (explicit, renamable). dataFrom.extract flattens all top-level keys of one JSON secret into the Secret. dataFrom.find discovers many secrets by name regex or tag and pulls them in bulk. Use data for precision, extract for a whole JSON blob, find for bulk discovery.

7. When would you use ClusterSecretStore over SecretStore, and what’s the risk? A ClusterSecretStore is cluster-scoped and referenceable from any namespace — convenient for a shared, low-sensitivity store (e.g. a public CA). The risk is blast radius: an over-broad cluster store plus a ClusterExternalSecret can fan a secret into namespaces that should never see it. Gate it with a namespaceSelector and keep the backend role tightly scoped; prefer namespaced stores by default.

8. How does PushSecret differ from ExternalSecret, and what extra permission does it need? ExternalSecret pulls from the store into a Secret; PushSecret writes an existing cluster Secret (or a generated value) back to the store. It needs write permission on the backend — secretsmanager:PutSecretValue (and possibly CreateSecret) for AWS, or create/update on the Vault policy path — which is a wider blast radius to manage.

9. How do you guarantee tenant A cannot read tenant B’s secrets on a shared cluster? Layer three controls: namespaced SecretStores (so a tenant can’t reference another’s store), tightly-scoped backend roles (the IAM Resource/Vault policy paths are the real boundary), and RBAC on the resulting Secrets (restrict get/list to the owning namespace). A separate IRSA role per namespace stops cross-namespace role assumption.

10. Why can an aggressive refreshInterval cost money and break things? Each refresh is an API call to the backend; a short interval (e.g. 10s) multiplied across hundreds of ExternalSecrets can exceed the Secrets Manager API rate (throttling) and rack up per-call charges. Match the interval to the rotation cadence — a daily-rotating token only needs 15m — and batch related keys into one JSON secret so one call returns many values.

11. What does ESO not protect, and what must you add? ESO removes secrets from Git but writes a normal Kubernetes Secret, which is only base64-encoded in etcd — so you must enable etcd encryption-at-rest (EKS KMS envelope encryption) and lock down RBAC on the Secret. ESO is prevention of Git exposure, not a substitute for cluster-side secret protection.

12. When is ESO the wrong tool? When you have no external store and want secrets self-contained in Git (use Sealed Secrets), when you need mount-only delivery with no Secret object for compliance (use the Secrets Store CSI driver), or when you cannot tolerate a runtime dependency on the store for secret refresh (the store being down means no rotation).

These map to the CKS (Certified Kubernetes Security Specialist)minimize microservice vulnerabilities and secrets management — and to vendor tracks like HashiCorp Vault Associate (Kubernetes auth, policies) and AWS Security Specialty (IRSA, Secrets Manager, KMS).

Quick check

  1. With Vault KV v2, your ExternalSecret’s remoteRef.key is payments/db. What must the Vault policy path be, and what version must the SecretStore set?
  2. ESO updated the Secret after a rotation, but the running pod still uses the old password. Name the cause and one fix.
  3. True or false: a SecretStore should contain the AWS access key and secret so ESO can authenticate.
  4. You want a Secret to survive when you delete its ExternalSecret. What do you set, and on which object?
  5. A PushSecret to Secrets Manager fails with AccessDenied, even though reads work. What permission is missing?

Answers

  1. The policy path must be kv/data/payments/db (KV v2 inserts data/), and the SecretStore must set version: "v2". The remoteRef.key correctly stays payments/db — ESO adds the data/ segment itself.
  2. The cause is an env-var (secretKeyRef) consumer that reads the value once at startup; updating the Secret doesn’t restart the pod. Fix it with Reloader (rolls the Deployment on Secret change) or by consuming the secret as a mounted volume (kubelet refreshes the file).
  3. False. That puts a static credential in the cluster, defeating the point. Use IRSA (auth.jwt.serviceAccountRef) so no keys land in the cluster.
  4. Set creationPolicy: Orphan on the ExternalSecret’s target before deleting it (the default Owner sets an owner reference that cascades the delete).
  5. Write permission — secretsmanager:PutSecretValue (and possibly secretsmanager:CreateSecret) — is missing from the IAM policy. Reads only need GetSecretValue/DescribeSecret.

Glossary

Next steps

You can now sync secrets from Vault and AWS into the cluster and rotate them hands-free. Build outward:

KubernetesExternal Secrets OperatorHashiCorp VaultAWS Secrets ManagerEKSSecrets ManagementIRSAGitOps
Need this built for real?

Vinod is a Senior Cloud Architect (22+ yrs) — available for Azure / AWS / GCP architecture, landing zones, and migrations.

Work with me

Comments

Keep Reading