maborak.com~/blog/kubernetes-secrets-management-approachessoftware & infrastructure engineering
Back to all articles

Kubernetes Secrets Management: Which Approach Fits, With a Working Demo of Each

Overview

Native Secrets, RBAC, encryption at rest with KMS v2, Sealed Secrets, SOPS with Flux and Argo CD, External Secrets Operator, the Vault Secrets Operator, the Secrets Store CSI driver, the Vault Agent sidecar and direct Vault access. What each changes, a demo with captured output, and the catch we hit.

A Kubernetes Secret is encoded, not protected. On an unmodified cluster the value sits in etcd as readable bytes, and anyone allowed to list Secrets in a namespace can read every value in it. Almost every secrets tool for Kubernetes changes one of three things: where the value is kept, who can read it, or how it reaches the pod. It leaves the other two as they were.

This guide runs each approach on a kind cluster, shows what it changes and what it leaves alone, and gives you the commands to repeat it. The captured output of every demo sits next to it in a collapsed block. Read the map first, then the section for the approach you are weighing.

Checked 8 November 2026.

How we tested, and how far to trust it

Everything marked tested ran on 8 November 2026 in throwaway kind v0.33.0 clusters (Kubernetes v1.37.0) on a laptop, with Helm, kubectl, kubeseal v0.41.2, sops 3.13.3 and age 1.3.2 installed. Vault 2.0.4 ran in dev mode as the external store, because the lab has no cloud account. The other versions are named where they appear: ESO 2.14.0, Argo CD 3.6.1, Flux v2.10.0, KSOPS v4.5.1, the Vault Secrets Operator 1.6.2, the KMS plugin v1.4.0 and Jenkins 2.580.2. Each result is one observation, not a rate, and times such as “by the first check, 20 seconds later” describe that run only. Anything marked docs only comes from the project’s documentation and was not run; only two setups are left in that state, ESO with Flux and ESO with AWS. Secret values in the captures are lab values; never copy dev-mode Vault, its root token root, or the sample encryption key to a real cluster.

Most demos run on ksec, a plain kind create cluster --name ksec. The encryption section builds two more clusters, and the ESO section names its own.

The map

Six rows showing where a secret starts, what exists in the cluster, and what the pod receives: native Secret, Sealed Secrets and SOPS, ESO and VSO, CSI driver, Vault Agent sidecar, and direct access from the app. The first three rows keep a Secret in etcd; the last three keep none, or one only while a pod mounts it.

The first three rows leave a Secret object in etcd. The CSI driver does only if you ask it to sync, and the last two never do. Source: our lab runs.

The rows answer different questions, so they are not substitutes for each other:

You want to… Read Status
know what a Secret is and what it exposes Native Secrets, How a pod consumes one tested
limit who can read Secrets through the API Who can read Secrets tested
protect etcd and its backups Encryption at rest tested
keep encrypted secrets in Git, no external store Sealed Secrets, SOPS with age, SOPS with Flux, SOPS with Argo CD tested
keep the source of truth in a vault and get Secret objects External Secrets Operator, Vault Secrets Operator tested
keep values out of etcd, delivered as files CSI driver, Vault Agent tested
keep values out of the cluster except the app’s memory The app talks to Vault tested

Native Secrets

What it is and how it works

A Secret is an API object holding small values (the limit is 1 MiB), stored by the API server in etcd. Kubernetes defines types that tools and the kubelet understand [1]. We created six of them:

kubectl create namespace demo
kubectl -n demo create serviceaccount app
kubectl -n demo create secret generic app-opaque --from-literal=password=s3cr3t-value
openssl req -x509 -newkey rsa:2048 -nodes -days 1 -subj "/CN=demo.local" -keyout tls.key -out tls.crt
kubectl -n demo create secret tls app-tls --cert=tls.crt --key=tls.key
kubectl -n demo create secret docker-registry app-registry \
  --docker-server=registry.example.com --docker-username=user --docker-password=pw
# basic-auth, ssh-auth and a legacy service-account token are applied from YAML

The basic-auth and ssh-auth Secrets use type: kubernetes.io/basic-auth and type: kubernetes.io/ssh-auth with stringData. A legacy token is a Secret of type kubernetes.io/service-account-token annotated with kubernetes.io/service-account.name: app; the modern alternative is kubectl create token app --duration=10m, which prints a short-lived token.

Demo

The commands above, then a look at what the cluster holds. kubectl get secrets lists the types, base64 -d decodes a value, and etcdctl read directly from the etcd pod shows what is stored:

kubectl -n demo get secrets
kubectl -n demo get secret app-opaque -o jsonpath='{.data.password}' | base64 -d
kubectl -n kube-system exec etcd-ksec-control-plane -- etcdctl \
  --endpoints=https://127.0.0.1:2379 --cacert=/etc/kubernetes/pki/etcd/ca.crt \
  --cert=/etc/kubernetes/pki/etcd/server.crt --key=/etc/kubernetes/pki/etcd/server.key \
  get /registry/secrets/demo/app-opaque --print-value-only | strings
Captured output: the six types, then the value decoded (kind-ksec, 21:07 UTC, one run)
# Native Kubernetes Secrets, RBAC and injection, 2026-11-08T21:07:44Z, cluster kind-ksec (kind v0.33.0, Kubernetes v1.37.0)
## 1. Built-in Secret types
secret/app-opaque created
secret/app-tls created
secret/app-registry created
secret/app-basic created
secret/app-ssh created
secret/app-sa-token created
$ kubectl -n demo get secrets
NAME           TYPE                                  DATA   AGE
app-basic      kubernetes.io/basic-auth              2      3s
app-opaque     Opaque                                1      3s
app-registry   kubernetes.io/dockerconfigjson        1      3s
app-sa-token   kubernetes.io/service-account-token   3      3s
app-ssh        kubernetes.io/ssh-auth                1      3s
app-tls        kubernetes.io/tls                     2      3s
$ legacy token Secret: first characters of .data.token (a JWT)
eyJhbGciOiJSUzI1NiIsImtp

$ kubectl -n demo create token app --duration=10m   (short-lived): first characters
eyJhbGciOiJSUzI1NiIsImtp

## 2. Base64 is encoding, not encryption
$ kubectl -n demo get secret app-opaque -o jsonpath={.data.password}
czNjcjN0LXZhbHVl
$ ... | base64 -d
s3cr3t-value
Captured output: what etcd holds on this cluster (kind-ksec, 21:07 and 21:17 UTC, one run)
## 3. What etcd stores (no encryption at rest on this cluster)
$ etcdctl get /registry/secrets/demo/app-opaque --print-value-only | strings
Secret
app-opaque
demo"
*$8aca7f1a-b56f-4be5-a18e-15a5af76495b2
....
kubectl-create
Update
....


# What etcd holds for a native Secret, 2026-11-08T21:17:07Z, cluster kind-ksec (no encryption at rest)
$ etcdctl get /registry/secrets/demo/app-opaque --print-value-only | grep -c rotated-value   # the value written by the patch
   2 password
   1 rotated-value

Pros and cons

Pros

  • Nothing to install, and the types give tools a shared format for certificates, registry credentials and tokens [1].
  • kubectl create token app --duration=10m printed a short-lived token for the service account, as an alternative to a long-lived token Secret.

Cons

  • Base64 is encoding. The value decoded with no key, and on this cluster the raw etcd bytes contained it, as the capture shows.
  • Kubernetes’ own docs say Secrets are “by default, stored unencrypted in the API server’s underlying data store (etcd)” [1].

How a pod consumes one

What it is and how it works

A container can read a Secret as an environment variable or as a file from a mounted volume. The two differ in exposure and in what happens when the Secret changes.

Demo

apiVersion: v1
kind: Pod
metadata: {name: consumer, namespace: demo}
spec:
  containers:
    - name: app
      image: busybox:1.37
      command: ["sleep","3600"]
      env:
        - name: PASS_ENV
          valueFrom: {secretKeyRef: {name: app-opaque, key: password}}
      volumeMounts: [{name: s, mountPath: /etc/s, readOnly: true}]
  volumes: [{name: s, secret: {secretName: app-opaque}}]

We read the environment of process 1, listed the mount, then patched the Secret and sampled both the variable and the file every 20 seconds:

Captured output: env var and file, then the Secret patched, then an immutable Secret (kind-ksec, 21:07 UTC, one run)
## 4. Env var versus volume
pod/consumer created
pod/consumer condition met
$ the env var is visible in the process environment
PASS_ENV=s3cr3t-value
$ the volume is a tmpfs
tmpfs on /etc/s type tmpfs (ro,relatime,size=65714556k,inode64,noswap)
$ the file
total 0
lrwxrwxrwx    1 root     root            15 Nov  8 21:07 password -> ..data/password
s3cr3t-value
secret/app-opaque patched
# Secret changed at 21:07:54
t+20s env=s3cr3t-value file=rotated-value
t+40s env=s3cr3t-value file=rotated-value
t+60s env=s3cr3t-value file=rotated-value
t+81s env=s3cr3t-value file=rotated-value
t+101s env=s3cr3t-value file=rotated-value
t+121s env=s3cr3t-value file=rotated-value

## 5. Immutable Secret
secret/app-immutable created
The Secret "app-immutable" is invalid: data: Forbidden: field is immutable when `immutable` is set

An immutable Secret refuses edits. Kubernetes’ docs say that once it is marked immutable you can only delete and recreate it [1]:

apiVersion: v1
kind: Secret
metadata: {name: app-immutable, namespace: demo}
immutable: true
stringData: {k: v1}

Pros and cons

Pros

  • A volume is a tmpfs, so the kubelet does not write the values to durable storage [1]. The capture shows the mount type.
  • A mounted file follows a Secret update. In this run it changed by the first check, 20 seconds later; the docs say the delay can be as long as the kubelet sync period plus cache propagation, and that a subPath mount gets no updates [1].
  • immutable: true stops accidental edits. Patching ours failed with field is immutable when immutable is set.

Cons

  • An environment variable is fixed when the container starts. It stayed at the old value through the last sample at 121 seconds, so a rotation needs a restart.
  • The variable is visible in the process environment (/proc/1/environ in the capture), which log dumps and debug commands can expose. A file avoids that, though anyone who can exec into the pod can read both.

Who can read Secrets

What it is and how it works

Kubernetes RBAC decides who can read a Secret through the API. The docs warn that granting list or watch on Secrets lets a subject read all Secret data in that namespace, not only the Secrets its pods reference [1]. We tested that and a second route.

Demo

kubectl -n demo create role list-secrets --verb=list --resource=secrets
kubectl -n demo create rolebinding lister --role=list-secrets --serviceaccount=demo:lister
kubectl -n demo create role make-pods --verb=create --resource=pods
kubectl -n demo create rolebinding creator --role=make-pods --serviceaccount=demo:creator
kubectl -n demo auth can-i get secret/app-basic --as=system:serviceaccount:demo:lister
kubectl -n demo get secrets -o jsonpath='{.items[?(@.metadata.name=="app-basic")].data.password}' \
  --as=system:serviceaccount:demo:lister | base64 -d

For the second route, the creator service account, which can only create pods, created a pod that mounts app-basic and prints the file; then an administrator read the pod’s log:

Captured output: list, get and create pods (kind-ksec, 21:07 UTC, one run)
## 6. RBAC: what list, get and create pods give
serviceaccount/lister created
serviceaccount/creator created
serviceaccount/nobody created
role.rbac.authorization.k8s.io/list-secrets created
rolebinding.rbac.authorization.k8s.io/lister created
role.rbac.authorization.k8s.io/make-pods created
rolebinding.rbac.authorization.k8s.io/creator created
$ can-i get secret as lister: no   can-i list secrets: yes
$ can-i get secret as nobody: no   can-i list secrets: no
$ as "lister" (verb list only): kubectl get secrets -o jsonpath of a password
Error from server (Forbidden): secrets "app-basic" is forbidden: User "system:serviceaccount:demo:lister" cannot get res
t0ps3cret
$ as "nobody" (no secret permissions)
Error from server (Forbidden): secrets is forbidden: User "system:serviceaccount:demo:nobody" cannot list resource "secrets" in API group "" in the namespace "d
$ as "creator" (create pods only): create a pod that mounts app-basic, then an admin reads its log
pod/sneaky created
Error from server (Forbidden): secrets "app-basic" is forbidden: User "system:serviceaccount:demo:creator" cannot get resource "secrets" in 
admin reads the log of the pod the creator made: t0ps3cret

Pros and cons

Pros

  • RBAC needs no extra software, and a service account with no Secret verbs got Forbidden.
  • kubectl auth can-i --as=... shows what an identity can do before you grant it.

Cons

  • The list verb reads values even when get by name is refused: can-i get answered no, and the jsonpath query still returned the password.
  • A service account that can create pods in a namespace can read that namespace’s Secrets by mounting them. Our creator could not get the Secret, yet the pod it made printed it. Restrict pods creation as carefully as secrets.

Encryption at rest

What it is and how it works

Encryption at rest changes how the API server writes Secrets to etcd. It does not change who can read them through the API. You enable it by pointing the API server at an EncryptionConfiguration file with --encryption-provider-config [2]. The providers differ in where the key lives: identity stores plain text, aescbc and aesgcm read a key from the file on the control-plane host, and a KMS provider uses a key held by an external service. Kubernetes’ provider table rates aescbc weak and not recommended, because of CBC’s padding-oracle weakness and because the key sits on the control-plane host, and rates KMS v2 strongest, stable since v1.29 [2]. The main demo here therefore uses KMS v2.

With KMS v2 the API server calls a plugin over a Unix socket on the control-plane host, and the plugin uses a key that stays in the external KMS [2]. We used an open-source plugin, vault-kubernetes-kms v1.4.0 [12], with the transit engine of Vault 2.0.4 (dev mode, in a Docker container) as the KMS.

Demo

Vault runs first, on the Docker network that kind uses. If that network does not exist yet, create any kind cluster once, or run docker network create kind:

docker run -d --name kms-vault --network kind --cap-add IPC_LOCK \
  -e VAULT_DEV_ROOT_TOKEN_ID=root hashicorp/vault:2.0.4
V() { docker exec -e VAULT_ADDR=http://127.0.0.1:8200 -e VAULT_TOKEN=root kms-vault vault "$@"; }
V secrets enable transit
V write -f transit/keys/kms

Three files wire the plugin into the control plane: the encryption configuration, which holds no key material; the plugin as a static pod; and the kind configuration that mounts both files and points the API server at the configuration:

EncryptionConfiguration for KMS v2 (encryption_provider_config_v2.yml)
kind: EncryptionConfiguration
apiVersion: apiserver.config.k8s.io/v1
resources:
  - resources: [secrets]
    providers:
      - kms:
          apiVersion: v2
          name: vault-kubernetes-kms
          endpoint: unix:///opt/kms/vaultkms.socket
      - identity: {}
The plugin as a static pod (vault-kubernetes-kms.yml)
apiVersion: v1
kind: Pod
metadata:
  name: vault-kubernetes-kms
  namespace: kube-system
spec:
  priorityClassName: system-node-critical
  hostNetwork: true
  containers:
    - name: vault-kubernetes-kms
      image: falcosuessgott/vault-kubernetes-kms:v1.4.0
      imagePullPolicy: IfNotPresent
      command:
        - /vault-kubernetes-kms
        - -vault-address=http://kms-vault:8200
        - -auth-method=token
        - -token=root
      volumeMounts:
        - name: kms
          mountPath: /opt/kms
      livenessProbe:
        httpGet: {path: /health, port: 8080}
      readinessProbe:
        httpGet: {path: /live, port: 8080}
  volumes:
    - name: kms
      hostPath: {path: /opt/kms}
kind configuration (kind-kms.yaml)
kind: Cluster
apiVersion: kind.x-k8s.io/v1alpha4
nodes:
  - role: control-plane
    extraMounts:
      - containerPath: /etc/kubernetes/encryption_provider_config_v2.yaml
        hostPath: /path/to/encryption_provider_config_v2.yml
        readOnly: true
        propagation: None
      - containerPath: /etc/kubernetes/manifests/vault-kubernetes-kms.yaml
        hostPath: /path/to/vault-kubernetes-kms.yml
        readOnly: true
        propagation: None
    kubeadmConfigPatches:
      - |
        kind: ClusterConfiguration
        apiServer:
          extraArgs:
            encryption-provider-config: "/etc/kubernetes/encryption_provider_config_v2.yaml"
          extraVolumes:
            - name: encryption-config
              hostPath: "/etc/kubernetes/encryption_provider_config_v2.yaml"
              mountPath: "/etc/kubernetes/encryption_provider_config_v2.yaml"
              readOnly: true
              pathType: File
            - name: socket
              hostPath: "/opt/kms"
              mountPath: "/opt/kms"
kind create cluster --name ksec-kms --config kind-kms.yaml

We wrote a Secret, rotated the transit key, wrote a second Secret, then paused the Vault container (it stays in memory, only unreachable). With Vault paused we read and wrote Secrets, restarted the API server, and read again; finally we unpaused Vault. The prefix and the vault:vN: marker come from the raw etcd bytes:

Captured output: KMS v2 with Vault transit: prefix, rotation, Vault paused, API server restart (kind-ksec-kms, 22:34 UTC, one run)
# Encryption at rest with a KMS v2 plugin backed by Vault transit, 2026-11-08T22:34:38Z, cluster kind-ksec-kms
$ the plugin pod and the API server flag
NAME                                          READY   STATUS    RESTARTS   AGE
vault-kubernetes-kms-ksec-kms-control-plane   0/1     Running   0          8s
--encryption-provider-config=/etc/kubernetes/encryption_provider_config_v2.yaml

$ EncryptionConfiguration (no key material in the file)
kind: EncryptionConfiguration
apiVersion: apiserver.config.k8s.io/v1
resources:
  - resources: [secrets]
    providers:
      - kms:
          apiVersion: v2
          name: vault-kubernetes-kms
          endpoint: unix:///opt/kms/vaultkms.socket
      - identity: {}

$ Vault transit key before
{'name': 'kms', 'type': 'aes256-gcm96', 'latest_version': 1, 'keys': ['1']}
namespace/demo created
secret/before-rotation created
$ etcd: what is stored for the new Secret
plaintext present=0 prefix=k8s:enc:kms:v2:vault-kubernetes-kms:. transit key version in the payload=vault:v1:
API returns: kms-value-one

$ rotate the Vault transit key, then write another Secret
latest_version            2
secret/after-rotation created
before-rotation: plaintext present=0 prefix=k8s:enc:kms:v2:vault-kubernetes-kms:. transit key version in the payload=vault:v1:
after-rotation:  plaintext present=0 prefix=k8s:enc:kms:v2:vault-kubernetes-kms:. transit key version in the payload=vault:v2:
both readable through the API: kms-value-one / kms-value-two

$ pause the Vault container (unreachable, data kept), then read and write Secrets without restarting the API server
# Vault paused at 22:34:59
read an existing Secret: kms-value-one
secret/while-paused created

$ restart the API server while Vault is paused (move its static pod manifest away and back)
t+10s readyz=[Unable to connect to the server: EOF] secret=[Error from server (Forbidden): secrets "before-rotation" is forbidden: User "kubernetes-admin" cannot get reso]
t+20s readyz=[Error from server (InternalError): an error on the] secret=[Error from server (InternalError): Internal error occurred: StorageError: corrupt object, Code: 7, Key: /secre]
t+30s readyz=[Error from server (InternalError): an error on the] secret=[Error from server (InternalError): Internal error occurred: StorageError: corrupt object, Code: 7, Key: /secre]
t+40s readyz=[Error from server (InternalError): an error on the] secret=[Error from server (InternalError): Internal error occurred: StorageError: corrupt object, Code: 7, Key: /secre]
t+50s readyz=[Error from server (InternalError): an error on the] secret=[Error from server (InternalError): Internal error occurred: StorageError: corrupt object, Code: 7, Key: /secre]
t+60s readyz=[Error from server (InternalError): an error on the] secret=[Error from server (InternalError): Internal error occurred: StorageError: corrupt object, Code: 7, Key: /secre]

$ unpause Vault
# Vault unpaused at 22:36:34
t+15s secret=[kms-value-one] while-paused=[kms-value-three]
t+30s secret=[kms-value-one] while-paused=[kms-value-three]
t+45s secret=[kms-value-one] while-paused=[kms-value-three]
t+60s secret=[kms-value-one] while-paused=[kms-value-three]

In an earlier run we stopped and started the Vault container instead of pausing it. Dev mode keeps data in memory, so the restarted Vault was empty and the transit key was gone:

Captured output: the earlier run, where the dev-mode Vault was stopped and started and so lost the key (kind-ksec-kms, 22:30 UTC, one run)
$ stop Vault, then restart the API server (move its static pod manifest away and back)
# Vault stopped at 22:30:44
read before restart: kms-value-one
t+10s readyz=[Unable to connect to the server: EOF] secret=[Error from server (Forbidden): secrets "before-rotation" is forbidden: User "kubernetes-admin" cannot get resource "secr]
t+20s readyz=[Error from server (InternalError): an error on the server ("] secret=[Error from server (InternalError): Internal error occurred: StorageError: corrupt object, Code: 7, Key: /secrets/demo/be]
t+30s readyz=[Error from server (InternalError): an error on the server ("] secret=[Error from server (InternalError): Internal error occurred: StorageError: corrupt object, Code: 7, Key: /secrets/demo/be]
t+40s readyz=[Error from server (InternalError): an error on the server ("] secret=[Error from server (InternalError): Internal error occurred: StorageError: corrupt object, Code: 7, Key: /secrets/demo/be]
t+50s readyz=[Error from server (InternalError): an error on the server ("] secret=[Error from server (InternalError): Internal error occurred: StorageError: corrupt object, Code: 7, Key: /secrets/demo/be]
t+60s readyz=[Error from server (InternalError): an error on the server ("] secret=[Error from server (InternalError): Internal error occurred: StorageError: corrupt object, Code: 7, Key: /secrets/demo/be]
t+70s readyz=[Error from server (InternalError): an error on the server ("] secret=[Error from server (InternalError): Internal error occurred: StorageError: corrupt object, Code: 7, Key: /secrets/demo/be]
t+80s readyz=[Error from server (InternalError): an error on the server ("] secret=[Error from server (InternalError): Internal error occurred: StorageError: corrupt object, Code: 7, Key: /secrets/demo/be]

$ start Vault again
# Vault started at 22:32:44
Initialized     true
Sealed          false
(dev mode keeps data in memory: a restarted container is a new Vault, so the transit key is gone unless it was kept)
t+15s secret=[Error from server (InternalError): Internal error occurred: StorageError: corrupt object, Code: 7, K]
t+30s secret=[Error from server (InternalError): Internal error occurred: StorageError: corrupt object, Code: 7, K]
t+45s secret=[Error from server (InternalError): Internal error occurred: StorageError: corrupt object, Code: 7, K]
t+60s secret=[Error from server (InternalError): Internal error occurred: StorageError: corrupt object, Code: 7, K]

Writes made before encryption was on stay readable, whatever the provider. We tested that with the local aescbc provider, because it needs no external service: a Secret went in while identity was first, then we switched the order and restarted the API server. The old Secret stayed plaintext in etcd and a new one was encrypted. Running the rewrite command from the docs encrypted the old one [2]:

kubectl get secrets --all-namespaces -o json | kubectl replace -f -

For that test the cluster used this configuration file and kind configuration, with a key from openssl rand -base64 32:

EncryptionConfiguration with aescbc (enc.yaml)
apiVersion: apiserver.config.k8s.io/v1
kind: EncryptionConfiguration
resources:
  - resources: [secrets]
    providers:
      - aescbc:
          keys:
            - name: key1
              secret: <32-byte base64 key>
      - identity: {}
kind configuration for the aescbc cluster (kind-enc.yaml)
kind: Cluster
apiVersion: kind.x-k8s.io/v1alpha4
nodes:
  - role: control-plane
    extraMounts:
      - hostPath: /path/to/enc.yaml
        containerPath: /etc/kubernetes/enc/enc.yaml
        readOnly: true
    kubeadmConfigPatches:
      - |
        kind: ClusterConfiguration
        apiServer:
          extraArgs:
            encryption-provider-config: /etc/kubernetes/enc/enc.yaml
          extraVolumes:
            - name: enc
              hostPath: /etc/kubernetes/enc
              mountPath: /etc/kubernetes/enc
              readOnly: true
              pathType: DirectoryOrCreate
Captured output: a Secret written before encryption, then rewritten, with the local aescbc provider (kind-ksec-enc, 21:20 UTC, one run; the API server was restarted twice)
# Encryption at rest: data written before encryption stays readable in etcd, 2026-11-08T21:20:40Z, cluster kind-ksec-enc
## step 1: provider order identity first (no encryption), write a Secret
secret/written-before created
stored bytes: plaintext value present=1 prefix=k8s.....v1..Secret........wr
## step 2: provider order aescbc first, restart the API server, write another Secret
secret/written-after created
written-before stored bytes: plaintext value present=1 prefix=k8s.....v1..Secret........wr
written-after  stored bytes: plaintext value present=0 prefix=k8s:enc:aescbc:v1:key1:..&..
## step 3: rewrite every Secret
secret/written-after replaced
secret/written-before replaced
written-before stored bytes: plaintext value present=0 prefix=k8s:enc:aescbc:v1:key1:V....
## the API returns plaintext in every case
written-before via kubectl: before-value

Pros and cons

Pros

  • With KMS v2 the file on the control-plane host holds no key; the key stays in Vault. The configuration in the capture contains only a socket path.
  • Rotating the transit key needs no API server restart: the Secret written after the rotation carried vault:v2:, the older one stayed vault:v1:, and both read back.
  • With Vault paused, existing Secrets still read and new ones still wrote until the API server was restarted.
  • Encrypting existing Secrets takes one command, and it protects against an etcd compromise such as a stolen backup or disk [2].

Cons

  • After the API server restarted with Vault paused, reads failed with StorageError: corrupt object, Code: 7 until Vault was back; once unpaused they recovered by the first check, 15 seconds later. The control plane now depends on the KMS at start-up.
  • Losing the key loses the data. The restarted dev-mode Vault had no transit key, and Secrets stayed unreadable after it came back. Back the key up, or use a Vault with real storage.
  • The plugin runs on every control-plane node, and we judged only that it worked. We did not assess its maturity beyond the release we used.
  • Encryption at rest does nothing for anyone who can read Secrets through the API, as the RBAC section showed. A locally managed key also fails to protect against a host compromise, in Kubernetes’ words [2].

Sealed Secrets

What it is and how it works

Sealed Secrets lets you keep encrypted secrets in Git. kubeseal encrypts a Secret with a public certificate fetched from a controller running in the cluster, and the result, a SealedSecret, is safe to commit. Only the controller holds the private key, and it turns the SealedSecret back into a normal Secret [3]. The default scope is strict, which binds the sealed value to the Secret’s name and namespace; namespace-wide and cluster-wide relax that [3].

Demo

helm repo add sealed-secrets https://bitnami.github.io/sealed-secrets
helm install sealed-secrets sealed-secrets/sealed-secrets -n kube-system \
  --set fullnameOverride=sealed-secrets-controller --wait
kubectl create namespace shop
kubectl -n shop create secret generic db --from-literal=password=lab-db-password \
  --dry-run=client -o yaml | kubeseal --format yaml > sealed.yaml
kubectl apply -f sealed.yaml

We then renamed the SealedSecret, moved it to another namespace, tried --scope namespace-wide, and applied the same file on the second cluster, which has its own controller:

Captured output: seal, apply, rename, move, namespace-wide scope (kind-ksec, 21:18 UTC, one run)
# Sealed Secrets, 2026-11-08T21:18:41Z, cluster kind-ksec
"sealed-secrets" has been added to your repositories
$ helm install sealed-secrets sealed-secrets/sealed-secrets -n kube-system --set fullnameOverride=sealed-secrets-controller --wait
NAME: sealed-secrets
NAMESPACE: kube-system
STATUS: deployed
NAME          	NAMESPACE  	REVISION	UPDATED                             	STATUS  	CHART                	APP VERSION
sealed-secrets	kube-system	1       	2026-11-08 21:18:43.764052 +0000 UTC	deployed	sealed-secrets-2.22.0	0.41.2     
namespace/shop created

$ seal a Secret (strict scope, the default)
sealed.yaml lines:       13; contains the plaintext: 0
---
apiVersion: bitnami.com/v1alpha1
kind: SealedSecret
metadata:
  name: db
  namespace: shop
spec:
  encryptedData:
    password: AgBtOFBB0gDpKEOyuNt1YRubZyw6SbKIQ8Az+hV4HWfjbKlghaFSF9uddzIkI9eYlRG8bYTRf3n6fUS9GEQcCvUUTMiMkb4z7+7OJPbAyZNnERIRr7jbMqpP+X9FwGEsLiBO6e5XF3WNmZixKIxC6PgWEmxZzrclNJm1Jt7IdzsqQsL9IiyjbdIC1fLw4SLqhiEgPDgN1y/zkLzArNj1fFbBvLWq2lFNN3oULLgw2G49jVlmnLDULwEk7nPGqgVnZUfOyndLe2xsdG6Mh25CgMVl0z+hMUZ+Trh8wo6vLodccgk1bPORSs1RGPT+ay6JhKWOTBy+3EwNV0bufzbGq8QDuUufnQJGKAMOQdWMFwsdwhX67Fc2Hvdg43ME0tTlHLy1jIClAjfhJiV0Jjhr4TmO7kJnhwUweRXvwJC9VybgL+c63vyI65ugr/XkLsgF90f4rGH2gXGkIpWo9TFIaNkDg9EeECEIj4Nb6lDaV7G+gv0Nno5jLCx32F/Tvskumg9cWLGY6WQplXbJHld7ckG0Yyu89eEpWH+JIkZRv8ey2sgHcM9ffSYh5CYeO/eIO+zsVerDT3uq0wjms1sLLNoiE1ICGHDaYJsMhdLTluxJMOE8LHafmb0/qZeZDiWqeZSaIHRcKnFkMToZ/5G8zghjg9CSrYfQhZzuYSLsa6adsm3tm2zaUC+0eaMRo9VpuHEHW24tJEfVS/BJASIFRAk=
  template:
    metadata:
      name: db
      namespace: shop

$ kubectl apply -f sealed.yaml
sealedsecret.bitnami.com/db created
NAME                          STATUS   SYNCED   AGE
sealedsecret.bitnami.com/db            True     6s

NAME        TYPE     DATA   AGE
secret/db   Opaque   1      6s
Secret value: lab-db-password   owner: SealedSecret

$ rename the SealedSecret (strict scope binds name and namespace)
sealedsecret.bitnami.com/db-renamed created
Synced=False: no key could decrypt secret (password)
Error from server (NotFound): secrets "db-renamed" not found

$ move it to another namespace
namespace/other created
sealedsecret.bitnami.com/db created
Synced=False: no key could decrypt secret (password)

$ namespace-wide scope allows a rename inside the namespace
sealedsecret.bitnami.com/db2-renamed created
NAME          TYPE     DATA   AGE
db2-renamed   Opaque   1      6s

$ the sealing keys live in the cluster as Secrets (names only)
NAME                      TYPE                DATA   AGE
sealed-secrets-keyl2tn9   kubernetes.io/tls   2      26s
Captured output: the same SealedSecret on a second cluster (kind-ksec-enc, 21:20 UTC, one run)
# Sealed Secrets: the same SealedSecret applied to a second cluster, 2026-11-08T21:20:07Z, cluster kind-ksec-enc (its own controller)
STATUS: deployed
namespace/shop created
$ kubectl apply -f sealed.yaml   # sealed on cluster A, applied on cluster B
sealedsecret.bitnami.com/db created
Synced=False: no key could decrypt secret (password)
Error from server (NotFound): secrets "db" not found

Pros and cons

Pros

  • No external service to run. The committed file contains no plaintext (the capture counts zero occurrences of the value).
  • Strict scope worked as designed: a renamed or moved copy failed with no key could decrypt secret (password), so a stolen SealedSecret is not reusable under another name or namespace.

Cons

  • The decryption key lives in the cluster as a Secret in kube-system (type kubernetes.io/tls in our run). Losing it, or building a new cluster, means your committed files cannot be unsealed; the same file failed on our second cluster. Back the key up.
  • Sealing keys renew every 30 days by default, but the project states that sealed secrets are not rotated and old keys are not deleted [3], so renewal does not change a leaked password.

SOPS with age

What it is and how it works

SOPS is an editor and encryption tool for YAML, JSON, ENV, INI and binary files, with key backends that include age, PGP and the major cloud KMS services [4]. For Kubernetes manifests you set encrypted_regex so only the data and stringData fields are encrypted and the rest stays readable. A decrypt step, such as a CI job, Flux’s kustomize-controller or an Argo CD plugin, applies the result [5].

Demo

age-keygen -o key.txt                 # prints the public key
cat > .sops.yaml <<'YAML'
creation_rules:
  - path_regex: secret\.yaml$
    encrypted_regex: '^(data|stringData)$'
    age: <the public key>
YAML
sops --encrypt secret.yaml > secret.enc.yaml
SOPS_AGE_KEY_FILE=key.txt sops --decrypt secret.enc.yaml | kubectl apply -f -

The capture shows the encrypted file, the apply, a decrypt without the key, a decrypt after we changed two characters of one encrypted value, and adding a second recipient with sops rotate:

Captured output: encrypt, apply, no key, tampered value, second recipient (sops 3.13.3, age 1.3.2, kind-ksec, 21:20 UTC, one run)
# SOPS with age, 2026-11-08T21:20:51Z, sops 3.13.3, age v1.3.2
$ age-keygen -o key.txt
Public key: age12xup6pjlekjj8rqkuplnga96cp26vzggh3q56g3r3mnkpexsjqysdcusez
public key: age12xup6pjlekjj8rqkuplnga96cp26vzggh3q56g3r3mnkpexsjqysdcusez

$ cat .sops.yaml
creation_rules:
  - path_regex: secret\.yaml$
    encrypted_regex: '^(data|stringData)$'
    age: age12xup6pjlekjj8rqkuplnga96cp26vzggh3q56g3r3mnkpexsjqysdcusez

$ sops --encrypt secret.yaml > secret.enc.yaml
apiVersion: v1
kind: Secret
metadata:
    name: db
    namespace: sopsdemo
type: Opaque
stringData:
    password: ENC[AES256_GCM,data:7pqawgGtAteV...]
    username: ENC[AES256_GCM,data:l8cwdg==,iv:iGqzEHQlWmM4wbwJNUUQz9+aaebIeoHT0S4yVFkU85c=,tag:Ay3qXexOzaFSi4+DyDKz3g==,type:str]
sops:
    age:
        - enc: |
            -----BEGIN AGE ENCRYPTED FILE----- ...
            -----END AGE ENCRYPTED FILE-----
          recipient: age12xup6pjlekjj8rqkuplnga96cp26vzggh3q56g3r3mnkpexsjqysdcusez
    encrypted_regex: ^(data|stringData)$
    lastmodified: "2026-11-08T21:20:52Z"
    mac: ENC[AES256_GCM,data:qukx6p8Zs1GI...]
    version: 3.13.3
plaintext password in the encrypted file: 0   apiVersion/kind/name still readable: 2

$ sops --decrypt secret.enc.yaml | kubectl apply -f -
namespace/sopsdemo created
secret/db created
Secret value in the cluster: lab-db-password

$ decrypt without the key
Failed to get the data key required to decrypt the SOPS file.

Group 0: FAILED

$ tamper with one encrypted value (swap two characters), then decrypt
Error decrypting tree: Error walking tree: Could not decrypt value: Could not decrypt with AES_GCM: cipher: message authentication failed

$ rotate the data key and add a second recipient
second key can decrypt: 1

Pros and cons

Pros

  • The file keeps apiVersion, kind and metadata readable, so a diff and a code review still make sense; only the values are encrypted.
  • A tampered value did not decrypt (message authentication failed), and without the key SOPS refused to decrypt at all.
  • sops rotate --add-age added a second key holder, which the second key then used to decrypt.

Cons

  • Whoever holds the private age key reads everything. Guard it, and keep it out of the repository the files live in.
  • Something must decrypt at deploy time. Our demo piped sops --decrypt into kubectl apply; the next two sections run the decrypt step inside Flux and Argo CD.

SOPS with Flux

What it is and how it works

Flux’s kustomize-controller can decrypt SOPS files while it applies a repository. A Kustomization carries decryption.provider: sops and a secretRef to a Secret holding the age key [5]. Git then holds only encrypted values, and the cluster holds the key.

Demo

Flux v2.10.0 [13] needs a Git server it can reach over HTTP or SSH. We ran Gitea 1.24 as a pod; any server works. Install Flux, start Gitea, create a user and an empty repository secrets:

kubectl apply --server-side -f https://github.com/fluxcd/flux2/releases/download/v2.10.0/install.yaml
kubectl apply -f gitea.yaml
kubectl -n gitea wait --for=condition=Ready pod/gitea --timeout=240s
kubectl -n gitea exec gitea -- su git -c "gitea admin user create --admin --username lab \
  --password labpass --email [email protected] --must-change-password=false"
kubectl -n gitea exec gitea -- curl -s -u lab:labpass -X POST http://localhost:3000/api/v1/user/repos \
  -H 'Content-Type: application/json' -d '{"name":"secrets"}'
Gitea pod and service (gitea.yaml)
apiVersion: v1
kind: Namespace
metadata: {name: gitea}
---
apiVersion: v1
kind: Pod
metadata: {name: gitea, namespace: gitea, labels: {app: gitea}}
spec:
  containers:
    - name: gitea
      image: gitea/gitea:1.24
      env:
        - {name: GITEA__security__INSTALL_LOCK, value: "true"}
        - {name: GITEA__database__DB_TYPE, value: sqlite3}
        - {name: GITEA__server__ROOT_URL, value: "http://gitea.gitea.svc.cluster.local:3000/"}
        - {name: GITEA__service__DISABLE_REGISTRATION, value: "true"}
      ports: [{containerPort: 3000}]
---
apiVersion: v1
kind: Service
metadata: {name: gitea, namespace: gitea}
spec:
  selector: {app: gitea}
  ports: [{port: 3000}]

Create the encrypted Secret, push it, give Flux the key and the repository login, then declare the source and the Kustomization:

age-keygen -o flux-key.txt                      # keep this file out of the repository
mkdir apps && cat > .sops.yaml <<'YAML'
creation_rules:
  - path_regex: apps/secret\.yaml$
    encrypted_regex: '^(data|stringData)$'
    age: <the public key>
YAML
# apps/namespace.yaml creates the namespace flux-demo; plain.yaml is a Secret with stringData
sops --encrypt --filename-override apps/secret.yaml plain.yaml > apps/secret.yaml
git init -b main && git add -A && git commit -m "encrypted secret"
git push http://lab:labpass@localhost:3300/lab/secrets.git main   # kubectl port-forward pod/gitea 3300:3000
kubectl -n flux-system create secret generic sops-age --from-file=age.agekey=flux-key.txt
kubectl -n flux-system create secret generic gitea-auth --from-literal=username=lab --from-literal=password=labpass
kubectl apply -f flux-objects.yaml
GitRepository and Kustomization (flux-objects.yaml)
apiVersion: source.toolkit.fluxcd.io/v1
kind: GitRepository
metadata: {name: secrets, namespace: flux-system}
spec:
  interval: 30s
  url: http://gitea.gitea.svc.cluster.local:3000/lab/secrets.git
  ref: {branch: main}
  secretRef: {name: gitea-auth}
---
apiVersion: kustomize.toolkit.fluxcd.io/v1
kind: Kustomization
metadata: {name: secrets, namespace: flux-system}
spec:
  interval: 30s
  path: ./apps
  prune: true
  sourceRef: {kind: GitRepository, name: secrets}
  decryption:
    provider: sops
    secretRef: {name: sops-age}

We then changed the value (decrypt, edit, re-encrypt, commit, push) and, at the end, deleted the age key Secret and forced a reconcile:

Captured output: Flux v2.10.0 decrypting a SOPS file from Gitea, a rotation, and a missing key (kind-ksec, 22:42 UTC, one run)
# Flux v2.10.0 decrypting SOPS (age) from Git, 2026-11-08T22:42:23Z, cluster kind-ksec
$ kubectl apply --server-side -f https://github.com/fluxcd/flux2/releases/download/v2.10.0/install.yaml
networkpolicy.networking.k8s.io/allow-scraping serverside-applied
networkpolicy.networking.k8s.io/allow-webhooks serverside-applied
deployment "source-controller" successfully rolled out
deployment "kustomize-controller" successfully rolled out
namespace/gitea created
pod/gitea created
service/gitea created
pod/gitea condition met
New user 'lab' has been successfully created!
create repo: HTTP 201
remote: Processed 1 references in total        
$ apps/secret.yaml as committed (values encrypted, plaintext count)
apiVersion: v1
kind: Secret
metadata:
    name: db
    namespace: flux-demo
type: Opaque
stringData:
    password: ENC[AES256_GCM,data:2vn9gQYY...]
sops:
    age:
        - enc: |
            -----BEGIN AGE ENCRYPTED FILE-----
            Oka+gI93rw2LsVjtie3xsWb6tUF8AqVq3XxlndTbLuPMAAXzFD5DzA==
            -----END AGE ENCRYPTED FILE-----
          recipient: age1f66s5dt6z0hswvvy5qzt2c3ucsxn5lrcg65wd7gpfnen4uwxhpgqg49s5j
    encrypted_regex: ^(data|stringData)$
    lastmodified: "2026-11-08T22:44:07Z"
    mac: ENC[AES256_GCM,data:82YIpuKs...]
plaintext in the committed file: 0
secret/sops-age created
secret/gitea-auth created
gitrepository.source.toolkit.fluxcd.io/secrets created
kustomization.kustomize.toolkit.fluxcd.io/secrets created

$ kubectl -n flux-system get gitrepository,kustomization
NAME                                             URL                                                         AGE   READY   STATUS
gitrepository.source.toolkit.fluxcd.io/secrets   http://gitea.gitea.svc.cluster.local:3000/lab/secrets.git   45s   True    stored artifact for revision 'main@sha1:cc19a7eca9f292d4c08b31a34d0aaa80a97ee07e'

NAME                                                AGE   READY   STATUS
kustomization.kustomize.toolkit.fluxcd.io/secrets   45s   True    Applied revision: main@sha1:cc19a7eca9f292d4c08b31a34d0aaa80a97ee07e
Secret in the cluster: flux-secret-1

$ change the value in Git (decrypt, edit, re-encrypt, commit, push)
remote: Processed 1 references in total        
# pushed at 22:44:53
t+15s secret=flux-secret-1
t+30s secret=flux-secret-2
t+45s secret=flux-secret-2
t+60s secret=flux-secret-2
t+75s secret=flux-secret-2
t+90s secret=flux-secret-2

$ remove the age key Secret, then force a reconcile
secret "sops-age" deleted from flux-system namespace
kustomization.kustomize.toolkit.fluxcd.io/secrets annotated
False: secrets "sops-age" not found
Secret in the cluster: flux-secret-2

Pros and cons

Pros

  • Git holds no plaintext: the committed file shows ENC[AES256_GCM,...] values and the capture counts zero occurrences of the password.
  • A value changed in Git reached the cluster by the second sample, 30 seconds after the push, with 30-second intervals.
  • With the key Secret gone, the Kustomization went Ready=False with secrets "sops-age" not found, and the cluster Secret kept its last value, so a lost key does not delete running credentials.

Cons

  • The key Secret must be created by hand before Flux can decrypt anything, and anyone who can read it in flux-system can decrypt every file.
  • The failure is visible only on the Kustomization status. Alert on it, because the workload keeps running on the old value.

SOPS with Argo CD

What it is and how it works

Argo CD has no built-in SOPS decryption. Its secret-management page prefers populating secrets on the destination cluster with an operator, and “strongly” cautions against manifest-generating plugins [6]. KSOPS is such a plugin: a kustomize exec plugin that decrypts SOPS files while Argo CD renders the application, and it needs changes to the repo-server [10]. We followed KSOPS v4.5.1’s README on Argo CD 3.6.1.

Demo

Three changes to Argo CD: allow kustomize plugins in argocd-cm, give the repo-server the age key, and patch the repo-server so an init container installs ksops and a matching kustomize:

kubectl -n argocd create secret generic sops-age --from-file=age.agekey=key.txt
kubectl -n argocd patch configmap argocd-cm --type merge \
  -p '{"data":{"kustomize.buildOptions":"--enable-alpha-plugins --enable-exec"}}'
kubectl -n argocd patch deployment argocd-repo-server --patch-file repo-server-patch.yaml
repo-server patch (repo-server-patch.yaml)
apiVersion: apps/v1
kind: Deployment
metadata:
  name: argocd-repo-server
spec:
  template:
    spec:
      volumes:
        - name: custom-tools
          emptyDir: {}
        - name: sops-age
          secret:
            secretName: sops-age
            items: [{key: age.agekey, path: keys.txt}]
      initContainers:
        - name: install-ksops
          image: viaductoss/ksops:v4.5.1
          command: ["/usr/local/bin/ksops", "install", "--with-kustomize", "/custom-tools"]
          volumeMounts:
            - {mountPath: /custom-tools, name: custom-tools}
      containers:
        - name: argocd-repo-server
          env:
            - {name: XDG_CONFIG_HOME, value: /.config}
            - {name: SOPS_AGE_KEY_FILE, value: /.config/sops/age/keys.txt}
          volumeMounts:
            - {mountPath: /usr/local/bin/kustomize, name: custom-tools, subPath: kustomize}
            - {mountPath: /usr/local/bin/ksops, name: custom-tools, subPath: ksops}
            - {mountPath: /.config/sops/age, name: sops-age}

The repository holds a namespace, the encrypted Secret, a KSOPS generator and a kustomization.yaml, and the age key stays outside it:

# secret-generator.yaml
apiVersion: viaduct.ai/v1
kind: ksops
metadata:
  name: db-generator
  annotations:
    config.kubernetes.io/function: |
      exec:
        path: ksops
files:
  - ./secret.enc.yaml
---
# kustomization.yaml
resources:
  - namespace.yaml
generators:
  - ./secret-generator.yaml
Application (application.yaml)
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata: {name: ksops-demo, namespace: argocd}
spec:
  project: default
  source:
    repoURL: git://gitd.gitd.svc.cluster.local:9418/ksops.git
    targetRevision: main
    path: .
  destination: {server: https://kubernetes.default.svc}
  syncPolicy:
    automated: {prune: true, selfHeal: true}

We then changed the value in Git and hard-refreshed the application, and finally re-encrypted the file for an age key that the repo-server does not hold:

Captured output: Argo CD 3.6.1 with KSOPS v4.5.1: sync, rotation, and a file encrypted for the wrong key (kind-esoargo, 22:50 UTC, one run)
# Argo CD 3.6.1 with KSOPS v4.5.1 (SOPS, age), 2026-11-08T22:50:52Z, cluster kind-esoargo
secret/sops-age created
$ argocd-cm: kustomize.buildOptions
configmap/argocd-cm patched (no change)
$ patch the repo-server (init container installs ksops and kustomize, the age key is mounted)
deployment.apps/argocd-repo-server patched (no change)
deployment "argocd-repo-server" successfully rolled out
init containers: install-ksops copyutil
$ files in the repository
.sops.yaml kustomization.yaml namespace.yaml secret-generator.yaml secret.enc.yaml 
plaintext in the repository:        0
application.argoproj.io/ksops-demo created

$ kubectl -n argocd get app ksops-demo
NAME         SYNC STATUS   HEALTH STATUS
ksops-demo   Synced        Healthy

Secret in the cluster: ksops-secret-1

$ change the value in Git, hard refresh
application.argoproj.io/ksops-demo annotated
t+15s secret=ksops-secret-2 app=Synced Healthy
t+30s secret=ksops-secret-2 app=Synced Healthy
t+45s secret=ksops-secret-2 app=Synced Healthy
t+60s secret=ksops-secret-2 app=Synced Healthy

$ re-encrypt the file for a different age key that the repo-server does not hold, push, refresh
application.argoproj.io/ksops-demo annotated
ksops-demo   Unknown   Healthy
ComparisonError: Failed to load target state: failed to generate manifest for source 1 of 1: rpc error: code = Unknown desc = `kustomize build <path to cached source> --enable-alpha-plugins --enable-exec` failed exit status 1: failed to evaluate function: error decrypting file "./secret.enc.yaml" from manifest.Files: trouble decrypting file: Error getting data key: 0 successful groups required, got 0unable to generate manifests: error decrypting file "./secret.enc.yaml" from manifest.Files: trou
Error: couldn't execute function: exit status 1
Secret in the cluster (unchanged): ksops-secret-2

Pros and cons

Pros

  • It works with a stock Argo CD and the README’s patch: the application reached Synced and Healthy with the decrypted Secret in the cluster, and a Git change followed a hard refresh by the first 15-second sample.
  • A file encrypted for the wrong key produced a clear ComparisonError (error decrypting file "./secret.enc.yaml"), left the application Unknown, and left the cluster Secret unchanged.

Cons

  • You change Argo CD itself: a build option, an init container, an age key mounted into the repo-server, and kustomize swapped for the one KSOPS ships. Upgrades of Argo CD now have a patch to carry.
  • The private key lives in the repo-server pod, and anyone who can exec into it can use it.
  • Keep the age key out of the repository. Our first attempt committed key.txt with git add -A; we removed it and reran the demo.
  • Argo CD’s own documentation recommends operators over this kind of plugin [6].

External Secrets Operator

External Secrets Operator (ESO) keeps Secret objects in sync with a store outside the cluster, such as Vault or AWS Secrets Manager. The value ends in etcd like a native Secret, so the pod consumes it the same way. ESO is also the approach with the most ways to install and connect it, so this section is organized by setup: Vault, Argo CD, Ansible, GitHub Actions, GitHub secrets and Jenkins, each with what it is, how it works, a demo and pros and cons.

Where a demo uses the fake provider, which serves fixed values, note that ESO 2.14.0 prints Warning: store fake isn't currently maintained when you apply such a store [reproduced]. Treat it as a test fixture [14].

What ESO does

Flow of External Secrets Operator: a SecretStore and ExternalSecret tell the controller where to log in and what to fetch; the controller fetches from the provider store, writes a Kubernetes Secret, and the pod mounts it. An environment variable stays fixed until restart, a mounted volume updates in place.

The controller does the fetching. A pod only sees a Kubernetes Secret, so what happens after a change depends on how the pod consumes it. Source: our lab runs, ESO 2.14.0.

A SecretStore (or the cluster-wide ClusterSecretStore) holds the login details for a provider. An ExternalSecret names the keys to fetch, the target Secret and a refreshInterval. The controller logs in, fetches, writes the Secret, and repeats on the interval. Whatever you use to install ESO, and whatever reads the Secret afterwards, those three objects stay the same. That is why the sections below can be read separately.

Every section assumes the base install, which follows the getting-started page [15]:

helm repo add external-secrets https://charts.external-secrets.io
helm install external-secrets external-secrets/external-secrets \
  --version 2.14.0 -n external-secrets --create-namespace --wait

Pin the version. The project supports only the current minor and recommends upgrading one minor at a time [17], and it ships often: v2.11.0 on 18 September, v2.12.0 on 6 October, v2.13.0 on 16 October and v2.14.0 on 23 October 2026 [18]. Removing it needs care. In our lab, helm uninstall with a live ExternalSecret removed 24 of the 25 ESO CRDs at once, but the externalsecrets CRD stayed in Terminating because the ExternalSecret still carried the finalizer externalsecrets.external-secrets.io/externalsecret-cleanup, which only the removed controller could clear. Deleting its namespace then hung until we patched the finalizer away, after which no ESO CRD remained; the generated Secret was still present while it hung. Delete your ExternalSecret objects before you remove the operator.

Captured output: helm uninstall with a live ExternalSecret (kind-esoansible, 20:28 UTC, one run)
# helm uninstall with a live ExternalSecret, 2026-11-08T20:28:18Z, cluster kind-esoansible, ESO chart 2.14.0
$ before: CRDs, ExternalSecret, Secret
ESO CRDs: 25
NAME                                    STORETYPE            STORE   REFRESH INTERVAL   STATUS         READY   LAST SYNC
externalsecret.external-secrets.io/db   ClusterSecretStore   fake    1m                 SecretSynced   True    47s

NAME        TYPE     DATA   AGE
secret/db   Opaque   1      49m

$ helm uninstall external-secrets -n external-secrets
release "external-secrets" uninstalled

$ right after: CRDs and the ExternalSecret
externalsecrets.external-secrets.io
ESO CRDs listed: 1
deletionTimestamp=2026-11-08T20:28:19Z
ExternalSecret finalizers=["externalsecrets.external-secrets.io/externalsecret-cleanup"]
db     Opaque   1      49m

$ kubectl delete namespace demo --wait=false; then 15 s later
namespace "demo" deleted
demo   Terminating   50m

$ remove the finalizer by hand
externalsecret.external-secrets.io/db patched
Error from server (NotFound): namespaces "demo" not found
ESO CRDs listed: 0
DONE

Which setup fits

If you… Read Status
keep secrets in HashiCorp Vault ESO with Vault tested
run the cluster from Argo CD ESO with Argo CD tested
bootstrap clusters with playbooks ESO with Ansible tested
want CI to prove your manifests apply ESO with GitHub Actions tested
need cluster secrets to reach GitHub Actions ESO with GitHub secrets tested
run Jenkins and want credentials from the cluster ESO with Jenkins tested
run Flux ESO with Flux docs only
keep secrets in AWS Secrets Manager ESO with AWS docs only
are unsure ESO is the right tool Where ESO fits docs only

ESO with Vault

What it is and how it works

Vault is a secrets server. ESO logs in to it with Kubernetes authentication: the controller presents a service-account token, Vault checks that token with the TokenReview API, and Vault returns a short-lived Vault token limited by a policy. From there it reads the key-value path the ExternalSecret names. TokenReview needs the system:auth-delegator ClusterRole. The ESO docs say to bind it to “the service account that is used for authentication” [23], which is ambiguous when Vault runs in the cluster. In our setup the Vault chart bound it to Vault’s own service account (vault-server-binding), and Vault’s Kubernetes auth config had no reviewer JWT set. When we deleted that binding, a new store failed with unable to create client, and binding the role only to the ESO service account (eso-vault) did not fix it. So in this layout Vault’s account needs the binding, and ESO’s does not (capture below).

Set an audience on the Vault role: Vault warns when a role has none. The ESO docs page says Vault 1.21 and later fail without one [23], but a HashiCorp docs change merged on 30 October 2025 states that 1.21 did not make audience required [30], and on Vault 2.0.4 a role without an audience worked in our test (capture below).

You choose how ESO presents its identity. We ran three variants against the same Vault, and all three reached Valid on the store:

Variant How it works What to know
Dedicated service account serviceAccountRef plus audiences; ESO requests a token for that account. The one to start from. The chart’s ClusterRole lets the controller create tokens for service accounts.
Controller identity Omit serviceAccountRef; ESO logs in with its own pod token. Works, but the Vault role is tied to the controller’s own identity.
Stored token tokenSecretRef points at a Secret holding a Vault token. You own the token’s lifetime and rotation.

Demo

This script builds the cluster, ESO, a dev-mode Vault, Kubernetes auth, a read-only policy, a role bound to one service account and one audience, and one secret; the manifests below are applied afterwards. It needs Docker, kind, kubectl and Helm on the machine, and it is for a throwaway cluster only: dev mode keeps data in memory and uses the root token root, so never copy that to a real Vault.

Script: Vault, ESO and the Vault side, from zero (vault-from-zero.sh)
# 1. Cluster, ESO and a dev-mode Vault (throwaway; dev mode keeps data in memory)
kind create cluster --name eso-demo
helm repo add external-secrets https://charts.external-secrets.io
helm repo add hashicorp https://helm.releases.hashicorp.com
helm install external-secrets external-secrets/external-secrets \
  --version 2.14.0 -n external-secrets --create-namespace --wait
helm install vault hashicorp/vault --version 0.34.1 -n vault --create-namespace \
  --set injector.enabled=false --set server.dev.enabled=true
kubectl -n vault wait --for=condition=Ready pod/vault-0 --timeout=180s

# 2. Vault side: Kubernetes auth, a read-only policy, a role bound to one service account, one secret
vx() { kubectl -n vault exec -i vault-0 -- env VAULT_ADDR=http://127.0.0.1:8200 VAULT_TOKEN=root vault "$@"; }
vx auth enable kubernetes
vx write auth/kubernetes/config kubernetes_host=https://kubernetes.default.svc.cluster.local:443
vx policy write eso-read - <<'POLICY'
path "secret/data/apps/*"     { capabilities = ["read"] }
path "secret/metadata/apps/*" { capabilities = ["read", "list"] }
POLICY
vx write auth/kubernetes/role/eso bound_service_account_names=eso-vault \
  bound_service_account_namespaces=external-secrets audience=vault \
  token_policies=eso-read token_ttl=15m
vx kv put secret/apps/shop db_user=shop db_password=first-password

# 3. Cluster side: the service account ESO will use, the namespace, then the manifests below
kubectl -n external-secrets create serviceaccount eso-vault
kubectl create namespace shop

# 4. Cleanup when you are done (lab only)
kind delete cluster --name eso-demo

Then the store and the ExternalSecret:

apiVersion: external-secrets.io/v1
kind: ClusterSecretStore
metadata: {name: vault}
spec:
  provider:
    vault:
      server: http://vault.vault.svc.cluster.local:8200
      path: secret
      version: v2
      auth:
        kubernetes:
          mountPath: kubernetes
          role: eso
          serviceAccountRef:
            name: eso-vault
            namespace: external-secrets
            audiences: [vault]
  conditions:
    - namespaces: [shop]
---
apiVersion: external-secrets.io/v1
kind: ExternalSecret
metadata: {name: shop-db, namespace: shop}
spec:
  refreshInterval: 1m
  secretStoreRef: {name: vault, kind: ClusterSecretStore}
  target: {name: shop-db}
  data:
    - {secretKey: DB_USER, remoteRef: {key: apps/shop, property: db_user}}
    - {secretKey: DB_PASSWORD, remoteRef: {key: apps/shop, property: db_password}}

The conditions block limits the store to the shop namespace. We ran the script and these manifests on a fresh cluster and deleted that cluster by hand afterwards (kind delete cluster --name eso-demo); chart 0.34.1 deploys Vault 2.0.4, the version the extras capture below prints:

Captured output: the script above plus the manifests, on a fresh cluster (kind v0.33.0, 20:01 UTC, one run; helm notes trimmed)
# Vault from zero on a fresh cluster, 2026-11-08T19:59:42Z
$ bash vault-from-zero.sh
Creating cluster "eso-demo" ...
 • Ensuring node image (kindest/node:v1.37.0) 🖼️  ...
 ✓ Ensuring node image (kindest/node:v1.37.0) 🖼️
 • Preparing nodes 📦   ...
 ✓ Preparing nodes 📦 
 • Writing configuration 📜  ...
 ✓ Writing configuration 📜
 • Starting control-plane 🕹️  ...
 ✓ Starting control-plane 🕹️
 • Installing CNI 🔌  ...
 ✓ Installing CNI 🔌
 • Installing StorageClass 💾  ...
 ✓ Installing StorageClass 💾
Set kubectl context to "kind-eso-demo"
You can now use your cluster with:
kubectl cluster-info --context kind-eso-demo
Have a question, bug, or feature request? Let us know! https://kind.sigs.k8s.io/#community 🙂
"external-secrets" already exists with the same configuration, skipping
"hashicorp" already exists with the same configuration, skipping
NAME: external-secrets
TEST SUITE: None
external-secrets has been deployed successfully in namespace external-secrets!
NAME: vault
pod/vault-0 condition met
Success! Enabled kubernetes auth method at: kubernetes/
Success! Data written to: auth/kubernetes/config
Success! Uploaded policy: eso-read
Success! Data written to: auth/kubernetes/role/eso
==== Secret Path ====
secret/data/apps/shop
======= Metadata =======
Key                Value
---                -----
created_time       2026-11-08T20:01:24.01714771Z
custom_metadata    <nil>
deletion_time      n/a
destroyed          false
version            1
serviceaccount/eso-vault created
namespace/shop created

$ kubectl apply -f store-and-externalsecret.yaml   # the YAML shown in the post
clustersecretstore.external-secrets.io/vault created
externalsecret.external-secrets.io/shop-db created
$ kubectl get clustersecretstore vault; kubectl -n shop get externalsecret shop-db
NAME    AGE   STATUS   CAPABILITIES   READY
vault   25s   Valid    ReadWrite      True
NAME      STORETYPE            STORE   REFRESH INTERVAL   STATUS         READY   LAST SYNC
shop-db   ClusterSecretStore   vault   1m                 SecretSynced   True    25s
DB_USER=shop  DB_PASSWORD=first-password
$ kubectl get clusterrolebinding -o wide | grep auth-delegator
NAME                                                                    ROLE                                                                                AGE     USERS 
vault-server-binding                                                    ClusterRole/system:auth-delegator                                                   62s           

The lab cluster behind the rest of this guide, with a second namespace trying to use the same store:

Captured output: store, ExternalSecret and a second namespace (kind-esolab, 19:28 UTC, one run)
# Vault + ESO, captured 2026-11-08T19:28:43Z, cluster kind-esolab, ESO chart 2.14.0
$ helm list -n external-secrets
NAME            	NAMESPACE       	REVISION	UPDATED                             	STATUS  	CHART                  	APP VERSION
external-secrets	external-secrets	2       	2026-11-08 16:58:58.882435 +0000 UTC	deployed	external-secrets-2.14.0	v2.14.0    

$ kubectl get clustersecretstore
NAME          AGE     STATUS                  CAPABILITIES   READY
fakec         156m    Valid                   ReadWrite      True
vault         6h1m    Valid                   ReadWrite      True
vault-podid   5h56m   Valid                   ReadWrite      True
vault-token   5h56m   InvalidProviderConfig   ReadWrite      False

$ kubectl -n shop get externalsecret shop-db
NAME      STORETYPE            STORE   REFRESH INTERVAL   STATUS         READY   LAST SYNC
shop-db   ClusterSecretStore   vault   1m                 SecretSynced   True    39s

$ kubectl -n shop get secret shop-db -o jsonpath={.data} (decoded)
DB_USER=shop
DB_PASSWORD=second-password-v2

$ kubectl -n shop get secret shop-db -o jsonpath={.metadata.ownerReferences[0].kind}
ExternalSecret

$ intruder namespace test (store has conditions: namespaces [shop])
externalsecret.external-secrets.io/steal created
NAME    STORETYPE            STORE   REFRESH INTERVAL   STATUS              READY   LAST SYNC
steal   ClusterSecretStore   vault   1m                 SecretSyncedError   False   
could not get secret data from provider

The failure cases we hit while setting this up, then three follow-up experiments that test the claims above: which service account needs the review permission, what a role without an audience does, and what a revoked token looks like over three minutes:

Captured output: three failures (kind-esolab, 19:29 UTC, one run each)
# Vault auth failures, captured 2026-11-08T19:29:15Z
$ store without audiences
clustersecretstore.external-secrets.io/vault-noaud created
NAME          AGE   STATUS                  CAPABILITIES   READY
vault-noaud   10s   InvalidProviderConfig                  False
InvalidProviderConfig: unable to create client
clustersecretstore.external-secrets.io "vault-noaud" deleted

$ vault-token store (token revoked earlier in this lab)
InvalidProviderConfig: unable to validate store

$ read a path outside the Vault policy (secret/other/x)
externalsecret.external-secrets.io/outside created
NAME      STORETYPE            STORE   REFRESH INTERVAL   STATUS              READY   LAST SYNC
outside   ClusterSecretStore   vault   1m                 SecretSyncedError   False   
externalsecret.external-secrets.io "outside" deleted from shop namespace
Captured output: token revocation, auth-delegator and audience experiments (kind-esolab, Vault 2.0.4, 20:29 to 20:35 UTC, one run each)
# Vault extras, 2026-11-08T20:29:23Z, cluster kind-esolab, Vault v2.0.4 (c9e9d1d4ddd4b55aae79a8949adffa9e96338720), built 2026-08-03T16:14:36Z

## C. revoke a stored Vault token and watch the store and the ExternalSecret
secret/vault-token2 created
clustersecretstore.external-secrets.io/vault-token2 created
externalsecret.external-secrets.io/c1 created
before revoke   store=Valid True es=SecretSynced True
Success! Revoked token (if it existed)
# token revoked at 20:29:49
t+15s store=Valid True es=SecretSynced True
t+30s store=Valid True es=SecretSynced True
t+45s store=Valid True es=SecretSyncedError False
t+60s store=Valid True es=SecretSyncedError False
t+76s store=Valid True es=SecretSyncedError False
t+91s store=Valid True es=SecretSyncedError False
t+106s store=Valid True es=SecretSyncedError False
t+121s store=Valid True es=SecretSyncedError False
t+136s store=Valid True es=SecretSyncedError False
t+151s store=Valid True es=SecretSyncedError False
t+166s store=Valid True es=SecretSyncedError False
t+181s store=Valid True es=SecretSyncedError False

## D. which service account needs system:auth-delegator
$ vault read auth/kubernetes/config (selected)
{'kubernetes_host': 'https://kubernetes.default.svc.cluster.local:443', 'disable_local_ca_jwt': False, 'token_reviewer_jwt_set': False}
$ auth-delegator bindings now
vault-server-binding [('ServiceAccount', 'vault', 'vault')]
clustersecretstore.external-secrets.io/d1 created
externalsecret.external-secrets.io/d1 created
with Vault SA bound       store=Valid True es=SecretSynced True
clusterrolebinding.rbac.authorization.k8s.io "vault-server-binding" deleted
# vault-server-binding deleted at 20:33:16
clustersecretstore.external-secrets.io/d2 created
externalsecret.external-secrets.io/d2 created
Vault SA unbound           store=InvalidProviderConfig  es=SecretSyncedError False
InvalidProviderConfig: unable to create client
clusterrolebinding.rbac.authorization.k8s.io/eso-vault-delegator created
clustersecretstore.external-secrets.io/d3 created
externalsecret.external-secrets.io/d3 created
only eso-vault bound        store=InvalidProviderConfig  es=SecretSyncedError False
InvalidProviderConfig: unable to create client
clusterrolebinding.rbac.authorization.k8s.io "eso-vault-delegator" deleted
clusterrolebinding.rbac.authorization.k8s.io/vault-server-binding created
# vault-server-binding restored at 20:34:17

## E. a Vault role without an audience on Vault v2.0.4
WARNING! The following warnings were returned from Vault:

  * Role eso-noaud does not have an audience configured. While audiences are
  not required, consider specifying one if your use case would benefit from
  additional JWT claim verification.

clustersecretstore.external-secrets.io/e1 created
externalsecret.external-secrets.io/e1 created
role without audience, token with default audience store=Valid True es=SecretSynced True
Valid: store validated
clustersecretstore.external-secrets.io/e2 created
externalsecret.external-secrets.io/e2 created
role without audience, token with audience vault store=Valid True es=SecretSynced True
DONE

Pros and cons

Pros

  • Vault’s policy bounds what ESO can read. A path outside apps/ failed with SecretSyncedError, whatever the ExternalSecret asked for.
  • With the dedicated service account there is no static credential in the cluster to rotate.
  • conditions on a ClusterSecretStore stopped a second namespace from reading the first one’s path. The CRD also lists namespaceRegexes and a namespaceSelector; we ran only namespaces. The project’s multi-tenancy guide covers the wider layouts [24].

Cons

  • Three pieces must agree: the service-account name, the audience and the Vault role. Removing audiences from the store while the role demanded audience vault gave InvalidProviderConfig: unable to create client, which does not say what is missing.
  • Different failures read alike. A namespace blocked by conditions and a path denied by the Vault policy both ended in could not get secret data from provider.
  • A stored token that Vault revokes does not fail loudly. After we revoked one, the ExternalSecret errored between our 30 s and 45 s samples, while the store printed Valid at every sample for the next three minutes. The older lab store whose token was revoked hours earlier showed InvalidProviderConfig in the first failure capture.

ESO with Argo CD

What it is and how it works

Argo CD installs the ESO chart as an Application, and the same Argo CD can manage your stores and ExternalSecret objects from Git. Only the ExternalSecret lives in Git; the Secret it creates appears as a child in the resource tree but is not in the repository, so no secret value is committed. Argo CD 3.6.1 ships a health check for ExternalSecret that reports Degraded when the Ready condition is false [20], so a broken secret shows up in the application’s health.

The ESO CRDs have reached the 256 KB annotation limit, so client-side apply cannot install them; the getting-started page says to use server-side apply everywhere [15]. ESO’s own end-to-end Argo CD test sets ServerSideApply=true for that reason [19].

Demo

apiVersion: argoproj.io/v1alpha1
kind: Application
metadata: {name: eso, namespace: argocd}
spec:
  project: default
  source:
    repoURL: https://charts.external-secrets.io
    chart: external-secrets
    targetRevision: 2.14.0
  destination: {server: https://kubernetes.default.svc, namespace: external-secrets}
  syncPolicy:
    automated: {prune: true, selfHeal: true}
    syncOptions: [CreateNamespace=true, ServerSideApply=true]

A second Application synced a namespace, a store and an ExternalSecret from a Git repository (the lab served it from a git daemon inside the cluster). Both applications, then the second one after we committed a remoteRef that points at a missing key:

Argo CD 3.6.1 Applications page: demo is Degraded and Synced, eso is Healthy and Synced; eso deploys chart external-secrets 2.14.0 from charts.external-secrets.io.

Argo CD 3.6.1 in the lab, captured 8 November 2026, 20:03 UTC. The ESO application is healthy; the demo application is Degraded.

Argo CD resource tree for the demo application: app health Degraded, “Caused by external-secrets.io/ExternalSecret:demo/db”; sync status Synced to main; the tree shows namespace demo, externalsecret db with a Degraded heart, secretstore fake Healthy, and the secret db.

The same demo application in the tree view, captured 8 November 2026, 20:03 UTC. Git is in sync, and the health shows that the ExternalSecret cannot read its key. The existing Secret kept its last value.

Without ServerSideApply=true, the same chart fails. The capture below installs the chart a second time as another release, so the CRDs already exist, then adds the option to the failing Application, then deletes and recreates it with the option set:

Captured output: a failing Application, the option added live, then recreated (Argo CD 3.6.1, second release on a cluster whose CRDs already existed, 20:28 UTC, one run)
# Argo CD: add ServerSideApply to an Application that is already failing, 2026-11-08T20:28:18Z, Argo CD 3.6.1
$ kubectl apply -f eso-nosa.yaml   # no ServerSideApply, second release, CRDs already exist
application.argoproj.io/eso-nosa created
# +70s
NAME       SYNC STATUS   HEALTH STATUS
eso-nosa   OutOfSync     Healthy
eso        OutOfSync     Healthy
Running: one or more synchronization tasks completed unsuccessfully, reason: error when patching "/dev/shm/2662521763": CustomResourceDefinition.apiextensions.k8s.io "secretstores.external-secrets.io" is invalid: metadata.annotations: Too long: may not be more than 262144 bytes,error when patching "/dev/shm/2450622984": CustomResourceDefinition.apiextensions.k8s.io "clustersecretstores.external-secrets.io" is invalid

$ kubectl patch app eso-nosa: add ServerSideApply=true to syncOptions
application.argoproj.io/eso-nosa patched
syncOptions=["CreateNamespace=true","ServerSideApply=true"]
# +60s after the patch
NAME       SYNC STATUS   HEALTH STATUS
eso-nosa   Synced        Healthy
eso        OutOfSync     Healthy
Running: one or more synchronization tasks completed unsuccessfully, reason: error when patching "/dev/shm/1576417721": CustomResourceDefinition.apiextensions.k8s.io "secretstores.external-secrets.io" is invalid: metadata.annotations: Too long: may not be more than 262144 bytes,error when patching "
# +120s after the patch
NAME       SYNC STATUS   HEALTH STATUS
eso-nosa   OutOfSync     Healthy
eso        OutOfSync     Healthy
Running: one or more synchronization tasks completed unsuccessfully, reason: error when patching "/dev/shm/2036302684": CustomResourceDefinition.apiextensions.k8s.io "clustersecretstores.external-secrets.io" is invalid: metadata.annotations: Too long: may not be more than 262144 bytes,error when pat
# +180s after the patch
NAME       SYNC STATUS   HEALTH STATUS
eso-nosa   OutOfSync     Healthy
eso        OutOfSync     Healthy
Running: one or more synchronization tasks completed unsuccessfully, reason: error when patching "/dev/shm/2036302684": CustomResourceDefinition.apiextensions.k8s.io "clustersecretstores.external-secrets.io" is invalid: metadata.annotations: Too long: may not be more than 262144 bytes,error when pat

$ delete the Application and apply it again with the option set from the start
application.argoproj.io "eso-nosa" deleted from argocd namespace
application.argoproj.io/eso-nosa created
NAME       SYNC STATUS   HEALTH STATUS
eso-nosa   OutOfSync     Healthy
eso        OutOfSync     Healthy
Succeeded: successfully synced (all tasks run)
DONE
Captured output: Argo CD applications after the cleanup (kind-esoargo, one run)
# Argo CD applications after the second release was deleted and eso was re-synced, captured 2026-11-08T20:48:52Z, cluster kind-esoargo
NAME   SYNC STATUS   HEALTH STATUS
demo   Synced        Degraded
eso    Synced        Healthy

Pros and cons

Pros

  • One tool installs ESO and the stores, and the application’s health carries a broken ExternalSecret (the second screenshot).
  • Nothing secret is in Git: only the ExternalSecret that says where to fetch it.

Cons

  • You must set ServerSideApply=true before the first sync. When we added it to a live Application that was already failing, the operation message stayed the same in every sample over the next three minutes, although the sync status showed Synced in one sample at 60 seconds and then went back to OutOfSync. Deleting the Application and creating it again with the option made the operation succeed; on the clean cluster behind the screenshots, the application created that way shows Healthy and Synced (first screenshot). We did not find out why the running operation kept its old options.
  • ESO’s CRDs must exist before any ExternalSecret that Argo CD applies. We did not test ordering, so check Argo CD’s sync-waves documentation for your layout [29].
  • Two Applications that share the chart’s cluster-scoped objects can fight. The throwaway second release in the capture above made the healthy eso application OutOfSync in every sample, and the recreated one stayed OutOfSync too. After we deleted the second release and re-synced, eso went back to Synced and Healthy (last block below).

ESO with Ansible

What it is and how it works

The kubernetes.core.helm module installs the chart from a playbook, and kubernetes.core.k8s applies the store and ExternalSecret objects. This suits teams that already bootstrap clusters with playbooks. The host running Ansible needs the Helm binary and the kubernetes Python package.

Demo

- name: Install the chart (CRDs included)
  kubernetes.core.helm:
    kube_context: kind-esoansible
    name: external-secrets
    chart_ref: external-secrets/external-secrets
    chart_version: "2.14.0"
    release_namespace: external-secrets
    create_namespace: true
    wait: true
    timeout: 5m

Before it, a helm_repository task adds the chart repository; after it, three kubernetes.core.k8s tasks apply a fake-provider ClusterSecretStore, a namespace and an ExternalSecret, and a final task waits for Ready. The whole playbook:

Playbook: eso.yml
- name: Install External Secrets Operator and one fake-provider secret
  hosts: localhost
  connection: local
  gather_facts: false
  vars:
    kube_context: kind-esoansible
    eso_chart_version: "2.14.0"
  tasks:
    - name: Add the ESO chart repository
      kubernetes.core.helm_repository:
        name: external-secrets
        repo_url: https://charts.external-secrets.io

    - name: Install the chart (CRDs included)
      kubernetes.core.helm:
        kube_context: "{{ kube_context }}"
        name: external-secrets
        chart_ref: external-secrets/external-secrets
        chart_version: "{{ eso_chart_version }}"
        release_namespace: external-secrets
        create_namespace: true
        wait: true
        timeout: 5m

    - name: Store backed by the fake provider
      kubernetes.core.k8s:
        context: "{{ kube_context }}"
        state: present
        definition:
          apiVersion: external-secrets.io/v1
          kind: ClusterSecretStore
          metadata: {name: fake}
          spec:
            provider:
              fake:
                data:
                  - {key: /demo/db, value: s3cr3t-from-ansible}

    - name: Namespace for the app
      kubernetes.core.k8s:
        context: "{{ kube_context }}"
        state: present
        definition: {apiVersion: v1, kind: Namespace, metadata: {name: demo}}

    - name: ExternalSecret
      kubernetes.core.k8s:
        context: "{{ kube_context }}"
        state: present
        definition:
          apiVersion: external-secrets.io/v1
          kind: ExternalSecret
          metadata: {name: db, namespace: demo}
          spec:
            refreshInterval: 1m
            secretStoreRef: {kind: ClusterSecretStore, name: fake}
            target: {name: db}
            data:
              - secretKey: password
                remoteRef: {key: /demo/db}

    - name: Wait until the ExternalSecret is Ready
      kubernetes.core.k8s_info:
        context: "{{ kube_context }}"
        api_version: external-secrets.io/v1
        kind: ExternalSecret
        namespace: demo
        name: db
      register: es
      until: es.resources | length > 0 and (es.resources[0].status.conditions | default([]) | selectattr('type','equalto','Ready') | selectattr('status','equalto','True') | list | length) > 0
      retries: 12
      delay: 5

Run it with ansible-playbook eso.yml after kind create cluster --name esoansible, ansible-galaxy collection install kubernetes.core and pip install kubernetes. We ran it on a cluster with ESO uninstalled, then a second time:

Captured output: two playbook runs (kind-esoansible, ansible-core 2.22.0, kubernetes.core 6.6.0, 19:38 UTC)
# Ansible + ESO, captured 2026-11-08T19:38:06Z, cluster kind-esoansible (ESO uninstalled beforehand)
$ ansible --version | head -1
ansible [core 2.22.0]
$ ansible-galaxy collection list kubernetes.core
--------------- -------
kubernetes.core 6.6.0  

$ ansible-playbook eso.yml   # run 1
PLAY [Install External Secrets Operator and one fake-provider secret] **********
TASK [Add the ESO chart repository] ********************************************
ok: [localhost]
TASK [Install the chart (CRDs included)] ***************************************
[DEPRECATION WARNING]: The 'status.values' return value is deprecated and will be removed in version 8.0.0. Use 'status.release_values' instead. This feature will be removed from collection 'kubernetes.core' version 8.0.0.
changed: [localhost]
TASK [Store backed by the fake provider] ***************************************
[WARNING]: store fake isn't currently maintained. Please plan and prepare accordingly.
[DEPRECATION WARNING]: Passing `warnings` to `exit_json` or `fail_json` is deprecated. This feature will be removed from ansible-core version 2.23. Use `AnsibleModule.warn` instead.
changed: [localhost]
TASK [Namespace for the app] ***************************************************
changed: [localhost]
TASK [ExternalSecret] **********************************************************
changed: [localhost]
TASK [Wait until the ExternalSecret is Ready] **********************************
ok: [localhost]
PLAY RECAP *********************************************************************
localhost                  : ok=6    changed=4    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0   

$ ansible-playbook eso.yml   # run 2
TASK [Wait until the ExternalSecret is Ready] **********************************
ok: [localhost]
PLAY RECAP *********************************************************************
localhost                  : ok=6    changed=0    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0   

$ kubectl -n demo get externalsecret db
NAME   STORETYPE            STORE   REFRESH INTERVAL   STATUS         READY   LAST SYNC
db     ClusterSecretStore   fake    1m                 SecretSynced   True    4s
password=s3cr3t-from-ansible

Pros and cons

Pros

  • Idempotent: the second run reported changed=0.
  • Changing chart_version was detected as changed=1 in an earlier run, even though the module warns that idempotency needs the helm diff plugin [21]. That test moved the chart from 2.14.0 down to 2.13.0 and back, only to see detection; real upgrades go one minor at a time.
  • One playbook covers the operator and its first secret, and it runs without Argo CD or Flux.

Cons

  • The control host needs Helm and the Python client, and the module prints deprecation notices in collection 6.6.0: wait_timeout is deprecated in favour of timeout, and the run above printed a notice about the status.values return value [21].
  • Playbooks apply once; nothing watches for drift between runs. Argo CD does that.
  • Ansible has no view of the ExternalSecret health after the wait task, so you need another check for later breakage.
  • One run of a back-to-back chart version change ended with failed=1. Two reruns passed, and we did not capture the error text, so we cannot say why it failed.

ESO with GitHub Actions

What it is and how it works

Here ESO is not the thing you install in production. A workflow_dispatch or pull-request job starts a kind cluster, installs the chart at a pinned version, applies your store and ExternalSecret manifests and waits for Ready. If it fails, your manifests would not reconcile.

Demo

The workflow file, .github/workflows/eso.yml:

Workflow: .github/workflows/eso.yml
name: install-eso
on: workflow_dispatch
jobs:
  eso:
    runs-on: ubuntu-latest
    env:
      ESO_CHART_VERSION: "2.14.0"
    steps:
      - uses: actions/[email protected]
      - uses: helm/[email protected]
        with:
          cluster_name: ci
      - name: Install External Secrets Operator
        run: |
          helm repo add external-secrets https://charts.external-secrets.io
          helm install external-secrets external-secrets/external-secrets \
            --version "$ESO_CHART_VERSION" -n external-secrets --create-namespace --wait --timeout 5m
      - name: Apply a fake-provider store and an ExternalSecret
        run: |
          kubectl apply -f manifests/
          kubectl -n demo wait --for=condition=Ready externalsecret/db --timeout=120s
      - name: Check the generated Secret
        run: |
          test "$(kubectl -n demo get secret db -o jsonpath='{.data.password}' | base64 -d)" = "s3cr3t-from-actions"
          kubectl get crd externalsecrets.external-secrets.io -o jsonpath='{range .spec.versions[*]}{.name} served={.served}{"\n"}{end}'

The manifests/ folder it applies holds two files:

manifests/10-store.yaml
apiVersion: external-secrets.io/v1
kind: ClusterSecretStore
metadata: {name: fake}
spec:
  provider:
    fake:
      data:
        - {key: /demo/db, value: s3cr3t-from-actions}
manifests/20-app.yaml
apiVersion: v1
kind: Namespace
metadata: {name: demo}
---
apiVersion: external-secrets.io/v1
kind: ExternalSecret
metadata: {name: db, namespace: demo}
spec:
  refreshInterval: 1m
  secretStoreRef: {kind: ClusterSecretStore, name: fake}
  target: {name: db}
  data:
    - secretKey: password
      remoteRef: {key: /demo/db}

The run that executed this job, as GitHub reports it (the repository is private, so the page itself is not linked):

Captured output: the GitHub Actions run, assembled from three captures of the same run: the `gh run view` summary, the step results and the log lines of its last steps
$ GitHub Actions run (ESO install in kind)
✓ main install-eso · 42491820574
Triggered via workflow_dispatch about 4 hours ago
JOBS
✓ eso in 2m26s (ID 128765654030)

$ gh run view 42491820574 --json jobs   (steps)
2 Run actions/[email protected]: success
3 Run helm/[email protected]: success
4 Install External Secrets Operator: success
5 Apply a fake-provider store and an ExternalSecret: success
6 Check the generated Secret: success

$ gh run view 42491820574 --log   (output lines of the last two steps)
clustersecretstore.external-secrets.io/fake created
namespace/demo created
externalsecret.external-secrets.io/db created
externalsecret.external-secrets.io/db condition met
v1 served=true
v1beta1 served=false

Pros and cons

Pros

  • Every change to your manifests is tested against the same pinned chart version in about 2.5 minutes.
  • No cloud account or Vault is needed in the job.

Cons

  • It proves your YAML and the CRD schema, not your provider. The job used a fake store, so a wrong Vault role or audience would pass here and fail in the cluster.
  • A fake store prints the maintenance warning described earlier.

ESO with GitHub secrets

What it is and how it works

This is the opposite direction. The GitHub provider is write-only: a PushSecret writes a Kubernetes Secret out to GitHub Actions or Dependabot secrets, and ESO cannot read from GitHub [27]. It authenticates as a GitHub App with an App ID, an installation ID and a private key, and the App needs the Secrets read and write permission. Use it when a value is created or rotated in the cluster and a pipeline needs the same value.

Creating the App is a browser task in GitHub’s settings:

  1. Open Settings, Developer settings, GitHub Apps, New GitHub App. Give it a name and any homepage URL, and untick Webhook, Active.
  2. Under repository permissions set Secrets to Read and write. Leave everything else at no access, and choose to allow installation only on your own account.
  3. After creating it, copy the App ID and use Generate a private key to download the .pem file.
  4. Choose Install App, pick Only select repositories and the target repository. The installation ID is the number at the end of the installation page’s URL (.../installations/<id>).

Then create the namespace, the key and the source Secret in the cluster:

kubectl create namespace ghpush
kubectl -n ghpush create secret generic github-app-private-key --from-file=privateKey.pem=./app.pem
kubectl -n ghpush create secret generic app-config --from-literal=value=pushed-v3

Demo

apiVersion: external-secrets.io/v1
kind: SecretStore
metadata: {name: github, namespace: ghpush}
spec:
  provider:
    github:
      appID: 123456            # placeholder
      installationID: 7654321  # placeholder
      organization: your-account
      repository: your-repo
      auth:
        privateKey: {name: github-app-private-key, key: privateKey.pem}
---
apiVersion: external-secrets.io/v1alpha1
kind: PushSecret
metadata: {name: to-github, namespace: ghpush}
spec:
  deletionPolicy: Delete
  refreshInterval: 1m
  secretStoreRefs: [{name: github, kind: SecretStore}]
  selector: {secret: {name: app-config}}
  data:
    - match: {secretKey: value, remoteRef: {remoteKey: ESO_LAB_PUSHED}}

We created the PushSecret, changed the source value, then deleted the PushSecret, listing the repository’s Actions secrets through GitHub’s API each time. GitHub never returns secret values, so the listing shows the name and updated_at. To prove the value itself arrived, a workflow step compared the pushed secret with the expected value and printed only whether they matched:

- env:
    PUSHED: ${{ secrets.ESO_LAB_PUSHED }}
  run: |
    if [ "$PUSHED" = "pushed-v3" ]; then echo "match=true length=${#PUSHED}"; else echo "match=false length=${#PUSHED}"; exit 1; fi
Captured output: PushSecret create, change and delete, then the workflow check (19:38 to 19:41 UTC, one run)
# GitHub provider (PushSecret to Actions secrets), captured 2026-11-08T19:38:44Z, cluster kind-esolab
$ kubectl -n ghpush get secretstore github
NAME     AGE     STATUS   CAPABILITIES   READY
github   3h44m   Valid    WriteOnly      True

$ GET /repos/<owner>/<repo>/actions/secrets   # before
[]

$ kubectl apply -f pushsecret.yaml
pushsecret.external-secrets.io/to-github created
NAME        AGE   STATUS   LAST SYNC
to-github   25s   Synced   23s

$ GET actions/secrets   # after PushSecret
[{"name":"ESO_LAB_PUSHED","updated_at":"2026-11-08T19:38:49Z"}]

$ change source Secret value, wait one refresh
secret/app-config configured
$ GET actions/secrets   # after source change
[{"name":"ESO_LAB_PUSHED","updated_at":"2026-11-08T19:39:49Z"}]

$ kubectl delete pushsecret to-github  (deletionPolicy: Delete)
pushsecret.external-secrets.io "to-github" deleted from ghpush namespace
$ GET actions/secrets   # after delete
[]


$ kubectl apply -f pushsecret.yaml   # recreated before the workflow run (terminal output copied at 19:41 UTC)
pushsecret.external-secrets.io/to-github created
NAME        AGE   STATUS   LAST SYNC
to-github   25s   Synced   23s

# End-to-end: workflow reads the secret ESO pushed, captured 2026-11-08T19:42:18Z
$ gh run view --log   (the step's output line, and the run result)
match=true length=9
{"conclusion":"success","createdAt":"2026-11-08T19:41:27Z","jobs":[{"conclusion":"success","name":"check"}]}

Pros and cons

Pros

  • A change in the cluster reaches the Actions secret on the next refresh, and deleting the PushSecret removes the remote secret when deletionPolicy is Delete.
  • The end-to-end check passed: the workflow read the pushed value and match=true.

Cons

  • You must create and install a GitHub App first, through the browser steps above.
  • The docs snippet quotes appID and installationID as strings. The 2.14.0 CRD rejects that with “must be of type integer”, so use plain numbers.
  • The docs list organization as required. We set it to a personal account name and set repository, and that was accepted for a repository secret; we did not test organization-level secrets.
  • Apply the SecretStore before the PushSecret. Ours was applied first and logged could not get SecretStore "github" until the store existed.
  • The provider is listed as alpha [17].

ESO with Jenkins

What it is and how it works

The Kubernetes Credentials Provider plugin turns Kubernetes Secret objects that carry a jenkins.io/credentials-type label into Jenkins credentials [26]. ESO creates those Secret objects, so Jenkins gets credentials from your secret store without anyone typing them into Jenkins. The plugin needs get, list and watch on secrets for the Jenkins service account, and a namespaced Role was enough.

Demo

The ExternalSecret carries the label and the description in its template. This lab used a fake store to keep the focus on Jenkins:

SecretStore and ExternalSecret (es.yaml)
apiVersion: external-secrets.io/v1
kind: SecretStore
metadata: {name: fake, namespace: ci}
spec:
  provider:
    fake:
      data:
        - {key: /ci/sonar-token, value: tok-v3}
---
apiVersion: external-secrets.io/v1
kind: ExternalSecret
metadata: {name: sonar-token, namespace: ci}
spec:
  refreshInterval: 1m
  secretStoreRef: {name: fake, kind: SecretStore}
  target:
    name: sonar-token
    template:
      metadata:
        labels:
          jenkins.io/credentials-type: secretText
        annotations:
          jenkins.io/credentials-description: "sonar token synced by ESO"
  data:
    - secretKey: text
      remoteRef: {key: /ci/sonar-token}

Jenkins itself runs as a Deployment, with the image and both plugins pinned to the versions we ran, whose service account may read secrets in its own namespace:

Namespace, ServiceAccount, Role, RoleBinding and Deployment (jenkins.yaml)
apiVersion: v1
kind: Namespace
metadata: {name: ci}
---
apiVersion: v1
kind: ServiceAccount
metadata: {name: jenkins, namespace: ci}
---
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata: {name: jenkins-secrets-reader, namespace: ci}
rules:
  - apiGroups: [""]
    resources: [secrets]
    verbs: [get, list, watch]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata: {name: jenkins-secrets-reader, namespace: ci}
roleRef: {apiGroup: rbac.authorization.k8s.io, kind: Role, name: jenkins-secrets-reader}
subjects: [{kind: ServiceAccount, name: jenkins, namespace: ci}]
---
apiVersion: apps/v1
kind: Deployment
metadata: {name: jenkins, namespace: ci}
spec:
  replicas: 1
  selector: {matchLabels: {app: jenkins}}
  template:
    metadata: {labels: {app: jenkins}}
    spec:
      serviceAccountName: jenkins
      containers:
        - name: jenkins
          image: jenkins/jenkins:2.580.2-lts-jdk21
          command: ["sh","-c","jenkins-plugin-cli --plugins kubernetes-credentials-provider:1.324.v5271cd145cb_1 plain-credentials:199.v9f8e1f741799 && exec /usr/bin/tini -- /usr/local/bin/jenkins.sh"]
          env:
            - {name: JAVA_OPTS, value: "-Djenkins.install.runSetupWizard=false"}
          ports: [{containerPort: 8080}]

The credential as Jenkins lists it, and the capture behind it:

Jenkins 2.580.2, Manage Jenkins, Credentials, Kubernetes, Global: one credential named sonar-token with the description “sonar token synced by ESO”.

Jenkins credentials store provided by the Kubernetes plugin, captured 8 November 2026, 19:43 UTC. Nobody created this credential in Jenkins. The screenshot predates the version pin; the Jenkins version is the same, 2.580.2.
Captured output: pinned image and plugins, credential, deletion and recreation (kind-esolab, 20:37 UTC, one run)
# Jenkins + ESO with pinned image and plugins, captured 2026-11-08T20:37:22Z, cluster kind-esolab
$ image and Jenkins version
jenkins/jenkins:2.580.2-lts-jdk21
X-Jenkins: 2.580.2
$ plugins
[('credentials', '1514.vb_01eb_014e7d4'), ('kubernetes-credentials-provider', '1.324.v5271cd145cb_1'), ('plain-credentials', '199.v9f8e1f741799')]
$ ExternalSecret and Secret
NAME          STORETYPE     STORE   REFRESH INTERVAL   STATUS         READY   LAST SYNC
sonar-token   SecretStore   fake    1m                 SecretSynced   True    17s
NAME          TYPE     DATA   AGE   LABELS
sonar-token   Opaque   1      54m   jenkins.io/credentials-type=secretText,reconcile.external-secrets.io/creat
$ credential in Jenkins
[{'id': 'sonar-token', 'description': 'sonar token synced by ESO', 'typeName': 'Secret text'}]
value Jenkins resolves: tok-v3

$ kubectl -n ci delete externalsecret sonar-token
externalsecret.external-secrets.io "sonar-token" deleted from ci namespace
after 5 s: secret=Error from server (NotFound): secrets "sonar-token" not foun | jenkins=[]
after 15 s (5 + 10): secret=Error from server (NotFound): secrets "sonar-token" not foun | jenkins=[]
after 35 s (5 + 10 + 20): secret=Error from server (NotFound): secrets "sonar-token" not foun | jenkins=[]

$ recreate it
externalsecret.external-secrets.io/sonar-token created
jenkins=[{'id': 'sonar-token', 'description': 'sonar token synced by ESO', 'typeName': 'Secret text'}]

Pros and cons

Pros

  • No secret is typed into Jenkins or stored in its configuration. Rotating the source changes the credential: in an earlier run both the Secret and the value Jenkins resolved moved to the new value, and deleting the ExternalSecret removed the credential by the first check, five seconds later, in the pinned run captured below.
  • The ExternalSecret template carries the label and description, so one manifest defines the whole credential.

Cons

  • With only the Kubernetes Credentials Provider installed, Jenkins logged No SecretToCredentialConverter found to convert secrets of type secretText and listed nothing. The plain-credentials plugin supplies the converter; the capture above shows it installed.
  • Jenkins decides who can read a credential’s value. Our lab Jenkins ran with security off, which is why the script console in the capture returned the value; do not copy that part.

ESO with Flux and with AWS (docs only)

We did not run these two. The project documents Flux with an example that installs ESO and reconciles ExternalSecret objects from a Git repository [16], and documents AWS Secrets Manager with several ways to authenticate: the controller’s pod identity, access keys, an EKS service account (IRSA), an assumed role and EKS Pod Identity [22]. If you pick either, treat the Vault, Argo CD and delivery sections here as the pattern, and check the linked pages for your version.

What every setup shares

Delivery options

Three ExternalSecret features change what lands in the cluster, independent of the setups above:

Captured output: template, ClusterExternalSecret and ConfigMap target (kind-esolab, 19:43 UTC, one run)
# Delivery features, captured 2026-11-08T19:43:29Z, cluster kind-esolab
$ template: kubectl -n tpl get secret dsn -o jsonpath={.data.DATABASE_URL}
postgres://appuser:[email protected]:5432/app

$ ClusterExternalSecret fanout: provisioned namespaces
["teamb","tpl"]
$ namespace teamc labelled team=b:
Error from server (NotFound): secrets "team-pass" not found
$ relabel teamc team=a at 19:43:49
t+15: team-pass   Opaque   1      15s
t+30: team-pass   Opaque   1      30s
t+45: team-pass   Opaque   1      45s

$ target.manifest ConfigMap
NAME         STORETYPE            STORE   REFRESH INTERVAL   STATUS           READY   LAST SYNC
app-config   ClusterSecretStore   fakec   1m                 ResourceSynced   True    42s
{"LOG_USER":"appuser"}
configmap/app-config patched
after 4s: appuser
  • Templates. target.template built a connection string from two fetched values [25].
  • ClusterExternalSecret. One object created an ExternalSecret in every namespace whose labels match a selector. A namespace labelled team=b got nothing; after relabelling to team=a, the Secret existed at our first check, 15 seconds later.
  • target.manifest. An ExternalSecret can write a ConfigMap or a custom resource instead of a Secret. The project calls this “heavy alpha” and warns that custom resources are not encrypted at rest [28]. Without the controller flag, the ExternalSecret errored with generic targets are disabled. Enable with --unsafe-allow-generic-targets flag; the chart value genericTargets.enabled=true adds the flag and the ConfigMap permissions, and the ConfigMap then synced. A manual edit was reverted by our check four seconds later. For other kinds, the chart’s values comment and template read genericTargets.resources[].apiGroup (singular), while the docs page writes apiGroups; with the plural key, helm template rendered no rule for argoproj.io. We did not test a live custom resource.

What a pod sees after a change

The Secret updates when the controller reconciles. What a pod sees depends on how it consumes the Secret. One pod read the same value as an environment variable and as a mounted file while we changed the source:

Captured output: env var and mounted file after the source changed (kind-esolab, 19:44 UTC, one run)
# Reload behaviour, captured 2026-11-08T19:44:57Z, cluster kind-esolab, ESO refreshInterval 1m
$ pod reads the same Secret as env var PASS_ENV and as file /etc/s/pass
start       secret=pw-v2 env=pw-v2 file=pw-v2
# source changed pw-v2 -> pw-v3 at 19:44:57
t+20s secret=pw-v3 env=pw-v2 file=pw-v3
t+40s secret=pw-v3 env=pw-v2 file=pw-v3
t+61s secret=pw-v3 env=pw-v2 file=pw-v3
t+81s secret=pw-v3 env=pw-v2 file=pw-v3
t+101s secret=pw-v3 env=pw-v2 file=pw-v3
t+122s secret=pw-v3 env=pw-v2 file=pw-v3
t+142s secret=pw-v3 env=pw-v2 file=pw-v3
t+162s secret=pw-v3 env=pw-v2 file=pw-v3
$ kubectl -n tpl rollout restart deploy/app
deployment.apps/app restarted
deployment "app" successfully rolled out
NAME                   READY   STATUS        RESTARTS      AGE
app-65698cf79f-ltvwd   1/1     Running       0             16s
app-849847f7d9-4wl4x   1/1     Terminating   2 (22m ago)   170m
new pod     secret=pw-v3 env=pw-v3 file=pw-v3
Captured output: the earlier reload run (same day, terminal output copied)
# Reload behaviour, earlier run the same day (same pod spec, same 1m refreshInterval); terminal output copied from the session
$ pod reads the same Secret as env var PASS_ENV and as file /etc/s/pass
start secret=pw-v1 env=pw-v1 file=pw-v1
# source changed pw-v1 -> pw-v2
t+20s secret=pw-v2 env=pw-v1 file=pw-v1
t+40s secret=pw-v2 env=pw-v1 file=pw-v1
t+61s secret=pw-v2 env=pw-v1 file=pw-v1
t+81s secret=pw-v2 env=pw-v1 file=pw-v2
t+101s secret=pw-v2 env=pw-v1 file=pw-v2
t+121s secret=pw-v2 env=pw-v1 file=pw-v2
t+142s secret=pw-v2 env=pw-v1 file=pw-v2
t+162s secret=pw-v2 env=pw-v1 file=pw-v2

An environment variable stays at its old value until the pod restarts, so use a rollout or a controller such as Reloader. A mounted file updates in place, and the application must re-read it. In this run the file matched the Secret at the first check; in an earlier run it lagged for between 61 and 81 seconds, so do not plan around a figure. The earlier run’s raw lines are in the second block above. One trap in our own check: right after rollout restart, the old pod was still Terminating, and reading the first pod in the list showed the old value. Select the newest pod.

When the store is unreachable

We pointed the Vault store at a closed port, then restored it:

Captured output: store pointed at a closed port, then restored (kind-esolab, 19:48 UTC, one run)
# Store outage, captured 2026-11-08T19:48:28Z, cluster kind-esolab, ExternalSecret refreshInterval 1m
# outage simulated by pointing the ClusterSecretStore at a closed port (:9999)
before     ES: shop-db   ClusterSecretStore   vault   1m    SecretSynced   True   23s | store: vault   6h20m   Valid   ReadWrite   True | secret DB_USER=shop
clustersecretstore.external-secrets.io/vault patched
# bad address applied at 19:48:28
t+20s ES: shop-db   ClusterSecretStore   vault   1m    SecretSynced   True   43s | store: vault   6h21m   Valid   ReadWrite   True | secret DB_USER=shop
t+41s ES: shop-db   ClusterSecretStore   vault   1m    SecretSynced   True   64s | store: vault   6h21m   Valid   ReadWrite   True | secret DB_USER=shop
t+61s ES: shop-db   ClusterSecretStore   vault   1m    SecretSynced   True   84s | store: vault   6h21m   InvalidProviderConfig   ReadWrite   False | secret DB_USER=shop
t+81s ES: shop-db   ClusterSecretStore   vault   1m    SecretSynced   True   104s | store: vault   6h22m   InvalidProviderConfig   ReadWrite   False | secret DB_USER=shop
t+101s ES: shop-db   ClusterSecretStore   vault   1m    SecretSyncedError   False   2m4s | store: vault   6h22m   InvalidProviderConfig   ReadWrite   False | secret DB_USER=shop
clustersecretstore.external-secrets.io/vault patched
# address restored at 19:50:09
t+20s ES: shop-db   ClusterSecretStore   vault   1m    SecretSyncedError   False   2m24s | store: vault   6h22m   Valid   ReadWrite   True | secret DB_USER=shop
t+40s ES: shop-db   ClusterSecretStore   vault   1m    SecretSynced   True   13s | store: vault   6h23m   Valid   ReadWrite   True | secret DB_USER=shop
t+60s ES: shop-db   ClusterSecretStore   vault   1m    SecretSynced   True   33s | store: vault   6h23m   Valid   ReadWrite   True | secret DB_USER=shop
t+80s ES: shop-db   ClusterSecretStore   vault   1m    SecretSynced   True   54s | store: vault   6h23m   Valid   ReadWrite   True | secret DB_USER=shop
t+101s ES: shop-db   ClusterSecretStore   vault   1m    SecretSynced   True   14s | store: vault   6h24m   Valid   ReadWrite   True | secret DB_USER=shop
t+121s ES: shop-db   ClusterSecretStore   vault   1m    SecretSynced   True   34s | store: vault   6h24m   Valid   ReadWrite   True | secret DB_USER=shop
t+141s ES: shop-db   ClusterSecretStore   vault   1m    SecretSynced   True   54s | store: vault   6h24m   Valid   ReadWrite   True | secret DB_USER=shop
t+161s ES: shop-db   ClusterSecretStore   vault   1m    SecretSynced   True   14s | store: vault   6h25m   Valid   ReadWrite   True | secret DB_USER=shop
Captured output: the earlier outage run (same day, terminal output copied)
# Store outage, earlier run the same day (same method: store pointed at :9999, then restored); terminal output copied from the session
before: ES shop-db SecretSynced True 40s | store vault Valid
# bad address applied
t+20  ES SecretSynced True 61s  | store Valid
t+40  ES SecretSynced True 81s  | store Valid
t+70  ES SecretSynced True 101s | store InvalidProviderConfig False
# address restored
t+15  ES SecretSynced True 116s | store InvalidProviderConfig
t+40  ES SecretSyncedError False | store InvalidProviderConfig
t+70  ES SecretSyncedError False | store Valid
(next check) ES SecretSyncedError False 2m53s | store Valid
(30 s later)  ES SecretSynced True 21s | store Valid
(30 s later)  ES SecretSynced True 51s | store Valid

In this run the store turned InvalidProviderConfig about a minute in, the ExternalSecret kept reporting SecretSynced through the 81-second sample and showed the error at 101 seconds, and the Secret kept its last value throughout. An earlier run differed: the ExternalSecret reported an error only after we restored the address, then recovered about two minutes later. The timing differed; what held both times is that an old SecretSynced is not proof the value is fresh. Alert on the store condition and on the ExternalSecret Ready condition together. We did not stop Vault itself, because dev mode loses its data on restart.

Vault Secrets Operator

What it is and how it works

The Vault Secrets Operator (VSO) is HashiCorp’s operator for the same job as ESO, limited to Vault. Its documentation says it “allows Pods to consume Vault secrets natively from Kubernetes Secrets” by watching its custom resources and writing the source data to a destination Secret [11]. It can also restart workloads when a secret changes: the VaultStaticSecret below names a Deployment in rolloutRestartTargets.

Demo

Install the chart, then declare a connection, an authentication method bound to the service account app (same Vault role as the CSI demo), and the secret to sync:

helm install vault-secrets-operator hashicorp/vault-secrets-operator --version 1.6.2 \
  -n vault-secrets-operator-system --create-namespace --wait
kubectl apply -f vso.yaml
VaultConnection, VaultAuth, VaultStaticSecret and a Deployment (vso.yaml)
apiVersion: secrets.hashicorp.com/v1beta1
kind: VaultConnection
metadata: {name: vault, namespace: apps}
spec:
  address: http://vault.vault.svc.cluster.local:8200
---
apiVersion: secrets.hashicorp.com/v1beta1
kind: VaultAuth
metadata: {name: app, namespace: apps}
spec:
  vaultConnectionRef: vault
  method: kubernetes
  mount: kubernetes
  kubernetes:
    role: app
    serviceAccount: app
---
apiVersion: secrets.hashicorp.com/v1beta1
kind: VaultStaticSecret
metadata: {name: shop-db, namespace: apps}
spec:
  vaultAuthRef: app
  mount: secret
  type: kv-v2
  path: apps/shop
  refreshAfter: 30s
  destination:
    create: true
    name: vso-shop-db
  rolloutRestartTargets:
    - kind: Deployment
      name: vso-app
---
apiVersion: apps/v1
kind: Deployment
metadata: {name: vso-app, namespace: apps}
spec:
  replicas: 1
  selector: {matchLabels: {app: vso-app}}
  template:
    metadata: {labels: {app: vso-app}}
    spec:
      containers:
        - name: app
          image: busybox:1.37
          command: ["sleep","3600"]
          env:
            - name: DB_PASSWORD
              valueFrom: {secretKeyRef: {name: vso-shop-db, key: db_password}}

We changed the value in Vault and sampled the Secret and the pod’s environment, then deleted the VaultStaticSecret:

Captured output: Vault Secrets Operator 1.6.2: sync, rotation with workload restart, deletion (kind-ksec, 22:38 UTC, one run)
# Vault Secrets Operator, 2026-11-08T22:38:12Z, cluster kind-ksec, Vault 2.0.4
$ helm install vault-secrets-operator hashicorp/vault-secrets-operator --version 1.6.2 -n vault-secrets-operator-system --create-namespace --wait
NAME: vault-secrets-operator
NAMESPACE: vault-secrets-operator-system
STATUS: deployed
version            7
vaultconnection.secrets.hashicorp.com/vault created
vaultauth.secrets.hashicorp.com/app created
vaultstaticsecret.secrets.hashicorp.com/shop-db created
deployment.apps/vso-app created
$ the synced Secret and the objects
NAME                                              SYNCED   HEALTHY   READY   AGE
vaultstaticsecret.secrets.hashicorp.com/shop-db   True     True      True    25s

NAME                 TYPE     DATA   AGE
secret/vso-shop-db   Opaque   2      25s
Secret value: vso-password-1
Secret owner: VaultStaticSecret
deployment "vso-app" successfully rolled out
pod vso-app-868456cbd7-2z65x env: vso-password-1

$ change the value in Vault; refreshAfter is 30s, rolloutRestartTargets names the Deployment
version            8
# changed at 22:39:05
t+16s secret=vso-password-2 pod=vso-app-868d88cd7f-d72t6 env=vso-password-2
t+31s secret=vso-password-2 pod=vso-app-868d88cd7f-d72t6 env=vso-password-2
t+46s secret=vso-password-2 pod=vso-app-868d88cd7f-d72t6 env=vso-password-2
t+61s secret=vso-password-2 pod=vso-app-868d88cd7f-d72t6 env=vso-password-2
t+76s secret=vso-password-2 pod=vso-app-868d88cd7f-d72t6 env=vso-password-2
t+91s secret=vso-password-2 pod=vso-app-868d88cd7f-d72t6 env=vso-password-2
NAME                       READY   STATUS    RESTARTS   AGE
vso-app-868d88cd7f-d72t6   1/1     Running   0          90s

$ delete the VaultStaticSecret: what happens to the destination Secret
vaultstaticsecret.secrets.hashicorp.com "shop-db" deleted from apps namespace
Error from server (NotFound): secrets "vso-shop-db" not found

Pros and cons

Pros

  • A Vault change reached the Secret and the workload by the first 15-second sample after it (t+16 s with refreshAfter: 30s): the operator restarted the Deployment, so the new pod read the new value in its environment variable.
  • The restart answers the environment-variable problem from the earlier sections without a separate controller.
  • Deleting the VaultStaticSecret removed the destination Secret at the first check, 8 seconds later.

Cons

  • It is Vault only. If the source of truth may move to another store, ESO keeps that option.
  • The synced Secret held two keys, db_password and _raw (the whole Vault response). Read the destination before you rely on a key layout.
  • Like ESO, it leaves a Secret object, and its value, in etcd.

Secrets Store CSI driver with Vault

What it is and how it works

The Secrets Store CSI driver mounts secrets from an external store into a pod as a volume. A provider (here, Vault’s) fetches the values when the pod starts; the driver keeps them in a tmpfs for the life of the pod [7]. Syncing the content into a Kubernetes Secret is optional, configured with secretObjects, and the sync page says the Secret is created only once a pod mounts the content and deleted when all the pods using it are gone [7].

Demo

Install the driver with sync and rotation on, then Vault with its CSI provider and the Vault side (a policy, and a role bound to the service account app in namespace apps):

helm repo add secrets-store-csi-driver https://kubernetes-sigs.github.io/secrets-store-csi-driver/charts
helm repo add hashicorp https://helm.releases.hashicorp.com
helm install csi secrets-store-csi-driver/secrets-store-csi-driver -n kube-system \
  --set syncSecret.enabled=true --set enableSecretRotation=true --set rotationPollInterval=30s --wait
helm install vault hashicorp/vault --version 0.34.1 -n vault --create-namespace \
  --set server.dev.enabled=true --set injector.enabled=true --set csi.enabled=true
kubectl -n vault wait --for=condition=Ready pod/vault-0 --timeout=240s

vx() { kubectl -n vault exec -i vault-0 -- env VAULT_ADDR=http://127.0.0.1:8200 VAULT_TOKEN=root vault "$@"; }
vx auth enable kubernetes
vx write auth/kubernetes/config kubernetes_host=https://kubernetes.default.svc.cluster.local:443
vx policy write app-read - <<'POLICY'
path "secret/data/apps/*" { capabilities = ["read"] }
POLICY
vx write auth/kubernetes/role/app bound_service_account_names=app \
  bound_service_account_namespaces=apps token_policies=app-read token_ttl=15m
vx kv put secret/apps/shop db_password=vault-password-1
kubectl create namespace apps && kubectl -n apps create serviceaccount app
Captured output: the installs and the Vault side (kind-ksec, 21:22 UTC, one run)
# Vault, CSI driver and agent injector on kind-ksec, 2026-11-08T21:22:16Z
"hashicorp" already exists with the same configuration, skipping
"secrets-store-csi-driver" has been added to your repositories
$ helm install csi secrets-store-csi-driver/secrets-store-csi-driver -n kube-system --set syncSecret.enabled=true --set enableSecretRotation=true --set rotationPollInterval=30s
NAME: csi
NAMESPACE: kube-system
STATUS: deployed
$ helm install vault hashicorp/vault --version 0.34.1 -n vault --create-namespace --set server.dev.enabled=true --set injector.enabled=true --set csi.enabled=true
NAME: vault
NAMESPACE: vault
STATUS: deployed
pod/vault-0 condition met
deployment "vault-agent-injector" successfully rolled out
daemon set "vault-csi-provider" successfully rolled out
NAME          	NAMESPACE  	REVISION	UPDATED                             	STATUS  	CHART                         	APP VERSION
csi           	kube-system	1       	2026-11-08 21:22:38.362272 +0000 UTC	deployed	secrets-store-csi-driver-1.6.2	1.6.2      
sealed-secrets	kube-system	1       	2026-11-08 21:18:43.764052 +0000 UTC	deployed	sealed-secrets-2.22.0         	0.41.2     
vault         	vault      	1       	2026-11-08 21:23:20.641097 +0000 UTC	deployed	vault-0.34.1                  	2.0.4      
$ Vault side
Success! Enabled kubernetes auth method at: kubernetes/
Success! Data written to: auth/kubernetes/config
Success! Uploaded policy: app-read
  required, consider specifying one if your use case would benefit from
==== Secret Path ====
version            1
namespace/apps created
serviceaccount/app created

Two SecretProviderClass objects and two pods: csi-plain mounts without secretObjects, and csi-sync mounts with it and also reads the synced Secret as an environment variable.

SecretProviderClass objects and pods (csi.yaml)
apiVersion: secrets-store.csi.x-k8s.io/v1
kind: SecretProviderClass
metadata: {name: vault-db, namespace: apps}
spec:
  provider: vault
  parameters:
    vaultAddress: http://vault.vault:8200
    roleName: app
    objects: |
      - objectName: "db-password"
        secretPath: "secret/data/apps/shop"
        secretKey: "db_password"
---
apiVersion: secrets-store.csi.x-k8s.io/v1
kind: SecretProviderClass
metadata: {name: vault-db-sync, namespace: apps}
spec:
  provider: vault
  secretObjects:
    - secretName: shop-db-synced
      type: Opaque
      data:
        - objectName: db-password
          key: password
  parameters:
    vaultAddress: http://vault.vault:8200
    roleName: app
    objects: |
      - objectName: "db-password"
        secretPath: "secret/data/apps/shop"
        secretKey: "db_password"
---
apiVersion: v1
kind: Pod
metadata: {name: csi-plain, namespace: apps}
spec:
  serviceAccountName: app
  containers:
    - name: app
      image: busybox:1.37
      command: ["sleep","3600"]
      volumeMounts: [{name: s, mountPath: /mnt/secrets, readOnly: true}]
  volumes:
    - name: s
      csi: {driver: secrets-store.csi.k8s.io, readOnly: true, volumeAttributes: {secretProviderClass: vault-db}}
---
apiVersion: v1
kind: Pod
metadata: {name: csi-sync, namespace: apps}
spec:
  serviceAccountName: app
  containers:
    - name: app
      image: busybox:1.37
      command: ["sleep","3600"]
      env:
        - name: DB_PASSWORD
          valueFrom: {secretKeyRef: {name: shop-db-synced, key: password}}
      volumeMounts: [{name: s, mountPath: /mnt/secrets, readOnly: true}]
  volumes:
    - name: s
      csi: {driver: secrets-store.csi.k8s.io, readOnly: true, volumeAttributes: {secretProviderClass: vault-db-sync}}

We changed the value in Vault and sampled the file, the variable and the synced Secret, then deleted the pod that mounts the synced Secret:

Captured output: CSI file, synced Secret, rotation and pod deletion (kind-ksec, 21:24 UTC, one run)
# Secrets Store CSI driver 1.6.2 with the Vault provider, 2026-11-08T21:24:17Z, cluster kind-ksec
$ kubectl -n apps get secrets   # before any pod
No resources found in apps namespace.
secretproviderclass.secrets-store.csi.x-k8s.io/vault-db created
secretproviderclass.secrets-store.csi.x-k8s.io/vault-db-sync created
pod/csi-plain created
pod/csi-sync created
$ wait for csi-plain (mounts without secretObjects)
pod/csi-plain condition met
$ the mounted file
total 0
lrwxrwxrwx    1 root     root            18 Nov  8 21:24 db-password -> ..data/db-password
vault-password-1
tmpfs on /mnt/secrets type tmpfs (ro,relatime,inode64)
$ kubectl -n apps get secrets   # csi-plain is running, but csi-sync started at the same time, so its synced Secret already exists
NAME             TYPE     DATA   AGE
shop-db-synced   Opaque   1      0s

$ csi-sync: file plus synced Kubernetes Secret
pod/csi-sync condition met
file=vault-password-1 env=vault-password-1
$ kubectl -n apps get secrets
NAME             TYPE     DATA   AGE
shop-db-synced   Opaque   1      1s
synced Secret owner: SecretProviderClassPodStatus/csi-sync-apps-vault-db-sync

$ rotate the value in Vault; sample file, env var and synced Secret
version            2
# changed at 21:24:18
t+20s file=vault-password-1 env=vault-password-1 synced-secret=vault-password-1
t+40s file=vault-password-1 env=vault-password-1 synced-secret=vault-password-1
t+60s file=vault-password-1 env=vault-password-1 synced-secret=vault-password-1
t+80s file=vault-password-2 env=vault-password-1 synced-secret=vault-password-2
t+101s file=vault-password-2 env=vault-password-1 synced-secret=vault-password-2
t+121s file=vault-password-2 env=vault-password-1 synced-secret=vault-password-2

$ delete the pod that mounts the synced Secret
pod "csi-sync" deleted from apps namespace
after 5 s: Error from server (NotFound): secrets "shop-db-synced" not found
after 15 s: Error from server (NotFound): secrets "shop-db-synced" not found
after 30 s: Error from server (NotFound): secrets "shop-db-synced" not found

With csi-plain as the only pod left, no Secret existed:

Captured output: only the pod without secretObjects is running (kind-ksec, 21:27 UTC)
# CSI without secretObjects: only csi-plain is running, 2026-11-08T21:27:49Z, cluster kind-ksec
NAME        READY   STATUS    RESTARTS   AGE
csi-plain   1/1     Running   0          3m32s
$ kubectl -n apps get secrets
No resources found in apps namespace.
file in csi-plain: vault-password-2

Pros and cons

Pros

  • Without secretObjects, the value is a file in a tmpfs and no Kubernetes Secret exists (the second capture lists none while the pod runs).
  • The file followed a change in Vault: in this run both the file and the synced Secret updated between 60 and 80 seconds with a 30-second poll.
  • The synced Secret was gone at the first check after we deleted its pod, so it does not outlive its consumers.

Cons

  • With sync on, a Secret object, and its value in etcd, exists while the pod runs. The claim that the driver bypasses etcd holds only without secretObjects.
  • The environment variable stayed at the old value after rotation (vault-password-1 through the last sample), because a running container’s environment is fixed.
  • Sync and auto rotation are listed as alpha on the project page [7]. The Secret appears only after a pod mounts it, so “solely relying on the syncing” does not work [7].

Vault Agent sidecar

What it is and how it works

Vault’s agent injector is a mutating webhook. When a pod carries vault.hashicorp.com/agent-inject annotations, it adds an init container that pre-populates a shared memory volume with the rendered secrets and a sidecar that keeps them fresh. The pod’s service account is bound to a Vault role [8]. No Kubernetes Secret is involved.

Demo

apiVersion: v1
kind: Pod
metadata:
  name: agent-demo
  namespace: apps
  annotations:
    vault.hashicorp.com/agent-inject: "true"
    vault.hashicorp.com/role: "app"
    vault.hashicorp.com/agent-inject-secret-db: "secret/data/apps/shop"
    vault.hashicorp.com/agent-inject-template-db: |
      {{- with secret "secret/data/apps/shop" -}}{{ .Data.data.db_password }}{{- end -}}
    vault.hashicorp.com/template-static-secret-render-interval: "20s"
spec:
  serviceAccountName: app
  containers:
    - name: app
      image: busybox:1.37
      command: ["sleep","3600"]

This uses the same Vault as the CSI demo. Our first attempt spelled the interval annotation template-config-static-secret-render-interval. The injector accepted the pod without complaint and ignored the annotation: the agent’s template_config held only exit_on_retry_failure, and the file stayed at the old value after we changed it:

Captured output: injector with the misspelled annotation (kind-ksec, 21:28 UTC, one run)
# Vault Agent injector (sidecar), 2026-11-08T21:28:07Z, cluster kind-ksec, Vault 2.0.4
version            3
pod/agent-demo created
pod/agent-demo condition met
$ containers the injector added
init: vault-agent-init
containers: app vault-agent
$ the rendered file and its mount
total 4
-rw-r--r--    1 100      1000            16 Nov  8 21:28 db
vault-password-3
tmpfs on /vault/secrets type tmpfs (rw,relatime,size=65714556k,inode64,noswap)
$ kubectl -n apps get secrets   # no Secret object was created for the app
No resources found in apps namespace.

$ rotate the value in Vault; sample the file
version            4
# changed at 21:28:08
t+15s file=vault-password-3
t+30s file=vault-password-3
t+45s file=vault-password-3
t+60s file=vault-password-3
t+76s file=vault-password-3
t+91s file=vault-password-3

With the documented spelling [9], the agent config gained static_secret_render_interval, and the file followed the change:

Captured output: injector with the documented annotation (kind-ksec, 21:30 UTC, one run)
# Vault Agent injector with the correct render-interval annotation, 2026-11-08T21:30:55Z, cluster kind-ksec
$ the first attempt used vault.hashicorp.com/template-config-static-secret-render-interval; the agent config it produced:
template_config = {'exit_on_retry_failure': True}
pod "agent-demo" deleted from apps namespace
version            5
pod/agent-demo created
pod/agent-demo condition met
$ the same pod with vault.hashicorp.com/template-static-secret-render-interval: "20s"; the agent config:
template_config = {'exit_on_retry_failure': True, 'static_secret_render_interval': '20s'}
file now: vault-password-5
version            6
# changed at 21:31:27
t+15s file=vault-password-5
t+31s file=vault-password-6
t+46s file=vault-password-6
t+61s file=vault-password-6
t+76s file=vault-password-6
t+91s file=vault-password-6

Pros and cons

Pros

  • No Secret object. The value is a file in a memory-backed volume (tmpfs in the capture), written by a sidecar that also renews the Vault token.
  • With the 20-second interval the file changed by the second sample, about 31 seconds after the change.

Cons

  • A misspelled annotation was silently ignored. Check the rendered agent config, not only that the pod started.
  • Every pod gets an init container and a sidecar, with the memory and start-up cost that implies. We did not measure it.
  • The injector needs the webhook and a Vault role per service account; the role limits who can read what, since a pod with another service account would be refused.

The app talks to Vault directly

What it is and how it works

The application, or a helper in the pod, logs in to Vault with its service-account token and reads the secret itself. Nothing in the cluster holds the value; the application keeps it in memory. We used curl to show the flow without a library. In your code, a Vault client library does the same two calls.

Demo

kubectl -n apps exec sdk-demo -- sh -c '
JWT=$(cat /var/run/secrets/kubernetes.io/serviceaccount/token)
VT=$(curl -s --request POST --data "{\"jwt\":\"$JWT\",\"role\":\"app\"}" \
  http://vault.vault:8200/v1/auth/kubernetes/login | sed -n "s/.*\"client_token\":\"\([^\"]*\)\".*/\1/p")
VAL=$(curl -s -H "X-Vault-Token: $VT" http://vault.vault:8200/v1/secret/data/apps/shop \
  | sed -n "s/.*\"db_password\":\"\([^\"]*\)\".*/\1/p")
echo "db_password length: ${#VAL}"'

The pod runs under the app service account (same Vault setup as above, pod image curlimages/curl:8.11.1). We printed only the length and whether it matched, then listed Secrets, listed the identity files in the pod, and ran the same login from a pod with a different service account:

Captured output: direct login and read, identity files, another service account (kind-ksec, 21:29 UTC, one run)
# Application talks to Vault directly (no injector, no CSI), 2026-11-08T21:29:39Z, cluster kind-ksec
pod/sdk-demo created
pod/sdk-demo condition met
$ the app logs in with its service-account token, then reads the secret (only the length is printed)
vault token obtained: yes
db_password length: 16  matches vault-password-4: yes
$ kubectl -n apps get secrets   # nothing in the cluster holds the value
No resources found in apps namespace.

$ but the identity is in the pod: anyone who can exec into it can do the same
ca.crt
namespace
token
token length: 1146
$ a second pod using a different service account is refused by the Vault role
serviceaccount/other created
pod/other created
pod/other condition met
{"errors":["service account name not authorized"]}

pod "other" deleted from apps namespace

Pros and cons

Pros

  • No Kubernetes Secret, no file and no environment variable hold the value, so the cluster’s Secret store and its backups never contain it.
  • Vault’s role check worked: the pod under another service account got service account name not authorized.

Cons

  • “Attack surface zero” is not true. The pod holds a service-account token (1146 bytes in our run), and anyone who can exec into the pod can run the same login and read the secret. Vault must also be reachable from the pod.
  • Your application code now depends on the store, and rotation, retries and caching are the application’s job.

Which one to pick

Approach Value at rest lives in Secret object while running Pod gets it as Rotation we saw Catch we hit
Native Secret etcd yes env var or file file by the first check, env var unchanged through 121 s list reads every value; create pods reads them too
Encryption at rest (KMS v2) etcd, encrypted; key in the KMS yes env var or file new key version used without an API server restart API readers still see plaintext; the API server needs the KMS at start-up; losing the key loses the data
Sealed Secrets Git, encrypted yes env var or file re-seal and apply the key lives in the cluster; a second cluster cannot unseal
SOPS with age Git, encrypted yes env var or file re-encrypt and apply whoever holds the age key reads everything
SOPS with Flux or Argo CD Git, encrypted yes env var or file a Git change reached the cluster in 30 seconds (Flux) and by the first 15-second sample after a hard refresh (Argo CD) the key lives in the cluster; Argo CD needs a repo-server patch
ESO external store yes env var or file within one refresh interval; env var needs a restart a revoked token left the store showing Valid
Vault Secrets Operator Vault yes env var or file restarted the Deployment by the first 15-second sample Vault only; two keys in the synced Secret
CSI driver external store only if synced file (env if synced) 60 to 80 seconds, 30-second poll sync puts the value back in etcd
Vault Agent external store no file in memory by about 31 seconds, 20-second interval a wrong annotation name is ignored without a warning
App to Vault external store no app memory the app’s job the pod holds the identity token

A sensible start

Begin with the baseline every cluster should have: least-privilege RBAC that treats list, watch and pod creation as Secret access, and encryption at rest with a KMS provider, which we ran with Vault transit. Then choose where the source of truth lives.

If you keep secrets in Git and run no external store, Sealed Secrets or SOPS with age is the shorter path; pick SOPS if you need readable diffs and several key holders, and Sealed Secrets if you want the cluster to be the only decryptor. Flux decrypts SOPS files natively, while Argo CD needs a plugin and a repo-server patch. With Vault or a cloud store already in place, ESO gives your workloads ordinary Secrets from that source, and the Vault Secrets Operator does the same for Vault alone and can restart the workload when the value changes. Use the CSI driver without secretObjects, or the Vault Agent sidecar, when you want values out of etcd and your application can read a file. Choose direct access only when the application can authenticate and re-fetch by itself, and protect the pod’s identity as you would a password.

Whatever you pick, treat environment variables as fixed at start, and plan a restart for them.

Sources

All checked 8 November 2026.

  1. Kubernetes, Secrets (opens in a new tab). Supports: unencrypted by default in etcd, the Secret types, tmpfs for mounted Secrets, update delay and subPath, immutable Secrets, the list and watch warning, the 1 MiB limit.
  2. Kubernetes, Encrypting Confidential Data at Rest (opens in a new tab). Supports: --encryption-provider-config, the provider table (aescbc not recommended, KMS v2 strongest, stable v1.29), the rewrite command, key rotation, the host-compromise caveat.
  3. Bitnami, Sealed Secrets (opens in a new tab), v0.41.2 (26 October 2026). Supports: asymmetric encryption, scopes, key renewal every 30 days, values not rotated, the Helm repository URL.
  4. SOPS, getsops/sops (opens in a new tab), v3.13.3 (23 July 2026). Supports: formats and key backends.
  5. Flux, Manage Kubernetes secrets with SOPS (opens in a new tab). Supports: kustomize-controller decryption, decryption.provider: sops, age keys, encrypted_regex (not run).
  6. Argo CD, Secret Management (opens in a new tab). Supports: the preference for populating secrets on the destination cluster, the operators it names, the caution about manifest-generating plugins (not run).
  7. Kubernetes SIGs, Secrets Store CSI driver (opens in a new tab), v1.6.2 (5 November 2026), and its sync as Kubernetes Secret (opens in a new tab) page. Supports: volume mounting, secretObjects, create and delete timing, alpha sync and rotation.
  8. HashiCorp, Vault Agent Injector (opens in a new tab). Supports: the mutating webhook, init container and sidecar, the shared memory volume at /vault/secrets, the service-account-to-role binding.
  9. HashiCorp, Vault Agent Injector annotations (opens in a new tab). Supports: vault.hashicorp.com/template-static-secret-render-interval, agent-inject annotations.
  10. Viaduct, KSOPS (opens in a new tab), v4.5.1 (13 April 2026). Supports: the generator format and the Argo CD repo-server patch.
  11. HashiCorp, Vault Secrets Operator (opens in a new tab), chart and operator 1.6.2 (21 October 2026). Supports: what the operator does and the workload rotation statement.
  12. FalcoSuessgott, vault-kubernetes-kms (opens in a new tab), v1.4.0 (19 August 2026). Supports: the KMS v2 plugin, its static pod and kind configuration.
  13. Flux, flux2 releases (opens in a new tab), v2.10.0 (26 October 2026). Supports: the install manifest used.
  14. External Secrets, Fake provider (opens in a new tab), v2.14.0. Supports: the fake provider returns static values for testing; the maintenance warning is from our run.
  15. External Secrets, Getting started (opens in a new tab), v2.14.0. Supports: the Helm install command, the 256 KB CRD note and server-side apply.
  16. External Secrets, GitOps using Flux (opens in a new tab), v2.14.0. Supports: the Flux path (not run).
  17. External Secrets, Stability and support (opens in a new tab), v2.14.0. Supports: only the current minor is supported; upgrade one minor at a time; provider stability tiers.
  18. External Secrets, Releases (opens in a new tab), v2.11.0 to v2.14.0. Supports: the release dates.
  19. External Secrets, e2e Argo CD application (opens in a new tab), v2.14.0. Supports: the sync options ESO’s own test uses.
  20. Argo CD, ExternalSecret health check (opens in a new tab), v3.6.1. Supports: Degraded when Ready is false.
  21. Ansible, kubernetes.core.helm module (opens in a new tab), collection 6.6.0. Supports: the helm diff note and the timeout option.
  22. External Secrets, AWS access (opens in a new tab), v2.14.0. Supports: the AWS authentication options (not run).
  23. External Secrets, HashiCorp Vault provider (opens in a new tab), v2.14.0. Supports: TokenReview, system:auth-delegator and the audience requirement from Vault 1.21.
  24. External Secrets, Multi-tenancy (opens in a new tab), v2.14.0. Supports: store layout options.
  25. External Secrets, Templating (opens in a new tab), v2.14.0. Supports: target.template with engine v2.
  26. Jenkins, Kubernetes Credentials Provider plugin (opens in a new tab), release 1.324.v5271cd145cb_1 (28 October 2026). Supports: the secrets RBAC and label selector; the credential types come from the External Secrets Jenkins example (opens in a new tab).
  27. External Secrets, GitHub provider (opens in a new tab), v2.14.0. Supports: write-only, GitHub App authentication, required fields.
  28. External Secrets, Targeting custom resources (opens in a new tab), v2.14.0. Supports: alpha status, the flag, the RBAC values.
  29. Argo CD, Sync waves (opens in a new tab), v3.6.1. Supports: ordering of resources within a sync (not run).
  30. HashiCorp, Vault docs PR #1221, “Clarify that 1.21 did not make audience required” (opens in a new tab), merged 30 October 2025. Supports: audience did not become required in Vault 1.21; the early 1.20 warning was incorrect.
Back to all articles

Technologies we work with

  • Argo CD
  • Kubernetes
  • Docker
  • Podman
  • Ansible
  • Jenkins
  • Chef
  • Proxmox VE
  • Google Cloud
  • Linux
  • Red Hat
  • CentOS
  • NGINX
  • Apache
  • Traefik
  • Prometheus
  • Grafana
  • PostgreSQL
  • MySQL
  • MariaDB
  • Redis
  • etcd
  • Qdrant
  • Elasticsearch
  • Logstash
  • Kibana
  • PagerDuty
  • Go
  • Python
  • FastAPI
  • PHP
  • TypeScript
  • React