Native Kubernetes Secrets usually start life in a YAML file, a Helm values file or a CI variable, so the same password ends up in Git history, in etcd and on every laptop that ran the deploy. Moving the source of truth to an external secrets manager (AWS Secrets Manager, Azure Key Vault, Google Secret Manager, HashiCorp Vault or similar) gives one place to control access, audit reads and rotate values, while workloads keep reading a Secret or a mounted file. This page is the migration procedure: inventory what you have, choose between the External Secrets Operator (ESO) and the Secrets Store CSI Driver, seed the external store without copying values by hand, cut workloads over, roll back if needed, and retire the old copies. It is for platform engineers and Kubernetes administrators. Hardening the operator itself is covered in the External Secrets Operator security guide, and Secret security inside the cluster in the Kubernetes Secrets Security Guide.
This is an independent guide from NHI Mgmt Group, based on the public documentation of Kubernetes, the External Secrets Operator project and the Secrets Store CSI Driver project as of November 2026. It is not sponsored or reviewed by any of these projects or by any secrets manager vendor. Field names and defaults change between releases, so confirm against the documentation for the version you run.
Key takeaways
- Migration is not finished until the old value is rotated. Every value that lived in Git, Helm values or etcd without encryption must be treated as exposed. Move it, then rotate it in the external store and let the sync deliver the new value.
- Seed the store from the cluster, not from laptops. ESO's PushSecret can copy an existing Secret's keys into the external store, so nobody has to decode and paste values by hand.
- Use a safe creation policy during cut-over. With
creationPolicy: Orphanthe Secret survives if you delete the ExternalSecret, which makes rollback a one-line change. Switch toOwneronce stable. - Environment variables do not refresh. Kubernetes updates mounted Secret volumes eventually, but a Pod reading a Secret through an environment variable or a
subPathmount only sees a new value after restart. Plan restarts with each rotation. - The cluster still holds a credential. ESO or the CSI provider needs its own identity to read the store. Use workload identity federation, not a static access key in another Secret.
When you need this
- Secrets are committed to Git or Helm values. Even base64-encoded, they are plaintext to anyone who can read the repository, including its history. Exposed cluster secrets have unlocked far more than the cluster: in the SAP exposed Kubernetes secrets incident, a key with access to an artifact repository was found in public.
- Rotation means editing YAML in several places. An external store gives one value to rotate and lets the sync push it to every namespace and cluster that uses it.
- You need to know who read a secret. Kubernetes audit logs record API access, but cloud secrets managers and Vault add per-secret access logging and policy outside the cluster.
- Several clusters share credentials. Copying the same Secret into each cluster multiplies exposure; one store with per-cluster read policies does not.
Before you start
| Requirement | Detail |
|---|---|
| Cluster access | Read access to Secrets in the namespaces in scope for the inventory; rights to install CRDs and controllers |
| External store | A secrets manager with a naming convention agreed in advance, for example <cluster>/<namespace>/<app>, and per-path access policy |
| Store identity | A workload identity for the operator or provider: IRSA or EKS Pod Identity, GKE Workload Identity Federation, Azure workload identity, or Vault Kubernetes auth |
| Encryption at rest | Synced Secrets still land in etcd; confirm API server encryption at rest is configured |
| Change plan | Owners for each application, a restart plan for workloads that read Secrets as environment variables, and a maintenance window for shared credentials |
Choosing the delivery mechanism
| Approach | How the workload sees the value | Choose it when | Trade-off |
|---|---|---|---|
| External Secrets Operator | A normal Kubernetes Secret created and refreshed by the controller | You want no application change, and Helm charts or operators expect a Secret by name | The value still exists in etcd and is readable by anyone with Secret read rights in the namespace |
| Secrets Store CSI Driver | A file in a CSI volume mounted into the Pod; optional sync to a Secret through secretObjects | You want the value only in the Pod's filesystem, not in etcd | Applications must read files; synced Secrets only exist while a Pod mounts the volume and are deleted when all consuming Pods go |
| Application reads the store directly | SDK call at runtime | The application already supports the store and you want no cluster copy at all | Code change per application; every Pod needs its own store identity |
Most estates migrate with ESO first, because it changes nothing in the workload, then move the most sensitive applications to CSI volumes or direct reads later. Git-based alternatives such as SOPS or Sealed Secrets solve the Git problem but not central rotation and access audit; see SOPS and Sealed Secrets for that comparison.
Step-by-step
Steps 1 and 2 are read-only. Steps 3 to 5 add the external path alongside the existing Secret. Step 6 changes ownership. Steps 7 and 8 retire the old copies and are the destructive part.
- Inventory Secrets without printing values.
kubectl getandkubectl describeshow names, types and key names but not the contents:
Export name, namespace, type, key names, labels, annotations andkubectl get secrets --all-namespaces kubectl describe secret <name> -n <namespace>ownerReferencesto a dated file. Do not use-o yamlor-o jsonpath='{.data}'for the inventory: that decodes to the values, and the file becomes a secret itself. - Classify each Secret. Not every Secret should move.
Also record, for each Secret, which Pods consume it and how:
Secret Action Opaque,kubernetes.io/basic-auth,kubernetes.io/ssh-authcreated from manifests or CIMigrate: these are the application credentials the project exists to move kubernetes.io/dockerconfigjsonimage pull SecretsMigrate with an ExternalSecret template that sets the type, or replace with registry federation where the platform supports it kubernetes.io/tlsMigrate if a person created it; leave alone if a certificate controller issues and renews it kubernetes.io/service-account-token,bootstrap.kubernetes.io/tokenDo not migrate; replace legacy long-lived token Secrets with projected tokens (see How to rotate a Kubernetes service account token) Any Secret with an ownerReferenceto another controllerLeave to that controller; ESO will report a conflict rather than take it over envorenvFrom, a volume, or asubPathmount. That decides whether a rotation needs a restart. - Install the delivery mechanism and connect it to the store with workload identity. Create a namespaced
SecretStoreper team (or aClusterSecretStoreonly where a central team owns all paths) that authenticates with the cluster's workload identity, and grant that identity read access only to the store paths for that namespace. A store credential kept in another Kubernetes Secret recreates the problem you are solving. See the Kubernetes Workload Identity Checklist. - Seed the external store. Two options:
- Push from the cluster: a
PushSecret(apiVersion: external-secrets.io/v1alpha1) selects the existing Secret withspec.selector.secret.name, maps eachsecretKeyto aremoteRef.remoteKeyandproperty, and writes it to the stores inspec.secretStoreRefs. LeavedeletionPolicyat its defaultNoneso deleting the PushSecret later does not delete the remote value. The store identity needs write access for this step only; remove it afterwards. - Generate new values in the store: for credentials you can rotate at the source (database users, API keys), create a fresh value directly in the secrets manager. This combines migration and rotation in one step and is the better option where the old value is known to be in Git.
- Push from the cluster: a
- Create the ExternalSecret alongside the existing Secret. For the first pass write to a new target name so you can compare without touching the running workload:
Confirm the ExternalSecret reaches conditionapiVersion: external-secrets.io/v1 kind: ExternalSecret metadata: name: app-db namespace: payments spec: refreshPolicy: Periodic refreshInterval: 1h0m0s secretStoreRef: name: payments-store kind: SecretStore target: name: app-db-migrated creationPolicy: Owner data: - secretKey: password remoteRef: key: prod/payments/app-db property: passwordReadywith reasonSecretSynced, and that key names and type match the original. Usetarget.templateto set the Secrettypeand any fixed keys the application expects. - Cut the workload over. Either point the Deployment at the new Secret name, or take over the original name. To take over the original, set
target.nameto it withcreationPolicy: Orphanfor the cut-over period. ESO adopts an existing Secret that has noownerReferenceand starts updating it, and withOrphanthe Secret survives if you later delete the ExternalSecret. Restart Pods that read the Secret as environment variables. Run the application's own health checks, then move tocreationPolicy: Owneronce the change has been stable for an agreed period. - Remove the old source. Delete the Secret manifest or Helm values entry from the repository and from CI variables, so the next deploy does not overwrite the synced Secret with the old value. If values were committed, the copies remain in Git history; see How to Remove Secrets From Git History, and rotate regardless.
- Rotate, then tighten RBAC. Rotate each migrated value in the store and confirm it reaches the Pods. Then remove human
get,listandwatchon Secrets in migrated namespaces where nobody needs them: the Kubernetes documentation warns that grantingliston Secrets implicitly lets the subject fetch their contents. See the Kubernetes RBAC Guide.
Refresh settings that matter
| Setting | Values | Migration guidance |
|---|---|---|
ESO refreshPolicy | Periodic (default), OnChange, CreatedOnce | Use Periodic so rotations in the store reach the cluster; CreatedOnce never refreshes |
ESO refreshInterval | Duration; zero means fetch once | Balance store API cost against how fast a rotation must land |
ESO creationPolicy | Owner, Orphan, Merge, None | Orphan during cut-over, Owner afterwards; Merge only to add keys into a Secret something else creates |
ESO deletionPolicy | Retain (default), Delete, Merge | Keep Retain so deleting a remote value by mistake does not delete the running Secret; Delete only works with Owner |
| CSI driver rotation | --enable-secret-rotation, --rotation-poll-interval (default 2m) | Rotation is not on by default; enable it if you rely on mounted values changing |
Verify it worked
- Sync status: every ExternalSecret is
Readywith reasonSecretSyncedand a recentstatus.refreshTime. - No manual Secrets left: re-run the inventory; each in-scope Secret now has an ExternalSecret owner or is on the documented exceptions list.
- Rotation test: change a non-critical value in the store and confirm it reaches the Pod within the refresh interval (plus a restart for environment variables).
- Repository clean: secret scanning on the repositories that held the manifests reports no live values. See the Git Secret Scanning Guide.
Rollback and common errors
With creationPolicy: Orphan, rollback is to delete the ExternalSecret: the Secret stays with its last synced value, and you can restore the old manifest if needed. With Owner, deleting the ExternalSecret garbage-collects the Secret, so switch back to Orphan first. Avoid taking plaintext exports of Secrets as a rollback backup; if you need a copy, the external store is the copy.
| Symptom | Likely cause | Fix |
|---|---|---|
| ExternalSecret reports a conflict on an existing Secret | The Secret is owned by another controller through an ownerReference | Leave it to that controller or remove the other owner deliberately |
| Values flip back and forth | Two ExternalSecrets merging the same keys, or Helm or GitOps re-applying the old manifest | One writer per key; remove the old manifest from the repository |
SecretSyncedError after a remote value was deleted | deletionPolicy: Retain keeps the Secret but flags the missing source | Restore the remote value or retire the ExternalSecret |
| Pod still uses the old password | Value read as an environment variable or through subPath | Restart the workload after rotation, or move to a volume mount |
| CSI-synced Secret missing | No running Pod mounts the volume yet | The driver only creates the Secret once a Pod mounts it; do not depend on sync alone |
| Authentication errors from the store | Workload identity not bound to the controller's service account, or policy missing the path | Check the identity binding and path policy before blaming the operator |
Small estate or large organisation
A single cluster with a few applications can migrate namespace by namespace with one SecretStore each and a short checklist. Large estates should treat this as a programme: generate the inventory across clusters automatically, publish a naming convention and path policy for the store, give each platform team a namespaced store bound to its own identity, migrate by wave (non-production, then low-risk production, then shared credentials), and use an admission policy to block new manually created Secrets in migrated namespaces. See the Kubernetes Admission Control Guide and, for the wider programme, the Secrets Management Guide.
Detection and measurement
ESO exposes Prometheus metrics including externalsecret_sync_calls_total, externalsecret_sync_calls_error and externalsecret_status_condition; alert on a rising error ratio and on ExternalSecrets that are not Ready. In the cluster, Kubernetes audit logs should show Secret reads only from the controller, kubelets and expected workloads; alert on list or get of Secrets by humans in migrated namespaces (see the Kubernetes Audit Log Detection Guide). In the store, alert on reads by identities other than the cluster's.
- Migration coverage: in-scope Secrets backed by an ExternalSecret or CSI volume, as a percentage of the inventory.
- Rotated after migration: migrated values rotated at least once since cut-over (target 100%).
- Sync health: ExternalSecrets not Ready for more than one refresh interval (target zero).
- Repository exposure: secret scanning findings in deployment repositories (target zero live findings).
- Static store credentials: SecretStores authenticating with a static key rather than workload identity (target zero).
- Human Secret access: users with
get,listorwatchon Secrets in migrated namespaces.
Common failure patterns
- Moving the value without rotating it. The old password is still in Git history and old etcd backups. Migration only reduces risk once the value changes.
- A static cloud key to reach the store. The Tesla Kubernetes incident shows how cloud credentials stored in a cluster become the attacker's next step; a static key for the secrets manager is the same risk with a bigger prize.
- One ClusterSecretStore for everything. Any namespace can then request any path the store identity can read. Prefer namespaced stores or restrict which namespaces may use a cluster store.
- GitOps fighting the operator. The old Secret manifest stays in the repository and the GitOps tool re-applies it after every sync, reverting the value. See the GitOps Security Guide.
- Forgetting environment variable consumers. The store rotates, the Secret updates, and the application keeps the old value until a restart that nobody scheduled, then fails at the worst time.
Framework mapping
| Framework | Reference | How this procedure supports it |
|---|---|---|
| NIST SP 800-53 Rev 5 | IA-5, IA-5(7) | Removes static authenticators from manifests and repositories; central rotation |
| NIST SP 800-53 Rev 5 | SC-28, AC-6 | Protects stored secrets and narrows who can read them |
| NIST CSF 2.0 | PR.AA-01, PR.DS-01, DE.CM-09 | Credentials managed centrally, data at rest protected, runtime access monitored |
| CIS Controls v8.1 | Control 3 (Data Protection), Control 6 (Access Control Management) | Sensitive data stored and accessed under policy |
| ISO/IEC 27001:2022 Annex A | 5.17 Authentication information, 8.24 Use of cryptography | Authentication information is managed and protected |
| MITRE ATT&CK v20 | T1552.007 Container API, T1552.001 Credentials In Files | Fewer credentials readable through the Kubernetes API or from repository files |
Practitioner checklist
- Inventory Secrets by name, type, keys and owner without decoding values.
- Exclude service account tokens, bootstrap tokens and controller-owned Secrets.
- Choose ESO, CSI driver or direct reads per application, and record why.
- Authenticate the operator or provider to the store with workload identity, never a static key.
- Seed the store with PushSecret or fresh values, then remove the store write permission.
- Cut over with
creationPolicy: Orphan, restart environment variable consumers, then switch toOwner. - Delete old manifests and CI variables, and clean Git history where values were committed.
- Rotate every migrated value and confirm delivery.
- Remove human Secret read rights in migrated namespaces and block new manual Secrets with admission policy.
- Alert on ESO sync errors, unexpected Secret reads and store reads by other identities.
Standards and references
- Kubernetes: Secrets
- Kubernetes: Good practices for Kubernetes Secrets
- Kubernetes: Managing Secrets using kubectl
- External Secrets Operator: ExternalSecret
- External Secrets Operator: PushSecret
- External Secrets Operator: Lifecycle, ownership and deletion policy
- Secrets Store CSI Driver: Sync as Kubernetes Secret
- External Secrets Operator: Metrics
Related NHI Mgmt Group resources: External Secrets Operator security guide, Kubernetes Secrets Security Guide, Secrets Management Guide, Kubernetes NHI Security Guide, HashiCorp Vault Guide.