← Back to list

Why Your JWT Tokens Are Valid Everywhere — and How the aud Claim Fixes That

A practical guide to JWT audience claims in Keycloak: what the spec requires, what CVE-2026–37979 patched, and how to configure it right.

Florian Röser in devsecops-community · 2026-06-09 05:49 · 1 claps · 7.7 min read paywalled
#keycloak #jwt #oauth2 #security
Open on Medium ↗

Why Your JWT Tokens Are Valid Everywhere — and How the aud Claim Fixes That

A practical guide to JWT audience claims in Keycloak: what the spec requires, what CVE-2026–37979 patched, and how to configure it right.

The default that leaves tokens too permissive

By default, a JWT access token issued by Keycloak is valid everywhere in your realm. Service A, Service B, Service C — present the token at any of them and it works.

That’s a problem. And it’s exactly what the aud (audience) claim is designed to prevent.

Keycloak 26.6.2 brought this to a head: CVE-2026–37979 was patched by enforcing aud on the introspection endpoint. Services that had skipped audience configuration suddenly started returning 401s.

If that happened to you, this article explains why — and how to fix it properly. But you don’t need a broken upgrade to get value here. If you’ve ever looked at a decoded JWT and wondered what aud is actually for, whether you need it, and how Keycloak is supposed to populate it, this covers that too — from the spec down to the config.

What the aud claim actually is

The aud (audience) claim originates in RFC 7519 §4.1.3 — the JWT spec itself, not OAuth. It identifies the recipients the token is intended for. Per §4.1.3, when aud is present a service MUST reject the token unless it finds its own identifier in the claim.

RFC 9068 — “JWT Profile for OAuth 2.0 Access Tokens” — tightens this further: aud is REQUIRED in access tokens (§2.2), and the resource server MUST validate it (§4). For an access token, then, a missing aud is not a free pass — it's grounds for rejection.

The claim is an access restriction checked at the destination, not a transport hint. Without it, a bearer token is valid everywhere behind your Keycloak. With it, it’s valid only at its named recipients.

Here’s how aud fits alongside the other standard claims — each answers a different question:

  • **iss** — Who issued this token?
  • **sub** — Who is it about?
  • **azp* — Which client requested* it?
  • **audWhich service(s) is it allowed to be used at?**
  • **scope** — What may the bearer do there?

azp and aud are easy to confuse: azp is the sender's identity, aud is the intended recipient(s). They are different parties and trigger different security checks.

What it looks like in practice

Without an Audience mapper — no aud field at all:

{
  "exp": 1780932860,
  "iat": 1780929260,
  "iss": "https://keycloak.my-domain.de/realms/my-realm",
  "sub": "0e71579f-2ef4-4066-8e75-c726b2c0c4a1",
  "typ": "Bearer",
  "azp": "test-service-client",
  "scope": "upstream:api.read"
}

Per RFC 9068 §4, any resource server receiving this token MUST reject it — it cannot verify the token was intended for it.

After adding an Audience mapper (Included Client Audience: upstream-read):

{
  "exp": 1780933103,
  "iat": 1780929503,
  "iss": "https://keycloak.my-domain.de/realms/my-realm",
  "aud": "upstream-read",
  "sub": "0e71579f-2ef4-4066-8e75-c726b2c0c4a1",
  "typ": "Bearer",
  "azp": "test-service-client",
  "scope": "upstream:api.read"
}

aud is now present: the resource server finds its own ID and accepts the token. Note that aud can be a single string or an array — both are valid per RFC 7519 §4.1.3, and your resource servers must handle both forms.

The attack it prevents

A bearer token is exactly that: anyone holding one can use it. The only thing limiting where a stolen or forwarded token works is aud.

Without aud validation, a token issued for Service A can be replayed against Service B behind the same Keycloak realm. A compromised or malicious Service A could take a token a client legitimately sent it, then forward it upstream to impersonate that client against Service B. This is the confused-deputy attack.

With aud, that broadly-valid token becomes a narrowly-scoped one — usable only at its named recipients.

One caveat: a token with multiple audiences is valid at all of them simultaneously. Any one recipient can use it against the others. So multi-audience tokens require mutual trust between those services. Prefer one audience per token where practical.

CVE-2026–37979: what Keycloak actually fixed

The flaw: Keycloak’s introspection endpoint (/realms/{realm}/protocol/openid-connect/token/introspect) authenticated the calling client but never checked whether that client was present in the token's aud. Any confidential client with valid credentials could introspect any valid token and read its claims — even tokens issued for a completely different resource server.

This is particularly dangerous with lightweight access tokens, which deliberately omit claims from the token body and expect you to fetch them via introspection. A malicious client could harvest claims it was never the audience for.

The fix (26.6.2): The introspection endpoint now verifies that the authenticated calling client is present in the token’s aud. If not, it returns:

{ "active": false }

It responds as though the token were invalid, leaking nothing.

CVSS 3.1: 6.5 / Medium (AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:N/A:N). Not catastrophic on its own, but a clear information-disclosure path in multi-tenant or multi-service deployments.

The same release also closed the equivalent leak on the UserInfo endpoint for lightweight access tokens. If you use lightweight tokens, audit both endpoints.

Configuring Keycloak: the Audience mapper pattern

Mental model: the service that will consume or introspect the token must appear in that token’s aud.

As of 26.6.x, Keycloak does not derive aud from request parameters — it doesn't support the resource= parameter from RFC 8707 (issue #14355 is still open, currently milestoned for a later release). The only path that actually populates aud is the Audience protocol mapper, and the supported way to attach it is through a dedicated client scope.

Step 1 — Create a client scope per protected resource

For each backend service (e.g. upstream-api), create a scope that stamps that service into aud:

Client Scopes → Create client scope

  • Name it clearly: aud-upstream-api
  • Type: Default (always included) or Optional (caller opts in per request) — see the decision guide below

Step 2 — Add an Audience mapper

Open the scope → Mappers → Configure a new mapper → Audience

Assign aud-upstream-api as Default (always present) or Optional (included when the client explicitly requests the scope).

Default vs Optional: a quick decision

Use Default when:

  • The client always calls this one resource.
  • You want the simplest config, with the fewest moving parts.

Use Optional when:

  • The client calls several resources situationally.
  • You want the caller to narrow aud per request (scope=aud-x) → tighter, single-audience tokens.

Optional + per-request scope selection is the closest Keycloak gets to RFC 8707’s “one token per resource” ideal, and is preferable for least-privilege.

Your two real-world scenarios

Backend-to-backend (client credentials)

backend-client  ──client_credentials──────▶  Keycloak
                    token.aud = "upstream-api"
backend-client  ──Bearer token────────────▶  upstream-api
upstream-api    ──introspect (own creds)──▶  Keycloak  ✅
  • Assign aud-upstream-api to backend-client (Default if it always calls upstream, Optional if only sometimes).
  • upstream-api must be a confidential client so it can authenticate to the introspection endpoint.
  • Don’t reach for resource= in the token request — Keycloak ignores it.

Frontend SPA (PKCE) → Backend API

spa (public, PKCE)  ──auth code──▶  Keycloak
                       token.aud = "backend-api"
spa  ──Bearer token──────────────▶  backend-api
backend-api validates aud == "backend-api"  ✅
  • Public clients can’t introspect (no secret), but the backend validates aud locally — it still needs to be there.
  • A common mistake: assuming the SPA’s own client ID is automatically the backend’s audience. It isn’t. Add an aud-backend-api scope (Default) to the SPA client explicitly.
  • By default, a Keycloak access token’s aud contains only account — or nothing useful to your API. Never assume it carries the backend's identity without a mapper.

Verify it works

Get a token and inspect it:

TOKEN=$(curl -s \
  -d grant_type=client_credentials \
  -d client_id=backend-client \
  -d client_secret=$SECRET \
  -d scope=aud-upstream-api \
  https://keycloak.example.com/realms/my-realm/protocol/openid-connect/token \
  | jq -r .access_token)
echo $TOKEN

Paste the token into **jwt.io** and check the aud field in the decoded payload. If it's there, the mapper is wired up correctly.

Then confirm introspection passes with 26.6.2’s audience check:

curl -s -u upstream-api:$UPSTREAM_SECRET \
  -d token=$TOKEN \
  https://keycloak.example.com/realms/my-realm/protocol/openid-connect/token/introspect \
  | jq .active
# expect: true

Troubleshooting: scope assigned but aud still missing

The symptom: upstream rejects the request even though the client scope is assigned and the token request succeeds.

Decode the token at jwt.io first. All three root causes look the same from the outside:

  1. Mapper missing — the scope is assigned, but the Audience mapper was never added to it. aud simply never gets written. Go to the scope's Mappers tab and check — it should not be empty.
  2. UUID in the mapper instead of client IDaud is present but contains something like a3f2... instead of upstream-api. Keycloak's UI pre-fills the UUID in some flows. Edit the mapper and replace it with the actual client ID.
  3. Optional scope not requested — the scope is Optional and the client doesn’t include scope=aud-upstream-api in the token request. The mapper never fires. Either switch the scope to Default or ensure the client sends it explicitly.

The token payload always tells you which case you’re in.

Migration: if 26.6.2 breaks existing integrations

If upgrading immediately isn’t an option, two escape hatches exist:

  • Server-wide: OIDC provider config option allow-token-introspection-without-audience-check
  • Per-client: Clients → (client) → Advanced → OpenID Connect Compatibility Modes → “Allow token introspection without audience check”

Treat these as a bridge, not a fix. Both are deprecated and scheduled for removal in Keycloak 27.0.0 (issue #49168). The only correct long-term solution is to put the introspecting service into the token’s aud.

Security considerations

  • Validate aud locally on every resource server that receives a JWT — don't rely solely on introspection.
  • Prefer single-audience tokens: one token per target service, requested with the appropriate scope. Multi-audience tokens extend trust to all listed recipients simultaneously.
  • Introspecting clients must be in aud as of Keycloak 26.6.2. Audit any service that calls introspection and ensure it's correctly mapped.
  • Lightweight access tokens: aud validation matters even more here — introspection is the only path to claims, so the audience check is the only gate.

Wrapping up

aud is the difference between a bearer token that works everywhere behind your realm and one that works only where it's meant to. Keycloak won't add it for you from resource= — you wire it in deliberately, one client scope per protected resource, and validate it at every consumer. Get that right and CVE-2026-37979's introspection check stops being a breakage and starts being a backstop.

Found this helpful? Follow me for more deep dives into authentication, authorization, and API security — and if you’ve hit a Keycloak aud footgun I didn't cover, drop it in the responses.

Sources


메타데이터
post_id
d0acb5e68a3c
slug
why-your-jwt-tokens-are-valid-everywhere-and-how-the-aud-claim-fixes-that-d0acb5e68a3c
url
https://medium.com/@vgzxkgmrpn/why-your-jwt-tokens-are-valid-everywhere-and-how-the-aud-claim-fixes-that-d0acb5e68a3c
canonical_url
https://medium.com/@vgzxkgmrpn/why-your-jwt-tokens-are-valid-everywhere-and-how-the-aud-claim-fixes-that-d0acb5e68a3c
author_url
https://medium.com/@vgzxkgmrpn
status
ok
fetched_at
2026-06-11 17:15:47