Understanding ID Tokens — Structure, Claims, and Verification in OIDC
In this tutorial, you will learn about Understanding ID Tokens. We cover key concepts, practical examples, and best practices to help you master this topic.
An ID token is a signed JWT (JSON Web Token) issued by the OIDC provider that contains verified claims about the authenticated user, including their unique identifier, name, email, and authentication metadata.
What You'll Learn
- Structure of an ID token as a JWT
- Standard claims: sub, aud, exp, iat, iss, nonce
- How to verify ID token signature and claims
Why It Matters
The ID token is the foundation of OIDC authentication. If you cannot correctly validate an ID token, your entire authentication system is vulnerable to forgery, replay attacks, and impersonation.
Real-World Use
When a user authenticates through Doda Browser's OIDC login, the backend receives an ID token. It verifies the signature using the provider's public key, checks the expiration, confirms the audience matches the app, and extracts the user's unique ID for session creation.
flowchart LR
Provider["OIDC Provider"] -->|"ID Token (JWT)"| App["Your App"]
App -->|"1. Verify Signature"| JWKS["Provider JWKS"]
App -->|"2. Check exp, nbf"| Time["Current Time"]
App -->|"3. Verify aud"| Config["Client ID"]
App -->|"4. Extract claims"| User["User Identity"]
style App fill:#dbeafe,stroke:#2563eb
ID Token Structure
An ID token is a JWT with three parts separated by dots: header, payload, signature.
import jwt
# Example decoded ID token payload
id_token_payload = {
"iss": "https://accounts.google.com",
"sub": "1234567890",
"aud": "your-client-id.apps.googleusercontent.com",
"exp": 1719561600,
"iat": 1719558000,
"auth_time": 1719558000,
"nonce": "random-nonce-value",
"at_hash": "hash-of-access-token",
"name": "Alice Smith",
"email": "alice@example.com",
"email_verified": True,
"picture": "https://example.com/avatar.jpg"
}
Standard Claims
| Claim | Full Name | Description |
|---|---|---|
iss |
Issuer | URL of the OIDC provider |
sub |
Subject | Unique user identifier (never changes) |
aud |
Audience | Client ID the token is intended for |
exp |
Expiration | Token expiry timestamp |
iat |
Issued At | Token issuance timestamp |
auth_time |
Authentication Time | When the user authenticated |
nonce |
Nonce | Replay attack prevention |
at_hash |
Access Token Hash | Links ID token to access token |
ID Token Verification
import requests
import jwt
from jwt import PyJWKClient
def verify_id_token(id_token, client_id, provider_jwks_url):
# Step 1: Fetch provider's public keys
jwks_client = PyJWKClient(provider_jwks_url)
signing_key = jwks_client.get_signing_key_from_jwt(id_token)
# Step 2: Decode and verify
claims = jwt.decode(
id_token,
signing_key.key,
algorithms=["RS256"],
audience=client_id,
options={
"verify_exp": True,
"verify_iat": True,
"require": ["iss", "sub", "aud", "exp"],
}
)
return claims
# Usage
claims = verify_id_token(
id_token="eyJhbGciOiJSUzI1NiIs...",
client_id="your-client-id.apps.googleusercontent.com",
provider_jwks_url="https://www.googleapis.com/oauth2/v3/certs"
)
print(f"Authenticated: {claims['sub']}")
print(f"Name: {claims.get('name', 'N/A')}")
Understanding the sub Claim
The sub claim is the unique identifier for the user. It never changes for the same user on the same provider. Use this as the primary key for user accounts in your database.
# Store user by sub claim
user_id = claims["sub"]
provider = claims["iss"]
# Check if user exists
user = db.users.find_one({
"provider": provider,
"provider_user_id": user_id
})
if not user:
user = db.users.insert_one({
"provider": provider,
"provider_user_id": user_id,
"name": claims.get("name"),
"email": claims.get("email"),
})
Common Mistakes
1. Not Verifying the Signature
Decoding a JWT without verifying the signature is insecure. Anyone can create a JWT with any claims. Always use a verification library.
2. Skipping the aud Check
Without audience verification, a token issued for another app can authenticate users on your app. Always verify aud matches your client ID.
3. Using Decoded Tokens Without Expiry Check
Expired tokens should be rejected. The exp claim must be verified. Libraries check this automatically when verify_exp is enabled.
4. Ignoring nonce for Login Requests
The nonce claim prevents replay attacks. If your auth request included a nonce, verify it matches in the ID token.
5. Assuming All Providers Use the Same Claims
Claims like name, email, and picture are optional. Not all providers include them. Always handle missing claims gracefully.
Practice Questions
- What are the three parts of a JWT ID token?
- Why is the
subclaim important for user identification? - What happens if you do not verify the ID token signature?
- Why must the
audclaim be verified? - What is the purpose of the
nonceclaim?
Answers:
- Header (algorithm and key ID), payload (claims), signature (signed by the provider).
- The
subis a stable, unique identifier for the user that never changes, unlike email or name. - An attacker can forge a JWT with any user identity, gaining unauthorized access to any account.
- Without audience verification, a token issued for a different application can be used to authenticate on your app.
- The
nonceprotects against replay attacks by ensuring the token corresponds to a specific authentication request.
Challenge: Write a Python function that fully validates an ID token: verify the signature using JWKS, check exp, aud, iss, and nonce. Include proper error handling for each validation step.
FAQ
Mini Project
Build a Python script that fetches the JWKS from an OIDC provider, verifies an ID token, and extracts all standard claims. Handle verification errors with specific error messages for each failure case.
What's Next
Continue with ID Token Claims Reference for a complete guide to standard and custom claims, or explore UserInfo Endpoint for retrieving additional user data.
Built by the developers of DodaTech
Doda Browser, DodaZIP & Durga Antivirus Pro