Skip to content

Tokens and audiences

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.

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%2Fmcp

and 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.

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 issued
POST /token code=abc… → 400 invalid_grant, and both of those are now dead

Revoking 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.

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.