ID Token
A JWT issued by an OpenID Connect provider that asserts who the authenticated user is, signed by the issuer. Distinct from an OAuth access token (which says what the bearer can do).
Structure: header.payload.signature
A JWT is three base64url-encoded parts joined by .. Each part is independently decodable; only the signature requires the issuer’s public key to verify.
eyJhbGciOiJSUzI1NiIsImtpZCI6Ii4uLiJ9 . eyJpc3MiOiJodHRwczovL2FjY291bnRzLi4uIn0 . SflKxw...
header (alg, kid) payload (claims) signature
Decoded Payload (Google example)
{
"iss": "https://accounts.google.com",
"sub": "110169484474386276334",
"aud": "YOUR_CLIENT_ID",
"exp": 1353604926,
"iat": 1353601026,
"nonce": "random_value_from_request",
"email": "user@gmail.com",
"email_verified": true,
"name": "John Doe",
"picture": "https://lh3.googleusercontent.com/photo.jpg"
}Critical Claims
| Claim | Meaning | Why it matters |
|---|---|---|
iss | Issuer URL | Must match the discovery issuer. Defends against tokens from a wrong provider. |
sub | Subject — stable user identifier within the issuer | Use this as your user ID. Never use email. |
aud | Audience — your client_id | If it doesn’t match yours, the token was issued for a different app. Reject. |
exp | Expiration (Unix seconds) | Reject if past. |
iat | Issued at | Reject if implausibly old or in the future (clock skew tolerance ~5 min). |
nonce | Replay-protection value you sent | Must match the value your client sent in the auth request. See Social Login Security. |
azp | Authorized party | Present when token is for one client but the user authorized many; verify if present. |
Validation Algorithm
flowchart TD A[Receive id_token] --> B[Parse header → get kid, alg] B --> C[Fetch JWKS from issuer's jwks_uri<br/>cache by kid] C --> D[Find key matching kid] D --> E[Verify signature using key + alg] E -->|fail| X[Reject] E -->|pass| F[Check iss == expected issuer] F -->|fail| X F -->|pass| G[Check aud == our client_id] G -->|fail| X G -->|pass| H[Check exp > now] H -->|fail| X H -->|pass| I[Check nonce == sent nonce] I -->|fail| X I -->|pass| J[Accept user identity]
Reference Validation (pseudocode)
import jwt # pyjwt
from jwt import PyJWKClient
jwks = PyJWKClient(discovery["jwks_uri"])
signing_key = jwks.get_signing_key_from_jwt(id_token).key
claims = jwt.decode(
id_token,
signing_key,
algorithms=["RS256"], # never accept "none"; pin algorithm
audience=CLIENT_ID,
issuer=discovery["issuer"],
options={"require": ["exp", "iat", "iss", "aud", "sub"]},
)
if claims.get("nonce") != stored_nonce_for_session:
raise InvalidToken("nonce mismatch")Why sub and Not email for the User ID
- Emails change (rebranding, address change, marriage).
- An email at one provider can be reassigned to a different person.
emailmay be absent if the user denies theemailscope.subis per-issuer-stable and unique forever.
The composite primary key for a federated user is (iss, sub), not email.
ID Token vs Access Token vs Refresh Token
| ID Token | Access Token | Refresh Token | |
|---|---|---|---|
| Tells you | who the user is | what bearer can do | how to get a new access token |
| Format | always JWT | opaque or JWT | opaque |
| Audience | your client | the resource server | the auth server |
| Sent to | nobody (consumed by you) | resource server APIs | the token endpoint |
| Lifetime | minutes (short — it’s a snapshot) | minutes to ~1h | days to months |
Common mistake: sending the ID Token to APIs as an authorization header. Don’t. APIs accept access tokens. The ID Token is for your client to consume.
Pitfalls
- Accepting
alg: none. Old JWT libraries did this by default. Pin acceptable algorithms. - Skipping signature verification because “we trust HTTPS” — HTTPS proves you talked to the auth server now; the token may be stale, replayed, or forged.
- Caching keys forever without honoring
kidrotation. Refetch JWKS onkidmiss. - Trusting
email_verified=falseas if it were verified.