goauthentik / goauthentik/authentik

`aud` of the ID token can be overwritten by a scope mapping, which violates OIDC Core 3.1.3.7

Open
#25,235 0 comments 0 reactions 1 assignee Claimed by @dewi-tik View on GitHub
bug/confirmed
Dominant language
Python
Stars
25.6k
Forks
2k
Avg merge
1d 2h
Merged PRs (30d)
659

Description

### Describe the bug

### Description

A scope mapping that returns an `aud` claim sets the audience of **both** the access token and the ID
token. For the access token that is the intended and documented behaviour (#4021) and matches RFC 9068.
For the ID token it produces a token that no OIDC client may accept: OpenID Connect Core 3.1.3.7
requires the client to verify that `aud` contains its own `client_id`, so the provider must set it. Any
standards-compliant client library therefore rejects the sign-in.

Concretely, `Microsoft.IdentityModel` (ASP.NET Core's OpenID Connect handler) fails the sign-in right
after the code exchange with:

```
SecurityTokenInvalidAudienceException: IDX10214: Audience validation failed
```

### Where it comes from

`IDToken.new()` sets the correct value — `id_token.aud = provider.client_id` — but `to_dict()` merges
the mapping result **over** the standard claims:

```python
# authentik/providers/oauth2/id_token.py
def to_dict(self) -> dict[str, Any]:
id_dict = asdict(self)
...
id_dict.pop("claims")
id_dict.update(self.claims) # <-- mapping claims win over aud, and over sub and iss as well
return id_dict
```

So the exposure is wider than `aud`: a scope mapping can also replace `sub` and `iss` in the ID token.
A short allow-list — refusing to let mapping claims overwrite the claims the specs reserve for the
provider — would fix the whole class of problem.

Steering inside the mapping is not a way out. Both tokens come from **one** `IDToken` object —
`IDToken.new(provider, access_token, request)` is encoded once through `to_access_token()` and once
through `to_jwt()` — so the mapping runs a single time, before there is anything to distinguish, and the
`token` object the expression receives is the access token in both cases (confirmed in a debugger: with
`if token.__class__.__name__ != "AccessToken": return {}` in place, both tokens still carry the identical
`aud`). That argument is not part of the documented variable list either — [Expressions][expr] names
`ak_logger`, `requests`, `user`, `request` and "other arbitrary arguments given by the provider".

[expr]: https://docs.goauthentik.io/add-secure-apps/providers/property-mappings/expression/

### How to reproduce

1. Create an OAuth2/OIDC provider (Authorization Code, confidential client) for a web application.
2. Create a scope mapping with any scope name, expression `return {"aud": "my-api"}`, and assign it to
that provider's scopes.
3. Leave *Include claims in id_token* **on**. Turning it off is not an alternative: the flag decides
whether the mapping's claims are collected at all, and both tokens are built from that same object,
so with it off neither token gets them — `aud` falls back to `client_id` in the **access** token as
well and the resource audience is gone.
4. Sign in with any standards-compliant OIDC client (ASP.NET Core, oidc-client-ts, mod_auth_openidc).

### Expected behavior

The ID token's `aud` always contains the provider's `client_id`, regardless of what scope mappings
contribute. A mapping's `aud` should apply to the access token only — or, better, the audience of the
access token should be requestable per RFC 8707 (`resource` parameter), which is the mechanism designed
for exactly this separation and which the MCP authorization specification now requires of authorization
servers.

Failing that, a documented way to tell a mapping which token it is contributing to would at least make
the separation possible from the outside — today the `token` argument exists but is neither documented
nor usable for this.

Independently of the audience question, emitting `typ: at+jwt` on JWT access tokens per RFC 9068 §2.1
would let resource servers reject an ID token presented as a bearer token, which is the standard's own
answer to cross-JWT confusion.

### Screenshots

_No response_

### Additional context

### Workaround

Return both values, so the ID token stays valid and the access token still names the resource:

```python
return {"aud": ["my-api", provider.client_id]}
```

This works (verified), but it weakens the access token's audience from "meant for this resource" to
"meant for this resource and issued to this client". It also makes the **ID token** carry the resource
audience, so a resource server that authorises on issuer, signature and `aud` alone will accept an ID
token as a bearer token. Anyone applying this workaround should keep a second, independent check — a
required `scope`, which authentik puts on the access token only.

### Related

- #4021 — explains setting `aud` from a scope
mapping; closed as `needs_documentation`, without mentioning the effect on the ID token.

### Deployment Method

Kubernetes

### Version

2026.2.2

### Relevant log output

```shell

```

Contributor guide

Open the contributing guide

Assessment

This issue has not been assessed yet.

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.