Skip to content

OIDC id_token vs access_token

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
}
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 payload

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

{
"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
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.

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 this

Establish 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 app
API → expects a token whose aud is itself
→ audience validation fails → 401 invalid_token

2. 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"}

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.

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 validate
access_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.

✗ 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-editable
  • 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