Safeguard
Vulnerability Analysis•

KEDA CVE-2026-77524: How a TriggerAuthentication Hands Tenants the Operator's Cluster Token

KEDA 2.20.2 and earlier send the operator's cluster token to a tenant-chosen Vault URL. Why December's fix missed it, what 2.21 breaks, and an audit.

Safeguard Security Team
Security Research
13 min read

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 to enforce-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 in KEDA_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 audience vault in 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_FILTER can restrict Vault addresses in warn or enforce mode, matching on scheme and host. It's off by default.
  • A legacy escape hatch. --service-account-token-mode=legacy restores 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 boundServiceAccountToken integration 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-audiences set 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 vault audience, and Vault's TokenReview call needs a separate, API-valid credential. The guide says not to use the new login token for it.
  • Leave credential out entirely. To use the new default file, omit the whole credential object. Keeping the old API-token path, or writing credential: {}, isn't the same.
  • Plain manifest installs. The guide's settings live in the Helm chart. The keda-2.21.0.yaml release manifest we checked doesn't add the keda-vault token projection, so manifest users relying on Vault Kubernetes auth need to add one or point each TriggerAuthentication at 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

  1. Find your version and your token users. Run the exports above, or check the operator image tag directly.
  2. Restrict who can create or change TriggerAuthentication objects today, especially through wildcard namespace-admin roles and CI service accounts. This was also the only workaround for CVE-2025-68476.
  3. 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.
  4. Turn on KEDA_OUTBOUND_FILTER in enforce mode with your real Vault addresses. It's off by default, and it closes the destination half of the problem.
  5. Look for past abuse. List TriggerAuthentication objects whose Vault address isn't one of your Vault servers, and check the audit log for the keda-operator service 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.

Never miss an update

Weekly insights on software supply chain security, delivered to your inbox.

Self-healing security runs on Safeguard.

Your first fix PR is minutes away.

No sales call required, even your agent can complete the purchase over MCP.