External Secrets Operator 2.12: The 29-Hour Revert, the SA-Token Guardrail, and the Pulumi ESC Repair

Sources

External Secrets Operator v2.12.0 — tagged 2026-10-06, chart published the same morning — reads like a cleanup release until you line up the PRs. Underneath the dependency bumps sit four changes that each close a hole a production team could actually fall through: a Helm chart override contract that broke within the v2.11.0 git tag and got reverted 29 hours later, a controller-side guardrail that stops ESO from writing privileged kubernetes.io/service-account-token Secrets assembled from individually-permitted template inputs, a Pulumi ESC PushSecret path that was destructively rewriting environment definitions on every push, and chart-level warnings for the oldest multi-replica footgun in the operator's history. We covered the project's origin story in the External Secrets Operator OSS tale and the broader tooling landscape in our secrets-management guide; this page is about what changed in the bits.

ChangePRClassWho feels it
Revert of global.imageRegistry chart change#6996Breaking (chart)Anyone vendoring the v2.11.0 git tag chart
Validate assembled Secret before writing#7065Security fixEveryone; templates that composed SA-token Secrets now fail
No templated values in error strings#7021Breaking (error surface)Debug workflows that read provider values from events
fail template function removed#7019Breaking (templates)Templates using fail for validation UX
Chart warnings for replicas > 1 without leader election#7036GuardrailHA deployments that were racing all along
rbac.leaderElection.create toggle#6950Chart hygieneSingle-replica installs carrying dead RBAC
Conjur Cloud authenticators (IAM, Azure, GCP)#6729FeatureCyberArk Conjur users on cloud IAM
Password generator prefix/suffix#7028FeatureAnyone templating credential shapes per-ExternalSecret
Pulumi ESC PushSecret rewrite fix#6933Data-integrity fixPulumi ESC users — read this one first
NoSecretErr semantics for akeyless + AWS ACM#7013, #7015Deletion-policy fixAnyone relying on deletionPolicy: Delete with those providers

The 29-Hour Chart Revert

The release's headline entry is a revert, and the timeline is the story. On 2026-09-18, PR #6983 (add global.imageRegistry to the chart) merged and shipped inside the v2.11.0 git tag the same day. The change was reasonable on its face: introduce a global.imageRegistry: ghcr.io prefix value, strip the registry host out of the per-image repository values, and let air-gapped users point everything at a mirror by flipping one key. The old global.repository stayed, marked deprecated.

The contract it silently broke: every existing values file that set image.repository — with the registry host, which is exactly what mirror users and the chart's own e2e suite do — now got double-prefixed. The v2.11.0 helper built the reference as registry + "/" + repository, so ghcr.io/external-secrets/external-secrets plus the default imageRegistry: ghcr.io produced ghcr.io/ghcr.io/external-secrets/external-secrets. The cert-controller in the e2e run went into CrashLoopBackOff on an image reference that does not exist. To make a full-path repository work at all you had to also pass imageRegistry: "" — a precedence wart nobody would discover until 2 a.m.

2026-09-18 07:27 UTC  PR #6983 merges: global.imageRegistry + host-less repositories
2026-09-18 14:38 UTC  v2.11.0 git tag cut — imageRegistry is IN the tag
2026-09-18 -> 09-19   e2e cert-controller CrashLoopBackOff: ghcr.io/ghcr.io/... refs
2026-09-19 19:43 UTC  PR #6996 reverts commit 9605c4e — "easier to revert and revise"
2026-09-21 12:12 UTC  helm-chart-2.11.0 PUBLISHED — clean values, revert included
2026-10-06 08:44 UTC  v2.12.0 tagged — the revert finally ships in a stable release
2026-10-06 11:40 UTC  helm-chart-2.12.0 published to charts.external-secrets.io

Here is the nuance that matters for your upgrade planning: the published helm-chart-2.11.0 (2026-09-21) already contained the reverted, clean values — we fetched the chart tag and confirmed: full-path ghcr.io/external-secrets/external-secrets repositories, no imageRegistry key. Consumers of the official chart repo were never exposed. The blast radius was the ~70-hour window between the v2.11.0 git tag and the chart publication, during which anyone vendoring the chart from the git tag (GitOps repos that pull chart source rather than the Helm repository) inherited the broken override contract. If your values were vendored from that tag, global.imageRegistry is dead weight in 2.12.0 — your per-image repository values must carry the registry host again.

Maintainer @evrardj-roche's revert message is the operational lesson of the whole incident: "For me, there is a problem in precedence to make this smooth, so it's easier to revert and revise." The tag-then-test order is worth internalizing — the v2.11.0 git tag was a release artifact, not a verified artifact. The chart publication gate, a separate manual PR (#6997), is what kept the break from reaching chart consumers.

v2.11.0 git tag (the broken 62-hour window):
  reference = global.imageRegistry + "/" + image.repository
             ^ ghcr.io (default)   ^ "ghcr.io/external-secrets/external-secrets"
             => "ghcr.io/ghcr.io/external-secrets/external-secrets"  (does not exist)

v2.12.0 (and the published 2.11.0 chart):
  if global.repository set  -> use it as-is (deprecated, full path)
  otherwise                 -> use image.repository verbatim
             ^ "ghcr.io/external-secrets/external-secrets"  (exists)
# Case 1 — you run defaults (ghcr.io): nothing to do
helm upgrade external-secrets external-secrets/external-secrets \
  --version 2.12.0 --reuse-values

# Case 2 — private mirror: full path per image, host included
helm upgrade external-secrets external-secrets/external-secrets \
  --version 2.12.0 \
  --set image.repository=my.io/org/external-secrets \
  --set webhook.image.repository=my.io/org/external-secrets \
  --set certController.image.repository=my.io/org/external-secrets

# Case 3 — you vendored values from the v2.11.0 GIT TAG (Sep 18-21 window):
# strip every global.imageRegistry line; repositories must carry the host again
grep -R "imageRegistry" environments/   # any hit is now dead configuration

The Guardrail: Assembled-Secret Validation

The security-relevant change is PR #7065, and the problem statement in the PR is unusually candid: template validation examined the declared template fields before rendering, but never validated the final Secret assembled from templates plus retained fields plus ExternalSecret metadata. Individually permitted inputs could compose into something ESO must never own — specifically a kubernetes.io/service-account-token typed Secret carrying a kubernetes.io/service-account.name annotation, or a bootstrap-token Secret. The fix moved validation to the only place the full truth exists: immediately before persistence.

var (
    errServiceAccountTokenSecret = errors.New(
        "service-account-token Secret with service-account-name annotation is not allowed")
    errBootstrapTokenSecret = errors.New(
        "bootstrap-token Secret is not allowed")
)

func validateSecretCandidate(secret *v1.Secret) error {
    //nolint:exhaustive // Only the privileged Secret types require special handling.
    switch secret.Type {
    case v1.SecretTypeServiceAccountToken:
        if _, ok := secret.Annotations[v1.ServiceAccountNameKey]; ok {
            return errServiceAccountTokenSecret
        }
    case v1.SecretTypeBootstrapToken:
        return errBootstrapTokenSecret
    }
    return nil
}

The call site is one line in the controller's reconcile path — return nil became return validateSecretCandidate(secret) at the end of template application, after LabelManaged and the data hash annotation are set. That placement is the whole point: static template validation was an allowlist of parts; this is an inspection of the assembled artifact.

Why this matters in practice: service-account-token Secrets project credentials into pods via mounted projections, and bootstrap-token Secrets can admit nodes to the cluster. A compromised or misconfigured upstream vault — or just an over-permissive ExternalSecret template — could previously have ESO stamp a privileged Secret type into existence with ESO's own LabelManaged marker on it. The attack is composition: no single template field is privileged, but type + an annotation + data keys assemble into one. If you run ESO with template freedom exposed to application teams (the common IDP pattern), this check converts a privilege-escalation-by-composition into a reconcile error.

What breaks: if you were deliberately using ESO to manage SA-token-typed Secrets — an edge case, but it exists in patterns that copy legacy token Secrets between clusters — those ExternalSecrets now fail to apply with the explicit error above, and they will keep failing. There is no opt-out flag. The migration vector is to stop having ESO own those objects; Kubernetes projects SA tokens natively and the projected-token flow is the supported path anyway.

The Error Surface Broke On Purpose

Two changes in 2.12.0 make errors less informative by design, and both are labeled breaking by their own author. PR #7021 removes error wrappings that included rendered template content and provider values: "one can leak more details than it should… this means a breaking change in error behaviour, but this is safer." The leak is real — error strings land in ExternalSecret status.conditions, Events, and anything scraping them, so a provider's secret value that failed to render could end up readable in your audit logs and dashboards. The cost: if your debugging workflow was "read the rendered value out of the event message," that workflow is dead. Raise the controller log level (--loglevel=debug, exposed in the chart as extraArgs.loglevel) when you need the detail.

PR #7019 removes the fail function from the sprig template map entirely — the rationale is that a template failure "puts errors into the rest of the controllers" without templating a Secret, serving no purpose. Templates that used fail to reject bad input with a friendly message now fail with a function-not-defined error. That is a worse error message in exchange for a cleaner failure mode. Validation of that shape now belongs upstream of the template — in schema, in policy engines like Kyverno validating your ExternalSecret specs, or in your portal's form layer.

Multi-Replica Footguns Now Shout

Running more than one ESO replica without leader election means every replica reconciles every ExternalSecret concurrently: Secret writes race, provider API quota burns, conditions flap. It has always been wrong; the chart has never told you. PR #7036 — a first contribution — adds NOTES.txt warnings that fire on install and upgrade, with separate checks for the controller and the cert-controller:

controller:     createOperator AND replicaCount > 1 AND NOT leaderElect
                -> "WARNING: replicaCount is N but leaderElect is false.
                   All replicas will reconcile concurrently.
                   Set leaderElect=true when running more than one replica."

cert-controller: certController.create AND NOT webhook.certManager.enabled
                 AND certController.replicaCount > 1 AND NOT leaderElect
                -> same warning, cert-controller variant
replicas: 3, leaderElect: false  (the footgun, now warned at install)
+----------+   +----------+   +----------+
|  ESO #1  |   |  ESO #2  |   |  ESO #3  |
+----+-----+   +----+-----+   +----+-----+
     |              |              |
     +--------------+--------------+
                    v
  all three reconcile the SAME ExternalSecrets: Secret writes race,
  provider API quota burns, status conditions flap, events spam

replicas: 3, leaderElect: true  (one reconciler at a time)
+----------+   +----------+   +----------+
|  leader  |   | standby  |   | standby  |
+----+-----+   +----------+   +----------+
     |
     v   Lease object in coordination.k8s.io
  exactly one active reconciler

Companion change PR #6950 adds rbac.leaderElection.create (default true, nested under rbac) so single-replica installs running without leader election can drop the namespaced leader-election Role and RoleBinding instead of carrying dead RBAC. It only takes effect when rbac.create: true. Small, but it closes the compliance-audit question of "why does this operator hold lease permissions it never uses."

Conjur Cloud Authenticators

PR #6729 brings the Conjur provider to where cloud workloads actually run: three new authenticator options under auth — iam (AWS, via the STS GetCallerIdentity flow), azure (JWT via IMDS or a projected ServiceAccount token), and gcp — alongside the existing apikey and cert flows. The win is ambient credentials: an IRSA-configured pod can authenticate to CyberArk Conjur with no static credential in the cluster at all. The field names come straight from the merged CRD types (account, serviceID, hostId — note the casing — and an optional secretRef block for explicit credentials, omitting it selects the default SDK chain):

apiVersion: external-secrets.io/v1
kind: SecretStore
metadata:
  name: conjur-iam
  namespace: default
spec:
  provider:
    conjur:
      url: https://conjur.example.com
      # caBundle:   # uncomment for self-signed certs
      auth:
        iam:
          account: myorg
          serviceID: prod          # authn-iam webservice ID in Conjur policy
          hostId: data/myapp/123456789012/MyRole
          # omit secretRef to use ambient credentials (IRSA, instance profile)
          # secretRef:
          #   accessKeyIDSecretRef:
          #     name: aws-creds
          #     key: access-key-id
          #   secretAccessKeySecretRef:
          #     name: aws-creds
          #     key: secret-access-key
          #   sessionTokenSecretRef:  # only for temporary credentials
          #     name: aws-creds
          #     key: session-token
          # insecure: true  # only when TLS terminates elsewhere (mesh sidecar)

Read the insecure flag's documentation carefully before copying it: the IAM authenticator sends the signed request as a bearer credential, so plain-HTTP Conjur is only acceptable when something else terminates TLS in front of it. The PR also threads a telemetry object through the ConjurClient factory — observability plumbing, invisible until you need it.

Password Generator Prefix and Suffix

PR #7028 adds prefix and suffix to the Password generator, applied after encoding so they stay literal, and not counted toward length. The motivating example in the PR is Garage access keys — GK followed by 24 hex characters — which previously required a template on every consuming ExternalSecret:

apiVersion: generators.external-secrets.io/v1alpha1
kind: Password
metadata:
  name: garage-access-key
spec:
  # 12 random characters, hex encoded to 24 characters
  length: 12
  encoding: hex
  prefix: GK

The design argument in the PR is worth repeating because it draws a correct line: a related request (#6944, closed as not-planned) wanted per-consumer values, which a generator cannot produce — it never sees who invoked it (#4384). A prefix that comes from the target system's credential format is the same for every consumer, so it belongs on the generator. The distinction holds for every credential-shape problem you will meet: format on the generator, consumer identity on the ExternalSecret.

The Pulumi ESC Repair

If you push Secrets to Pulumi ESC, upgrade before you do anything else with this operator. PR #6933 fixes PushSecret on the Pulumi provider rewriting the entire environment definition from the resolved environment. The damage inventory from the PR body: a single-key push dropped the environment's imports:, pulumiConfig:, environmentVariables:, and files: blocks; fn::secret, fn::open::* and ${...} content was written back as resolved literals — dynamic credentials frozen, encrypted values stored in plaintext. Any environment more complex than a flat key/value list was damaged by one push. The root cause sat in the provider's PushSecret implementation resolving the environment and writing the resolution back as the definition — a read-path artifact used as a write-path source.

There is no recovery advice that makes this retroactive: environments corrupted by previous pushes are corrupted; the fix only stops future damage. Check your ESC environments for resolved literals and plaintext where you expected references, especially anything pushed between the provider's introduction and this release.

Smaller Fixes That Matter

Hidden Costs and Breaking Changes

ChangeWhat actually happensMigration vector
global.imageRegistry gonePer-image repository values must carry the registry host again; mirror configs that relied on the prefix key stop applyingSet full-path image.repository (all three images) or deprecated global.repository; delete any vendored imageRegistry lines
Assembled-Secret validationExternalSecrets producing SA-token-typed Secrets (with the service-account-name annotation) or bootstrap-token Secrets now fail to apply, permanentlyMove those Secrets out of ESO's scope; use Kubernetes' native projected tokens
No templated values in errorsEvent and status messages no longer contain rendered secret data — also no longer contain your debug contextSwitch debugging to controller log level debug via extraArgs.loglevel
fail removed from templatesTemplates using it error with function-not-defined instead of your messageMove input validation to CRD schema or admission policy (Kyverno) before the template runs
Pulumi ESC PushSecretFixed in 2.12.0 only — every push on 2.11.0 or earlier risks the destructive rewriteUpgrade first, then audit environments for frozen credentials and plaintext
Chart NOTES warningsCosmetic at first glance, but they will surface in your CI's helm output — expect new "WARNING" lines on upgrade of misconfigured HA installsTreat each warning as a real bug: set leaderElect=true for any replicaCount > 1

Upgrade mechanics are unremarkable: the chart's appVersion tracks 2.12.0, installCRDs defaults to true, and the operator images ship as ghcr.io/external-secrets/external-secrets:v2.12.0 with -ubi and -ubi-boringssl flavours alongside the distroless default. If your policy requires UBI images, that is a values change (image.flavour: ubi), not a new install.

Verdict

Skip this release only if you do not run External Secrets Operator. For everyone else the split is sharp: Pulumi ESC users should treat 2.12.0 as an emergency — the PushSecret fix stops ongoing definition corruption, and the audit for past damage is on you. Teams exposing template freedom to application teams get the SA-token composition guardrail, which converts a real escalation path into a loud reconcile failure; accept the breaking edge it carries and migrate the rare legit case now rather than at gunpoint during an incident. Multi-replica installs without leader election finally get told they have been racing, which is the cheapest incident prevention in the release. The chart revert is cleanup for most and urgent only for anyone who vendored values from the v2.11.0 git tag in the 62-hour window — grep for imageRegistry and be done. The two deliberate error-surface regressions are the honest cost of the rest: less debuggability in exchange for not leaking provider data into events. That is the correct trade, and you should plan your debugging workflow around it rather than fighting it.

Credit Where Due

@evrardj-roche carried the critical path of this release — the 29-hour revert (#6996), the assembled-Secret validation (#7065), the error-templating removal (#7021), the fail cleanup (#7019), and the e2e default-vars fix (#6995). @alekc shipped the original imageRegistry change (#6983), took the revert gracefully, and still landed the Password generator prefix/suffix (#7028) plus the UUID documentation (#7027). @hdabrowski did the Conjur Cloud authenticators (#6729). @kriszkern added the RBAC toggle (#6950). Five contributors made their first merged PRs in this release — @anikievev (replica warnings, #7036), @KR-Ravindra (the Pulumi ESC fix, #6933), @wankhede04 (both NoSecretErr fixes, #7013 and #7015), @guanchzhou (Orphan fan-out docs, #6885), and @PCDattt (Vault metadata docs, #6982). A minor release where a third of the substantive changes come from first-time contributors is the health signal you want from a CNCF project — and one where the second-most-important fix (Pulumi ESC) came from a user who hit the bug in production rather than from the core team.