Tokens and audiences
What gets issued
Section titled “What gets issued”| Format | Lifetime | Single use | |
|---|---|---|---|
| authorization code | opaque, 64 bytes | 10 minutes | yes |
| access token | signed JWT (RS256) | 1 hour | no |
| id token | signed JWT (RS256) | 15 minutes | no |
| refresh token | opaque, 64 bytes | 30 days | yes, rotated |
Opaque tokens are stored as a SHA-256 digest — a database read cannot recover one. Access tokens are
JWTs so a resource server can verify them offline against the JWKS, but a row is still kept per
token, keyed by jti, so they can be revoked and inspected.
What the id token carries, and what it does not
Section titled “What the id token carries, and what it does not”The id token is identity, and nothing a scope asked for:
{ "iss": "https://jons.auth.example", "sub": "9f1c…", "aud": "2bb32482-…", "exp": 1756400000, "iat": 1756399100, "auth_time": 1756398000, "acr": "urn:masks:acr:pwd", "nonce": "n-once", "at_hash": "aG91c2U…", "tenant": { "uuid": "…", "subdomain": "jons" }}No name, no email, no preferred_username. OIDC Core 5.4 puts the claims a scope asks for at
userinfo for any flow that issues an access token, and carrying them here as
well hands a name and an address to everything that reads the token rather than to what presents one.
Ask /userinfo for those.
auth_time |
when the person actually signed in — not when the token was built |
acr |
urn:masks:acr:pwd or urn:masks:acr:mfa, by whether a second factor was used |
at_hash |
left half of the SHA-256 of the access token, so a client can confirm the two were issued together rather than paired by whoever delivered them |
tenant |
who answered, so a consumer reads a claim instead of calling back |
max_age is enforced against auth_time, not against iat. Sign in at nine, reuse consent at
eleven, and a token whose auth_time was its own issue time would swear you had just authenticated.
Audiences, and why resource exists
Section titled “Audiences, and why resource exists”By default a token’s aud is the client it was issued to. That is fine for an id token, which the
client consumes itself, and useless for an access token the client sends somewhere else.
RFC 8707 fixes this. The client names the API it intends to call:
GET /authorize ?response_type=code &client_id=… &resource=https%3A%2F%2Fjons.things.example%2Fmcpand that value becomes the token’s aud:
{ "aud": "https://jons.things.example/mcp", "scope": "openid profile email" }The resource server then verifies aud against its own URL and refuses anything else. Without
this, a token minted for one API is a valid token at every other API that trusts the same issuer —
so any one of them can replay a token it receives against all the others.
resource may repeat to name several. It can be sent at /authorize, at /token, or both; at the
token endpoint it may only narrow what the code already authorized.
code_challenge with S256 is required for public clients and accepted from every client. plain
is not supported.
challenge = BASE64URL(SHA256(verifier))The verifier is checked at the token endpoint with a constant-time comparison, and the code is consumed whether or not it matches. Burning it only on success would let a wrong verifier be retried with the right one — which is the interception the check exists to catch, granted a second attempt.
A replayed code takes its tokens with it
Section titled “A replayed code takes its tokens with it”Presenting a code twice is evidence the first presentation may not have been the client’s. So the second attempt is refused and everything the first one minted is revoked — the access token and the refresh token both, since both record the code as their parent:
POST /token code=abc… → 200, access + refresh issuedPOST /token code=abc… → 400 invalid_grant, and both of those are now deadRevoking the code alone would leave whoever intercepted it holding the things the code was only ever a means to — and the refresh token is the worse half to leave behind, because it outlives the access token by a month.
Refresh rotation
Section titled “Refresh rotation”Refreshing consumes the presented token and mints a child that records its parent. A replayed refresh token fails, because the first use already consumed it.
