Home / Stories / Secrets hygiene
Story · Security Live

Every Secret gets a source of truth, and it isn't git.

A webhook signing key was created with kubectl create secret during an incident. Another one sits base64-encoded in a YAML file in the repo. Both work, until the namespace is rebuilt or someone rotates the key. The assistant turns each into an ExternalSecret backed by your Vault, seeds Vault from the running value, and checks every diff it writes for credentials that should not be there.

Starts fromA Secret in the cluster with no ExternalSecret behind it, a Secret manifest committed to git, or a credential inside Helm values.
Looks atSecret types and key names (never values), your ClusterSecretStores, chart and Job ownership, the workloads that consume each Secret.
You getA draft MR that adds the ExternalSecret (and a PushSecret to seed Vault), removes the committed Secret, and says what must be true before merging.
01 · Which Secrets have no source?

Every Secret without an ExternalSecret behind it.

The assistant lists Secrets in application namespaces and looks for an ExternalSecret that produces each one. It reads names, types and key names only: its cluster access is read-only and it never sees a secret value.

Here: payments/stripe-webhook, two keys, created by hand, and a committed smtp-relay Secret in the repo.

02 · Not every Secret should move

Some belong to a chart, some aren't secret at all.

Secrets written by a chart's own bootstrap Job, or named after a Helm release in the same namespace, are left alone: re-shipping them would fight the chart.

TLS and CA material goes to review, because a key-value store path that turns out empty would overwrite a working certificate. A committed Secret whose values are plainly not credentials is proposed as a ConfigMap instead.

03 · Write the ExternalSecret

Same name, same keys, backed by your store.

The ExternalSecret mirrors the Secret's keys, one remoteRef per key. When the cluster has exactly one ClusterSecretStore it is used; with several, your configured default wins, otherwise the MR asks you to choose.

For a committed Secret, the same MR deletes the static manifest, so git and the ExternalSecret never both own it.

04 · Seed Vault on merge

No manual vault kv put.

An ExternalSecret pointed at an empty path would replace a working Secret with an empty one. So the MR pairs it with a PushSecret that copies the live value into Vault once, on merge, and never overwrites or deletes it afterwards.

The value travels from the cluster to Vault inside your cluster. It never passes through the assistant, the model, or the MR.

05 · Secrets hiding in Helm values

Credential-shaped values become references.

When a HelmRelease is migrated or adopted, its values are scanned for credential-shaped leaves: a password key, a token pasted into a startup script. Each is rewritten to a reference, and one ExternalSecret supplies them all through valuesFrom.

Anything ambiguous is declined, not guessed: the MR then carries a scaffold and a note instead.

06 · Scan every diff

Before any MR opens, not after.

Every diff from every agent is checked for literal-looking credentials: API keys, tokens, passwords, client secrets. A hit withholds the MR.

ExternalSecret edits get their own checks: a renamed remoteRef key, a template reverted to the old engine, a rewrite that matches everything, deletionPolicy flipped to Delete.

07 · Before a restart bites

A missing Secret only fails when the pod restarts.

A diff that makes a workload reference a Secret that exists nowhere, in git or in the cluster, is held: the next pod start would fail with CreateContainerConfigError.

When an ExternalSecret stops syncing, the issue names the workloads that consume its Secret, so the stale credential is fixed before the next rollout picks it up.

08 · Keeping ExternalSecrets current In development

Operator upgrades that remove old API and template versions.

When the External Secrets Operator stops serving an old API version, every Helm release that still renders it fails at its next upgrade. Those failures are reported today, one issue per release; grouping them into one issue per cause is built and not yet switched on.

Roadmap Before an operator upgrade, sweep live ExternalSecrets for defaults the new version removes (such as a stored template engine version) and propose declaring them.

secrets without an ExternalSecret · metadata only
  • shop/cart-api-db ExternalSecret cart-api-db · synced
  • payments/stripe-webhook Opaque · keys: signing-secret, api-key · no source
  • platform/smtp-relay committed in git · base64 in YAML
  • platform/ingress-ca ca.crt only · certificate material
  • search/es-bootstrap-token written by a chart Job · skipped
values read: 0 · key names and types only
Chart-owned or release-namedleft alone
Certificate materialdraft MR held for review
Not a credentialconvert to ConfigMap
CredentialExternalSecret + PushSecret
apps/payments/externalsecret_stripe-webhook.yaml
apiVersion: external-secrets.io/v1
kind: ExternalSecret
metadata: { name: stripe-webhook, namespace: payments }
spec:
  refreshInterval: 1h
  secretStoreRef: { kind: ClusterSecretStore, name: vault }
  target: { name: stripe-webhook, creationPolicy: Owner }
  data:
    - secretKey: signing-secret
      remoteRef: { key: payments/stripe-webhook, property: signing-secret }
    - secretKey: api-key
      remoteRef: { key: payments/stripe-webhook, property: api-key }
1 ClusterSecretStore in cluster · picked automatically
Secretlive value
PushSecretIfNotExists · deletion: None
Vaultpath seeded once
ExternalSecretowns the Secret
apps/payments/pushsecret_stripe-webhook.yaml
kind: PushSecret
spec:
  updatePolicy: IfNotExists    # never overwrite what Vault holds
  deletionPolicy: None        # removing this never deletes the Vault data
  selector: { secret: { name: stripe-webhook } }
apps/shop/helmrelease_orders-api.yaml
  values:
    db:
      host: orders-db
-     password: s3cr3t-from-2021
    migrate:
      command:
-       - sh -c "curl -H 'Token: 9f2c…' http://…"
+       - sh -c "curl -H \"Token: ${MIGRATE_TOKEN}\" http://…"
+     env: [ { name: MIGRATE_TOKEN, valueFrom: { secretKeyRef: … } } ]
+ valuesFrom:
+   - kind: Secret
+     name: orders-api-externalized
2 credentials externalized · risk floored to medium · populate before merge
pre-MR checks · every proposal
  • literal credential in diff MR withheld
  • remoteRef key renamed held for review
  • template engine version removed held for review
  • rewrite matching every key held for review
  • deletionPolicy Retain → Delete flagged: destructive
  • ExternalSecret references, encrypted blocks pass through
ExternalSecret payments/billing-db · SecretSyncedError
ClusterSecretStore "vault" · permission denied on payments/billing-db
consumers of Secret billing-db
  • deploy/billing-api envFrom.secretRef
  • cronjob/invoice-run secretKeyRef DB_PASSWORD
Secret still holds the last synced value · rotation will not reach these pods
HelmReleases failing after an operator upgrade
 shop/orders-api       no matches for kind "ExternalSecret" in version "external-secrets.io/v1alpha1"
 shop/cart-api         no matches for kind "ExternalSecret" in version "external-secrets.io/v1alpha1"
 payments/billing-api  no matches for kind "ExternalSecret" in version "external-secrets.io/v1alpha1"
  … 8 more, same message

The merge request you get.

Illustrative, shaped like the real conversion MR: same title format, same PushSecret pairing, same pre-merge conditions.

Draft

Convert Secret to ExternalSecret: payments/stripe-webhook

from gitops-ai/convert-secret-payments-stripe-webhook into main · 3 files · +34 −0
category:securitysource:flux_migrationrisk:mediumxrev::LGTM
OverviewBefore mergeAfter mergeChanges
Summary

Secret payments/stripe-webhook exists in the cluster with no ExternalSecret and no manifest in git. It cannot be recreated if the namespace is rebuilt, and rotating it means editing the cluster by hand. This MR makes Vault its source of truth.

What is in this MR
  • An ExternalSecret with the same name and the same two keys, via ClusterSecretStore vault (the only store in this cluster).
  • A PushSecret that copies the current value into Vault once, on merge (updatePolicy: IfNotExists, deletionPolicy: None).
  • Both files wired into apps/payments/kustomization.yaml.
Could not verify

The assistant reads Secret metadata only and cannot see the PushSecret's sync status. Check it after merge (below).

  • The ClusterSecretStore's Vault role may write payments/stripe-webhook (the PushSecret needs write once).
  • No other process writes this Secret in the cluster (it will be owned by the ExternalSecret from now on).
After merge, expect
  • kubectl -n payments get pushsecret stripe-webhook: Synced.
  • kubectl -n payments get externalsecret stripe-webhook: SecretSynced, same two keys.
  • The Secret now carries an owner reference to the ExternalSecret. Its content is unchanged, so pods do not restart.
Note on deletion

With creationPolicy: Owner, deleting this ExternalSecret later also deletes the Secret. deletionPolicy: Retain does not prevent that.

+ apps/payments/externalsecret_stripe-webhook.yaml   (new, 17 lines)
+ apps/payments/pushsecret_stripe-webhook.yaml       (new, 15 lines)
  apps/payments/kustomization.yaml
+   - externalsecret_stripe-webhook.yaml
+   - pushsecret_stripe-webhook.yaml
R
independent reviewer second opinion, no pipeline context

Key names match the live Secret, store name matches the cluster's only ClusterSecretStore, no value appears in the diff. LGTM.

What keeps it honest.

Secret changes fail quietly and late. Most of the rules below exist because of a failure with exactly that shape.

Never reads a live valueCluster Secrets are listed by name, type and key names only. Secret-shaped content found in git is scrubbed before anything reaches the model.
No empty overwriteAn ExternalSecret over a path that may be empty would blank a working Secret. It ships with a PushSecret or a populate-first condition, and certificates go to review.
One ownerA committed Secret is deleted in the same MR that adds its ExternalSecret, and only the document for that Secret, not its neighbours in the file.
Charts keep their SecretsSecrets a chart's bootstrap Job writes are never converted, even after the Job itself is gone.
Your opt-out winsA skip annotation on the live Secret or its manifest stops conversion for that Secret.
Says when it takes effectEvery Secret or ConfigMap change states how it reaches the pod (restart, checksum annotation, reloader or nothing) and what you need to do.

Where this stands.

Live
  • Secret → ExternalSecret conversion with Vault seeding via PushSecret
  • Committed Secrets replaced; non-credentials moved to ConfigMaps
  • Credentials in Helm values externalized during migration and adoption
  • Credential scan and ExternalSecret checks on every diff; missing-Secret references held
  • Failed ExternalSecret syncs linked to the workloads that consume them
In development
  • One issue per cause when many releases fail on an unserved ExternalSecret API version (built, switched off)
Roadmap
  • Pre-upgrade sweep for stored defaults a new operator version removes
  • Templated ExternalSecrets (per-key Vault entries assembled into a config file in git) instead of whole config blobs
  • Key-level consumer tracing on every Secret change
← Previous storyWhat leaves your network