OIDC id_token vs access_token
OIDC id_token vs access_token
Section titled “OIDC id_token vs access_token”Both arrive from the same /token call
Section titled “Both arrive from the same /token call”scope=openid makes the response additive, not a replacement — you get the normal OAuth response plus an id_token:
{ "access_token": "ya29.a0AfH6...", ← for the resource server "expires_in": 3599, "scope": "openid email profile", "token_type": "Bearer", "id_token": "eyJhbGciOiJSUzI1NiIs..." ← for YOUR app}Two audiences, two directions
Section titled “Two audiences, two directions”access_token |
id_token |
|
|---|---|---|
| Who it’s for | the resource server (the API) | your app (the client) |
| Format | undefined by spec — opaque string, or JWT per provider | always a JWT |
| Do you parse it? | No. Never read it; you don’t own it | Yes. That’s its entire purpose |
| Do you send it to APIs? | ✓ yes, as Bearer |
✗ never |
| What it carries | granted scopes / permissions | claims about the user’s identity |
| Revocable | yes | no — it’s a signed assertion |
| Purpose | “the user granted this access” | “here is who the user is” |
The rule: id_token proves who to you; access_token proves what’s allowed to the API. Different audiences, opposite directions, never interchangeable.
The id_token is a JWT — with a published contract
Section titled “The id_token is a JWT — with a published contract”It’s the same three-segment container you already know:
eyJhbG...YyJ9 . eyJpc3M...czov... . signature└── header ──┘ └──── payload ────┘ └── sig ──┘ ↑ the claims ARE the payloadThe difference from a generic JWT: the payload isn’t free-form. OIDC defines the claim names and their meanings.
| Generic JWT | OIDC id_token |
|
|---|---|---|
| Payload contents | whatever you want | defined claim set |
| Claim names | your choice | iss, aud, sub, exp, nonce… specified |
| Semantics | you invent them | fixed meanings, fixed validation rules |
| Verification | your own rules | any OIDC-conforming app validates identically |
Same container, same location, published schema.
The claim contract
Section titled “The claim contract”{ "iss": "https://accounts.google.com", ← MUST match the expected issuer "aud": "CLIENT_ID.apps.googleusercontent.com", ← MUST equal YOUR client_id "azp": "CLIENT_ID...", ← authorized party (when aud is multi-valued) "sub": "110169484474386276334", ← stable user ID — use THIS as the key "email": "user@example.com", "email_verified": true, "nonce": "n-0S6_WzA2Mj", ← must match what you sent in /authorize "iat": 1710000000, "exp": 1710003600, ← short, typically 1 hour "at_hash": "77QmUPtjPfzWtF2AnpK9RQ" ← binds this id_token to its access_token}Required vs optional (OIDC Core):
| Claim | Requirement | Meaning |
|---|---|---|
iss |
required | issuer |
sub |
required | user ID |
aud |
required | must equal your client_id |
exp |
required | expiry |
iat |
required | issued-at |
auth_time |
optional (required if max_age was sent) |
when the user actually authenticated |
nonce |
conditional | required if you sent one — anti-replay |
at_hash |
conditional | required if an access_token was issued |
email, name, picture |
optional | only with the matching scope |
What each claim defends against
Section titled “What each claim defends against”| Claim | Prevents |
|---|---|
aud = your client_id |
a token minted for another app being replayed at yours |
iss |
a token from a different or malicious issuer |
nonce |
replay of an old id_token (matches the nonce sent in /authorize) |
at_hash |
token substitution — pairing a valid id_token with a different access_token |
exp / iat |
stale tokens |
azp |
confusion when a token is valid for multiple audiences |
sub is the identity key, not email. Provider emails can be changed or recycled; sub is stable and unique per issuer. Using email as a primary key breaks when a user changes it.
Validation checklist
Section titled “Validation checklist”claims = jwt.decode( token, key, # from the provider's JWKS algorithms=["RS256"], # allow-list — never trust the header's alg audience=CLIENT_ID, # library enforces aud issuer=ISSUER, # library enforces iss)assert claims["exp"] > time.time()assert claims["nonce"] == session_nonce # YOUR check — libraries don't do thisEstablish the key via discovery: /.well-known/openid-configuration → jwks_uri.
Because it is a JWT, every JWT attack applies — alg:none, weak HMAC secret, algorithm confusion (RS256→HS256), kid injection, unchecked exp. The OIDC contract doesn’t add protection; it adds rules about what must be checked. Skipping them is exactly how alg:none succeeds in the wild. See the JWT page.
Never send the id_token to an API — even as Bearer
Section titled “Never send the id_token to an API — even as Bearer”Two independent reasons:
1. aud doesn’t match the API.
id_token → "aud": "<your client_id>" issued FOR the client appAPI → expects a token whose aud is itself → audience validation fails → 401 invalid_token2. It grants nothing. The id_token carries no scope. It is an assertion about identity, not an authorization to touch resources.
A correct API response:
GET api.example.com/user Authorization: Bearer <id_token>→ 401 {"error":"invalid_token","error_description":"Invalid audience"}The vulnerability when an API is sloppy
Section titled “The vulnerability when an API is sloppy”If an API validates only the signature (not aud), and the provider signs id_tokens and access_tokens with the same key, a valid id_token passes. That is audience confusion / token substitution — a real finding class, and exactly the kind of thing the OWASP categories cover.
Two defenses:
| Defense | Mechanism |
|---|---|
aud validation |
reject any token whose audience isn’t this API |
typ header (RFC 9068) |
JWT access tokens carry "typ": "at+jwt"; id_tokens carry "typ": "JWT" → reject on type mismatch |
This is also why at_hash exists — it binds an id_token to one specific access_token so the two can’t be mixed across flows.
Telling them apart in practice
Section titled “Telling them apart in practice”id_token |
access_token |
|
|---|---|---|
| Format guaranteed | ✓ always a JWT | provider’s choice — often opaque |
typ header |
JWT |
at+jwt if a JWT (RFC 9068) |
| You parse it | ✓ validate the claims | ✗ never |
| You send it to APIs | ✗ never | ✓ yes, as Bearer |
| Scopes | none | the granted permissions |
| Contains identity claims | ✓ sub, email, iss… |
not defined |
Why “it’s like a JWT payload” applies to one and not the other
Section titled “Why “it’s like a JWT payload” applies to one and not the other”id_token → MUST be a JWT → has a payload you read and validateaccess_token → format undefined → opaque string, OR a JWT, per provider (Google/Okta typically opaque; some providers issue JWT access tokens)The spec guarantees a structure for the id_token and deliberately does not for the access_token. That asymmetry is the whole reason the two must be handled differently.
Three don’ts
Section titled “Three don’ts”✗ don't send id_token to an API → it's not an access token (aud mismatch, no scopes)✗ don't parse/trust access_token claims → undefined format, not yours to interpret✗ don't skip aud/iss/nonce/exp validation → it's just a JWT; unsigned claims are attacker-editableSources
Section titled “Sources”- OpenID Connect Core 1.0 (ID Token section, claim definitions, validation rules)
- RFC 9068 (JWT Profile for OAuth 2.0 Access Tokens —
at+jwt) - RFC 7519 (JWT), RFC 8725 (JWT Best Current Practices)
- OWASP API Security Top 10 — API2 Broken Authentication