Every Kubernetes service account token is a credential for the Kubernetes API unless you say otherwise. Send a pod's token to Vault, a metrics backend or a partner's webhook to prove who the pod is, and that receiver now holds a working API credential for the pod's service account. The fix is the token's audience: request tokens for a named receiver, and make every receiver, including the API server, reject tokens meant for someone else.
This guide is for platform and security engineers who run Kubernetes workloads that authenticate to anything other than the API server. It explains what the audience claim does, how to issue and check audience-bound tokens, one Vault default worth changing, and how to find tokens in your cluster that are being handed out with API audiences. Both scripts below use only the Python standard library.
The short answer
A projected service account token with no audience gets the API server's audience, so it works against the Kubernetes API wherever you send it. For every non-API receiver, request a token with a dedicated audience (audience: vault in a projected volume, or kubectl create token --audience), and make the receiver require that audience. In Vault's Kubernetes auth method, that means setting the role's audience. Without it, Vault accepts tokens of any audience, including API tokens. Then find and remove legacy Secret-based tokens, which have no audience and no expiry.
What the audience claim does
A service account token is a signed JWT. Its aud claim lists who it's for. The Kubernetes documentation says a recipient "must identify itself" with one of those audiences "and otherwise should reject the token".
The API server applies this to itself through the --api-audiences flag. Tokens used against the API must be bound to at least one of those audiences. If the flag isn't set, it defaults to the issuer URL from --service-account-issuer. And when a projected token doesn't name an audience, it "defaults to the identifier of the API server".
So the safe pattern is simple. Give each receiver its own audience, one the API server doesn't accept. A token stolen from that receiver, or sent to the wrong place, can't be used against the cluster.
The incident that prompted this guide shows what happens otherwise. In KEDA CVE-2026-77524, fixed in KEDA 2.21.0, a namespace tenant could make the KEDA operator send its own API token to a fake Vault server and use it to read every Secret in the cluster. KEDA's fix requires tokens with approved, non-API audiences. That's a design you can apply to your own workloads.
The three kinds of token in your cluster
| Token | How you get it | Audience | Lifetime |
|---|---|---|---|
| Automounted token | Added to every pod by default at /var/run/secrets/kubernetes.io/serviceaccount/token | The API server's | Bound to the pod and refreshed by the kubelet; nominally about an hour, but extended up to a year by default (--service-account-extend-token-expiration) |
| Projected token you add | A serviceAccountToken source in a projected volume, or the TokenRequest API | Whatever you set; the API server's if you don't | You choose (minimum 10 minutes, default 1 hour) |
| Legacy Secret token | A Secret of type kubernetes.io/service-account-token | No aud claim at all | Never expires |
Legacy tokens deserve their own clean-up. Kubernetes stopped generating them automatically in 1.24. Since 1.29, auto-generated ones that sit unused are invalidated after a year by default and later purged. Manually created ones remain valid until you delete them.
Inspect a token
The first mode of the script below decodes a token's claims without checking the signature or sending it anywhere. Run it on a pod's automounted token to learn your cluster's API audience:
kubectl exec -n shop deploy/web -- cat /var/run/secrets/kubernetes.io/serviceaccount/token \
| python3 sa_token_audiences.py token
Here it is on a projected token and a legacy token from a test fixture:
$ python3 sa_token_audiences.py token < projected.jwt
sub: system:serviceaccount:shop:web
iss: https://kubernetes.default.svc.cluster.local
aud: ['https://kubernetes.default.svc.cluster.local']
exp: 2026-10-15 18:46 UTC (1.0h lifetime)
bound to pod: web-7d9f
$ python3 sa_token_audiences.py token < legacy.jwt
sub: system:serviceaccount:ci:ci-runner
iss: kubernetes/serviceaccount
aud: (none: legacy Secret-based token)
exp: none, the token never expires
The first token is tied to a pod and, in this fixture, expires within an hour. In many clusters the automounted token is valid for much longer, because of the expiry extension above. Either way, anyone holding it can use it against the API. The second is worse: no audience, no expiry, and valid until someone deletes the Secret.
Issue tokens with a dedicated audience
For a workload that logs in to Vault, add a second projected token next to the default one:
volumes:
- name: vault-token
projected:
sources:
- serviceAccountToken:
audience: vault
expirationSeconds: 600
path: token
containers:
- name: app
volumeMounts:
- name: vault-token
mountPath: /var/run/secrets/vault
readOnly: true
The kubelet refreshes the file before it expires. Point the Vault client at /var/run/secrets/vault/token instead of the default path. For a one-off token, for example in a CI job, request one from the TokenRequest API:
kubectl create token deployer --audience vault --duration 10m
Choose audience names the API server won't accept. Avoid the issuer URL and anything else listed in --api-audiences.
If the workload doesn't call the Kubernetes API at all, also set automountServiceAccountToken: false on the pod or service account. An audience-bound token for Vault is no use to an attacker against the API. The automounted token next to it still is.
Make every receiver check the audience
An audience is only useful if the receiver checks it. Three common receivers:
Vault's Kubernetes auth method. Set audience on each role:
vault write auth/kubernetes/role/payments \
bound_service_account_names=payments \
bound_service_account_namespaces=payments \
audience=vault \
token_policies=payments-read
This matters more than it looks. In the Vault Kubernetes auth plugin's source, a role with no audience passes the token's own audiences to the Kubernetes TokenReview. Any valid token passes, including tokens with the API server's audience. With audience=vault, Vault asks Kubernetes to confirm that specific audience, and an API token is refused.
kube-rbac-proxy in front of a metrics endpoint. Set --auth-token-audiences to the audience you issue for it, and keep its authorization checks.
Your own service. Call the TokenReview API with spec.audiences set to your audience, then check status.audiences in the response. The TokenReview reference notes that if you leave audiences empty, it defaults to the API server's audience. A service that does that is, in effect, asking callers to send it API credentials.
A receiver that calls TokenReview needs its own API credential to do so, usually a service account bound to system:auth-delegator. KEDA's migration guide makes the same point for Vault: don't use the dedicated login token for the review.
Find API-valid tokens being handed out
The audit mode reads kubectl exports and lists every token mount that the API server would accept, apart from the standard automounted token. That covers projected tokens with no audience or with the API audience, mounted somewhere else, and legacy token Secrets mounted into pods. Each of these is usually there to authenticate to something other than the API.
#!/usr/bin/env python3
"""Find Kubernetes service account tokens that would work against the API server
when they're meant for something else.
Two modes, standard library only:
# 1. Inspect one token's claims (no signature check, nothing is sent anywhere)
python3 sa_token_audiences.py token < token.jwt
# 2. Audit exported pods, and optionally Secrets, for API-valid tokens
kubectl get pods -A -o json > pods.json
kubectl get secrets -A --field-selector type=kubernetes.io/service-account-token -o json > sa-secrets.json
python3 sa_token_audiences.py audit pods.json --api-audience https://kubernetes.default.svc [sa-secrets.json]
Get your cluster's API audience by reading the "aud" of any default pod token
with mode 1. A projected token with no "audience" gets that value, so it's an
API credential wherever you send it.
"""
import base64, datetime as dt, json, sys
DEFAULT_MOUNT = "/var/run/secrets/kubernetes.io/serviceaccount"
def claims(token):
part = token.strip().split(".")[1]
part += "=" * (-len(part) % 4)
return json.loads(base64.urlsafe_b64decode(part))
def show_token(raw):
c = claims(raw)
aud = c.get("aud")
print(f"sub: {c.get('sub')}")
print(f"iss: {c.get('iss')}")
print(f"aud: {aud if aud is not None else '(none: legacy Secret-based token)'}")
if "exp" in c:
exp = dt.datetime.fromtimestamp(c["exp"], dt.timezone.utc)
life = (c["exp"] - c.get("iat", c["exp"])) / 3600
print(f"exp: {exp:%Y-%m-%d %H:%M} UTC ({life:.1f}h lifetime)")
else:
print("exp: none, the token never expires")
pod = (c.get("kubernetes.io") or {}).get("pod", {}).get("name")
if pod:
print(f"bound to pod: {pod}")
def audit(pods_path, api_aud, secrets_path=None):
pods = json.load(open(pods_path))["items"]
legacy = set()
if secrets_path:
for s in json.load(open(secrets_path))["items"]:
if s.get("type") == "kubernetes.io/service-account-token":
legacy.add((s["metadata"]["namespace"], s["metadata"]["name"]))
findings, automount = [], 0
for p in pods:
ns, name = p["metadata"]["namespace"], p["metadata"]["name"]
spec = p["spec"]
sa = spec.get("serviceAccountName", "default")
vols = {v["name"]: v for v in spec.get("volumes", [])}
mounts = {}
for c in spec.get("containers", []) + spec.get("initContainers", []):
for m in c.get("volumeMounts", []):
mounts.setdefault(m["name"], set()).add((c["name"], m["mountPath"]))
for vname, v in vols.items():
where = ", ".join(f"{c}:{path}" for c, path in sorted(mounts.get(vname, []))) or "not mounted"
for src in (v.get("projected") or {}).get("sources", []):
t = src.get("serviceAccountToken")
if t is None:
continue
aud = t.get("audience") or api_aud
default = all(path == DEFAULT_MOUNT for _, path in mounts.get(vname, []))
if default and not t.get("audience"):
automount += 1
continue
if aud == api_aud:
why = "no audience set" if not t.get("audience") else "audience is the API server's"
findings.append((f"{ns}/{name}", sa, f"projected token, {why}", where))
sec = (v.get("secret") or {}).get("secretName")
if sec and (ns, sec) in legacy:
findings.append((f"{ns}/{name}", sa, f"legacy token Secret {sec} (no expiry)", where))
print(f"{len(pods)} pods; {automount} mount the standard automounted API token")
print(f"{len(findings)} extra token mount(s) the API server would accept:\n")
for pod, sa, why, where in findings:
print(f" {pod} (sa {sa}): {why}\n mounted at {where}")
return 1 if findings else 0
def main():
a = sys.argv[1:]
if a[:1] == ["token"]:
show_token(sys.stdin.read())
elif a[:1] == ["audit"] and "--api-audience" in a:
i = a.index("--api-audience")
api_aud = a[i + 1]
rest = [x for j, x in enumerate(a[1:], 1) if j not in (i, i + 1)]
sys.exit(audit(rest[0], api_aud, rest[1] if len(rest) > 1 else None))
else:
sys.exit(__doc__)
if __name__ == "__main__":
main()
We ran it on a five-pod fixture cluster. The fixture includes a Vault client with an audience: vault token, a remote-write agent with an audience-less projected token, a CI runner mounting a legacy token Secret, and an exporter that set the API audience explicitly for a partner integration:
python3 sa_token_audiences.py audit pods.json \
--api-audience https://kubernetes.default.svc.cluster.local sa-secrets.json
5 pods; 4 mount the standard automounted API token
3 extra token mount(s) the API server would accept:
observability/remote-writer-0 (sa remote-writer): projected token, no audience set
mounted at main:/var/run/secrets/tokens/remote
ci/runner-x8k2 (sa ci-runner): legacy token Secret ci-runner-token (no expiry)
mounted at main:/etc/ci/token
batch/exporter-2 (sa exporter): projected token, audience is the API server's
mounted at main:/var/run/secrets/partner
The Vault client isn't flagged. The other three are tokens leaving the cluster as API credentials. Each needs a dedicated audience and a receiver configured to require it, or, for the legacy Secret, deletion. The script exits with status 1 when it finds anything, so it can run as a periodic check.
One newer control on the node side
Since Kubernetes 1.33, the ServiceAccountNodeAudienceRestriction feature is in beta and enabled by default. It limits which audiences a kubelet can request through the TokenRequest API to the ones already referenced by pods on that node. That narrows what a compromised node can mint. It doesn't change what your workloads send to receivers.
The fair view
Audiences don't protect a pod from itself. A compromised container can still use its automounted API token. RBAC and turning off automounting deal with that, as covered in our Kubernetes RBAC best practices. Audiences deal with a different problem: tokens that leave the pod by design and end up somewhere you don't control.
There's an operational cost too. Every receiver has to support audience checks, and not all do. KEDA's guide notes that Datadog Cluster Agent 7.83.2 doesn't expose the setting. Migrating means changing the token issuer and the receiver together, which is harder than most security fixes.
It's still the right default. The API server already enforces audiences on every request. Most tokens that leak do so through integrations that never asked for one. Choosing an audience for each receiver turns a cluster credential into a credential for one service.