In KEDA 2.20.2 and earlier, anyone allowed to create a TriggerAuthentication in their own namespace can make the KEDA operator send them its Kubernetes service account token. That token can read every Secret in the cluster. The bug, CVE-2026-77524 (GHSA-637c-6jxx-4rwm, CVSS 9.9), was fixed in KEDA 2.21.0 on 23 September 2026, and the CVE record was published on 14 October. It's an incomplete fix of CVE-2025-68476 from last December. There's no 2.20 patch release, and the fix changes how every service-account-token integration in KEDA is configured.
This post is for platform teams running KEDA on shared clusters, where namespace owners or CI pipelines can create KEDA resources. It explains the bug without the exploit steps, why December's fix didn't stop it, what 2.21 changes and what that breaks, and why your scanner probably isn't flagging it. It includes a script that checks a cluster export for exposure.
The short answer
Upgrade the KEDA operator to 2.21.0, after reading KEDA's migration guide, because the fix is a breaking change for HashiCorp Vault Kubernetes authentication and every boundServiceAccountToken integration. Don't use --service-account-token-mode=legacy except briefly during migration, because it restores the vulnerable behaviour. Until you upgrade, remove create and update on triggerauthentications.keda.sh from anyone you wouldn't give cluster-wide Secret access. Scanners only started flagging it late on 15 October (UTC), more than three weeks after the fix, so a clean scan from before then told you nothing.
How the bug works
KEDA can read scaler credentials from HashiCorp Vault. A TriggerAuthentication sets the Vault server in spec.hashiCorpVault.address and can choose authentication: kubernetes, which logs in to Vault with a Kubernetes service account token. If the object has no credential block, KEDA 2.20 used the operator pod's own token, read from the standard path /var/run/secrets/kubernetes.io/serviceaccount/token.
Nothing checked the address. A tenant could point it at a server they ran in their own namespace and reference the TriggerAuthentication from a ScaledObject. The operator then sent its own token to that server as the body of a Vault login request. The advisory's reproduction on a kind cluster running unmodified KEDA 2.20.0 shows the tenant reading back the token, authenticating as system:serviceaccount:keda:keda-operator, reading a Secret in kube-system and creating a Job there. We aren't repeating the steps.
What makes this serious is the operator's permissions. According to the advisory, the token has cluster-wide get on all resources, which includes every Secret in every namespace. It can also create Jobs and HorizontalPodAutoscalers in any namespace, update scale subresources, and update validatingwebhookconfigurations and apiservices. Reading Secrets alone usually turns up a more privileged token, so the advisory treats this as namespace admin to cluster admin.
The preconditions are ordinary for a multi-tenant cluster: the tenant needs create on triggerauthentications.keda.sh and scaledobjects.keda.sh in its namespace, plus the usual edit rights. A namespace admin role written as verbs: ["*"] on resources: ["*"] grants all of that. KEDA's release manifest doesn't add its resources to the built-in edit or admin roles, so who has these rights depends on how your cluster grants them.
Why December's fix didn't hold
CVE-2025-68476 (GHSA-c4p6-qg4m-9jmr), fixed in 2.17.3 and 2.18.3 on 22 December 2025, used the same field. A tenant could set credential.serviceAccount to any file path on the operator's node, and KEDA would send that file's contents to the Vault address. The fix checked what was in the file: it had to be a JWT whose subject starts with system:serviceaccount:.
That check stopped tenants reading /etc/passwd or arbitrary keys. The operator's own token passes it by design, since it's exactly the kind of file the check was written to allow. The destination still wasn't validated, and the default credential was still the operator's own identity. The first fix restricted what could be sent without asking where it was going or whose it was.
Incomplete fixes like this one are common enough that it's worth asking one question of any security fix you review: does it remove the capability, or just one way of using it?
What 2.21 changes
The fix is commit 1e21cc7, merged by Jan Wozniak. It doesn't try to validate tenant input further. It makes sure any token KEDA sends is useless against the Kubernetes API:
- Audience enforcement by default. A new operator flag,
--service-account-token-mode, defaults toenforce-audience. Tokens KEDA reads from a file must carry an approved audience. Tokens it mints for a named service account must have an exact audience mapping inKEDA_SERVICE_ACCOUNT_TOKEN_AUDIENCES, or minting fails. KEDA's guidance is to choose audiences the API server doesn't accept, so a leaked token can only be used by its intended receiver. - A separate token for Vault. The default Vault login token is now
/var/run/secrets/keda-vault/token, a projected token for the operator's service account with the audiencevaultin the 2.21 Helm chart. The operator's API token stays where it is, and is no longer the default. - An optional destination allowlist.
KEDA_OUTBOUND_FILTERcan restrict Vault addresses inwarnorenforcemode, matching on scheme and host. It's off by default. - A legacy escape hatch.
--service-account-token-mode=legacyrestores the old behaviour for the whole operator, with warnings at startup and on every use.
The design choice is worth noting. Tenants can still point KEDA at their own server, unless you turn on the allowlist. What they collect is a token the API server will reject.
What the upgrade breaks
KEDA's 2.20 to 2.21 migration guide is long, and it needs to be. The points most likely to catch teams out:
- It's not just Vault. Every
boundServiceAccountTokenintegration needs an audience mapping, including the Metrics API, Prometheus, Loki and Datadog Cluster Agent scalers. Without one, those scalers fail while the operator stays healthy, so autoscaling quietly stops for the affected workloads. - Receivers must accept the new audience. A Prometheus or Loki endpoint behind kube-rbac-proxy needs
--auth-token-audiencesset to match. The guide says Datadog Cluster Agent 7.83.2 doesn't expose that setting, and gives alternatives. - Vault needs its own reviewer credential. The Vault role has to accept the
vaultaudience, and Vault's TokenReview call needs a separate, API-valid credential. The guide says not to use the new login token for it. - Leave
credentialout entirely. To use the new default file, omit the wholecredentialobject. Keeping the old API-token path, or writingcredential: {}, isn't the same. - Plain manifest installs. The guide's settings live in the Helm chart. The
keda-2.21.0.yamlrelease manifest we checked doesn't add thekeda-vaulttoken projection, so manifest users relying on Vault Kubernetes auth need to add one or point eachTriggerAuthenticationat their own projected token.
Why your scanner is quiet
KEDA published its repository advisory with the fix on 23 September. For more than three weeks, GitHub's global advisory database had no record of CVE-2026-77524, and OSV returned nothing for github.com/kedacore/keda/v2 at 2.20.2. GitHub reviewed the advisory at 20:19 UTC on 15 October, mapping it to github.com/kedacore/keda/v2 below 2.21.0, and OSV listed it within minutes. Until then, container image scanners that read Go binaries had nothing to match.
One detail may have been part of the delay. The repository advisory names the affected package as github.com/kedacore/keda, while KEDA's Go module path, and the package named in the December advisory, is github.com/kedacore/keda/v2. GitHub's reviewed entry lists both. We can't say whether that caused the delay. Either way, this is another case where a clean scan can mean the vulnerability data hasn't caught up.
Check a cluster
This script reads three JSON exports you take with kubectl, so it needs no cluster credentials of its own. It reports the operator version and token mode, every TriggerAuthentication and ClusterTriggerAuthentication that makes KEDA send a service account token, and which non-system subjects can create or change those objects. It uses only the Python standard library.
#!/usr/bin/env python3
"""Audit a cluster for KEDA CVE-2026-77524 (GHSA-637c-6jxx-4rwm) exposure.
Reads JSON exported with kubectl, so it needs no cluster access of its own:
kubectl get deploy -A -l app=keda-operator -o json > keda.json
kubectl get triggerauthentications,clustertriggerauthentications -A -o json > auth.json
kubectl get roles,clusterroles,rolebindings,clusterrolebindings -A -o json > rbac.json
python3 keda_77524_audit.py keda.json auth.json rbac.json
Reports: the operator version and token settings, every TriggerAuthentication that
makes KEDA present a service account token (Vault Kubernetes auth or
boundServiceAccountToken), and which non-system subjects can create or change them. It doesn't resolve
aggregated ClusterRoles or impersonation, so treat its RBAC list as a minimum.
Standard library only.
"""
import json, re, sys
OPERATOR_TOKEN = "/var/run/secrets/kubernetes.io/serviceaccount/token"
FIXED = (2, 21, 0)
def load(path):
with open(path) as f:
return json.load(f).get("items", [])
def version(image):
m = re.search(r":v?(\d+)\.(\d+)\.(\d+)", image or "")
return tuple(int(x) for x in m.groups()) if m else None
def check_operator(items):
exposed = False
for d in items:
for c in d["spec"]["template"]["spec"]["containers"]:
if "keda" not in c.get("image", ""):
continue
v = version(c["image"])
args = " ".join(c.get("args", []) + c.get("command", []))
env = {e["name"]: e.get("value", "") for e in c.get("env", [])}
mode = re.search(r"--service-account-token-mode[= ](\S+)", args)
mode = mode.group(1) if mode else "enforce-audience (default)"
ns = d["metadata"]["namespace"]
print(f"operator {ns}/{d['metadata']['name']}: {c['image']}")
if v is None:
print(" [?] can't read a version from the image tag; check the digest by hand")
exposed = True
elif v < FIXED:
print(f" [VULNERABLE] {'.'.join(map(str, v))} < 2.21.0, no backport exists")
exposed = True
else:
print(f" [ok] {'.'.join(map(str, v))} includes the fix")
if mode.startswith("legacy"):
print(" [VULNERABLE] --service-account-token-mode=legacy re-opens the bug")
exposed = True
else:
print(f" [ok] token mode: {mode}")
filt = env.get("KEDA_OUTBOUND_FILTER", "")
if "enforce" in filt:
print(" [ok] Vault outbound allowlist enforced")
else:
print(" [info] Vault outbound allowlist not enforced (default off)")
return exposed
def check_auth(items):
risky = []
for a in items:
spec, meta = a.get("spec", {}), a["metadata"]
name = f"{a['kind']} {meta.get('namespace', '(cluster)')}/{meta['name']}"
vault = spec.get("hashiCorpVault") or {}
if vault.get("authentication") == "kubernetes":
cred = vault.get("credential")
if cred is None:
src = "default token (the operator's API token before 2.21)"
elif cred.get("serviceAccountName"):
src = f"minted for SA {cred['serviceAccountName']}"
elif cred.get("serviceAccount"):
src = "operator API token" if cred["serviceAccount"] == OPERATOR_TOKEN else f"file {cred['serviceAccount']}"
else:
src = "empty credential (not the 2.21 default file)"
risky.append((name, f"Vault k8s auth -> {vault.get('address')} using {src}"))
if spec.get("boundServiceAccountToken"):
for b in spec["boundServiceAccountToken"]:
risky.append((name, f"boundServiceAccountToken for SA {b.get('serviceAccountName')}"))
print(f"\n{len(risky)} authentication object(s) that send a service account token:")
for n, why in risky:
print(f" {n}: {why}")
return risky
def rule_allows(rule):
groups = rule.get("apiGroups", [])
res = rule.get("resources", [])
verbs = rule.get("verbs", [])
return (("keda.sh" in groups or "*" in groups)
and ("triggerauthentications" in res or "*" in res)
and any(v in verbs for v in ("create", "update", "patch", "*")))
def check_rbac(items):
roles = {}
for r in items:
if r["kind"] in ("Role", "ClusterRole") and any(rule_allows(x) for x in r.get("rules") or []):
roles[(r["kind"], r["metadata"].get("namespace"), r["metadata"]["name"])] = True
who = []
for b in items:
if b["kind"] not in ("RoleBinding", "ClusterRoleBinding"):
continue
ref = b["roleRef"]
ns = b["metadata"].get("namespace")
key = (ref["kind"], ns if ref["kind"] == "Role" else None, ref["name"])
if key not in roles:
continue
scope = f"namespace {ns}" if b["kind"] == "RoleBinding" else "ALL namespaces"
for s in b.get("subjects") or []:
sname = f"{s['kind']} {s.get('namespace', '') + '/' if s.get('namespace') else ''}{s['name']}"
if s["name"].startswith("system:") or s["name"] == "cluster-admin":
continue
who.append((sname, scope, ref["name"]))
print(f"\n{len(who)} non-system subject(s) can create or change TriggerAuthentications:")
for s, scope, role in sorted(set(who)):
print(f" {s} in {scope} (via {role})")
return who
def main():
if len(sys.argv) != 4:
sys.exit(__doc__)
exposed = check_operator(load(sys.argv[1]))
risky = check_auth(load(sys.argv[2]))
who = check_rbac(load(sys.argv[3]))
print()
if exposed and who:
print("EXPOSED: a vulnerable operator plus tenants who can create or change TriggerAuthentications.")
sys.exit(1)
if exposed:
print("VULNERABLE VERSION: no non-system subject found that can create TriggerAuthentications; upgrade anyway.")
sys.exit(1)
print("Operator is fixed. Review the token users above before and after the 2.21 migration.")
if __name__ == "__main__":
main()
We ran it on 15 October against a fixture cluster with KEDA 2.20.2, a Vault Kubernetes auth object using the default credential, a Prometheus token integration, a cluster-wide Vault auth pinned to the operator token, and three ways of granting TriggerAuthentication rights:
operator keda/keda-operator: ghcr.io/kedacore/keda:2.20.2
[VULNERABLE] 2.20.2 < 2.21.0, no backport exists
3 authentication object(s) that send a service account token:
TriggerAuthentication payments/vault-auth: Vault k8s auth -> https://vault.internal.example:8200 using default token (the operator's API token before 2.21)
TriggerAuthentication analytics/prom-auth: boundServiceAccountToken for SA prom-reader
ClusterTriggerAuthentication (cluster)/shared-vault: Vault k8s auth -> https://vault.internal.example:8200 using operator API token
3 non-system subject(s) can create or change TriggerAuthentications:
Group team-a-admins in namespace team-a (via tenant-admin)
ServiceAccount team-a/argocd-deployer in namespace team-a (via tenant-admin)
User dana@example.com in namespace analytics (via keda-autoscaling-editor)
EXPOSED: a vulnerable operator plus tenants who can create or change TriggerAuthentications.
exit 1
The same cluster after an upgrade that kept legacy mode is still exposed:
operator keda/keda-operator: ghcr.io/kedacore/keda:2.21.0
[ok] 2.21.0 includes the fix
[VULNERABLE] --service-account-token-mode=legacy re-opens the bug
[info] Vault outbound allowlist not enforced (default off)
3 authentication object(s) that send a service account token:
TriggerAuthentication payments/vault-auth: Vault k8s auth -> https://vault.internal.example:8200 using default token (the operator's API token before 2.21)
TriggerAuthentication analytics/prom-auth: boundServiceAccountToken for SA prom-reader
ClusterTriggerAuthentication (cluster)/shared-vault: Vault k8s auth -> https://vault.internal.example:8200 using operator API token
3 non-system subject(s) can create or change TriggerAuthentications:
Group team-a-admins in namespace team-a (via tenant-admin)
ServiceAccount team-a/argocd-deployer in namespace team-a (via tenant-admin)
User dana@example.com in namespace analytics (via keda-autoscaling-editor)
EXPOSED: a vulnerable operator plus tenants who can create or change TriggerAuthentications.
exit 1
The three authentication objects are your migration list. Each needs an audience decision before the upgrade, or its scaler will stop working after it.
What to do
- Find your version and your token users. Run the exports above, or check the operator image tag directly.
- Restrict who can create or change
TriggerAuthenticationobjects today, especially through wildcard namespace-admin roles and CI service accounts. This was also the only workaround for CVE-2025-68476. - Plan the 2.21 upgrade around the migration guide. Map an audience to every token integration, configure the receivers, test in a non-production cluster, then upgrade the chart and operator together.
- Turn on
KEDA_OUTBOUND_FILTERinenforcemode with your real Vault addresses. It's off by default, and it closes the destination half of the problem. - Look for past abuse. List
TriggerAuthenticationobjects whose Vault address isn't one of your Vault servers, and check the audit log for thekeda-operatorservice account reading Secrets or creating Jobs outside its usual pattern.
The fair view
The trigger is narrow. It needs a tenant who can create KEDA authentication objects, which many single-team clusters don't have. Clusters without that kind of tenant aren't exposed until someone is given those rights. The maintainers fixed the class of bug, not just the path: a token scoped to its receiver is useless to whoever steals it, whatever the next variant looks like. The advisory credits Artem Cherezov (@cherez0ff, working with Claude Opus 4.8), everping, zwindler, b0b0haha and j311yl0v3u as finders, and the-vibe-dev as reporter.
The cost is that a security fix shipped only as a minor release with three breaking changes. Teams that hold back for that reason stay on 2.20.2, with no scanner telling them why they shouldn't. For an operator with cluster-wide Secret access, it's worth asking which of its credentials tenants can influence. Our post on the security implications of Kubernetes operators covers that review more broadly.