Verify ID Tokens and Claims
Verify ID tokens and their claims — OAuth 2 · OpenID Connect tutorial. Hands-on steps, troubleshooting, and what to study next.
Focus: verify id tokens and their claims
You’ve built the OAuth 2.0 flow, exchanged codes, and received an ID token from your OpenID Connect provider. But holding that token in your hands is like holding a signed contract — if you don’t check the signature and the fine print, you can’t trust any of the promises it makes. This lesson walks you through the exact, step-by-step process of verifying ID tokens and their claims so you never blindly trust a token’s contents again, and you’ll be ready to build secure, user-aware applications with confidence.
The problem this lesson solves
When your application receives an ID token, it’s tempting to parse the JWT, extract the user’s name or email, and move on. But that’s a critical security mistake. An ID token can be forged, tampered with, or come from a completely different audience — and the web is full of attackers who know you might skip verification. If you trust an unverified token’s claims, you could be granting access to the wrong user, leaking user data, or letting an attacker impersonate a legitimate identity.
Consider this scenario: your OAuth 2.0 client receives an ID token and reads the email claim. If that token was never verified, you’re essentially trusting a string of base64-encoded text that anyone with a token generator could have produced. The result? An attacker can set email: attacker@evil.com and log in as that user. Verifying ID tokens and their claims is not an optional best practice — it’s the difference between a secure application and a data breach waiting to happen.
Core concept / mental model
Think of an ID token as a cryptographically signed ID card. The card has the user’s name, photo, and other details clearly printed on it, but the security is in the signature — the hologram that proves the card was issued by the official government office. Without verifying that hologram, the ID is worthless.
In OpenID Connect, the ID token is a JSON Web Token (JWT). It’s composed of three parts:
- Header: tells you the signing algorithm (e.g.,
RS256) - Payload: contains the claims — user attributes like
sub,email,name,iss, andaud - Signature: generated by the issuer’s private key, verifiable with the issuer’s public key
Verifying the ID token means checking the signature using the issuer’s public key, then validating the claims to ensure they match what you expect. The signature proves the token really came from the issuer; the claims prove that the token is for your application and hasn’t expired.
The key claims you must verify
| Claim | What it means | Why you must verify it |
|---|---|---|
iss |
Issuer identifier (who issued the token) | Prevents tokens from rogue sources |
aud |
Audience (the intended recipient) | Ensures the token is for your client ID |
exp |
Expiration time (in seconds since epoch) | Token is only valid during its lifetime |
iat |
Issued-at time | Sanity check; token shouldn’t be issued in the future |
sub |
Subject (the user’s unique identifier) | Stable ID for the user; you must not use email as a primary key |
How it works step by step
Verifying an ID token is a deterministic, multi-step process that every OIDC library does under the hood, but you should understand it to know what’s happening. Here’s the logical flow:
- Decode the JWT without verifying first (to read the header) and note the
kid(key ID). - Fetch the provider’s discovery document (typically at
/.well-known/openid-configuration) to get thejwks_uri. - Retrieve the JSON Web Key Set (JWKS) from the
jwks_uriand pick the key that matches the token’skidheader. - Verify the signature using that public key. For
RS256, this means checking that the RSA signature matches the signing input. - Validate the claims in order:
- Is
issexactly equal to your provider’s expected issuer? - Isaudyour client ID? (Some providers allow an array; pick the one that matches) - Isexpin the future, and is the token not expired? - Isiatnot in the far future (to prevent replay attacks)? - Check ifnonceis present (if your flow included one) and matches the value you stored in the auth request. - If any check fails, reject the token — don’t just log a warning and proceed.
Each step is a cause-and-effect chain: the signature guarantees integrity, the issuer claim guarantees authenticity, the audience and nonce prevent cross-client and replay attacks.
Hands-on walkthrough
Time to put it into practice. We’ll use Python with the pyjwt and httpx libraries, but the logic translates to any OIDC library (e.g., oidc-client in Node, jjwt in Java). First, install the libraries:
pip install pyjwt httpx cryptography
Now let’s fetch the discovery document and the JWKS, then verify a decoded token.
import json
import httpx
from jwt import decode, get_unverified_header, InvalidTokenError
from jwt import algorithms
# 1. Config - your OAuth client and issuer
CLIENT_ID = "my-spa-client"
ISSUER = "https://accounts.example.com" # e.g., your IdP's OIDC issuer
# 2. Discovery - get jwks_uri
well_known = f"{ISSUER}/.well-known/openid-configuration"
with httpx.Client() as client:
r = client.get(well_known)
r.raise_for_status()
jwks_uri = r.json()["jwks_uri"]
# 3. Fetch JWKS
jwks_response = client.get(jwks_uri)
jwks_response.raise_for_status()
jwks = jwks_response.json()
# 4. Your ID token (from a real OIDC flow)
id_token = "eyJhbGciOiJSUzI1NiIsImtpZCI6Im9pZGMta2V5IiwidHlwIjoiSldUIn0.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaXNzIjoiaHR0cHM6Ly9hY2NvdW50cy5leGFtcGxlLmNvbSIsImF1ZCI6Im15LXNwYS1jbGllbnQiLCJleHAiOjE3MDU3NTQwMDAsImlhdCI6MTcwNTc1MDQwMH0.signature_here"
# 5. Get the key ID from the token header
header = get_unverified_header(id_token)
kid = header.get("kid")
# Find the matching key in JWKS
key = None
for jwk in jwks["keys"]:
if jwk["kid"] == kid:
key = jwk
break
if not key:
raise ValueError("No matching key found")
# 6. Convert the JWK to a PEM public key (pyjwt expects PEM or JWK dict)
# pyjwt supports JWK dict directly in newer versions, so we pass it as algorithm key
public_key = algorithms.RSAAlgorithm.from_jwk(json.dumps(key))
# 7. And now verify!
try:
claims = decode(
id_token,
public_key,
algorithms=["RS256"],
audience=CLIENT_ID,
issuer=ISSUER,
options={"require": ["exp", "iss", "aud"]}, # enforce required claims
leeway=60, # allow 60 seconds clock skew
)
print("Token verified! Claims:")
print(json.dumps(claims, indent=2))
except InvalidTokenError as e:
print(f"Verification failed: {e}")
Expected output (for a valid token):
Token verified! Claims:
{
"sub": "1234567890",
"name": "John Doe",
"iss": "https://accounts.example.com",
"aud": "my-spa-client",
"exp": 1705754000,
"iat": 1705750400
}
Manual claim validation (just in case you’re on a no-library diet)
If you’re ever auditing or building your own, you can check claims manually after verifying the signature (always verify signature first!). Here’s how:
import time
# After signature verification, validate each claim
if claims["iss"] != ISSUER:
raise ValueError("Issuer mismatch")
if claims["aud"] != CLIENT_ID:
raise ValueError("Audience mismatch")
if claims["exp"] < int(time.time()) - 60: # allow 60s clock skew
raise ValueError("Token expired")
if "nonce" in claims and claims["nonce"] != expected_nonce:
raise ValueError("Nonce mismatch")
# sub is the user's stable ID; never use email as your user primary key
user_id = claims["sub"]
Compare options / when to choose what
When verifying ID tokens, you have two main approaches: use a well-known OIDC library or hand-roll your verification. Most production apps should use a library, but understanding the manual path is invaluable for debugging and security audits.
| Approach | Pros | Cons | When to use |
|---|---|---|---|
Use an OIDC library (e.g., pyjwt, oidc-client, oauth2-provider) |
Tried-and-tested, handles edge cases, free updates | Less transparent; you must still configure correctly | Almost always — for production apps, especially insecure flows (implicit) |
| Manual verification | Full control, educational, no heavy dependency | Lots of room for error, easy to get a detail wrong | For learning, debugging, or very specific requirements |
| Provider-specific SDK (e.g., AWS Cognito, Auth0) | Simplifies claim mapping, may auto-verify | Locked to one provider, less portable | When you’re deeply integrated with one provider |
Recommendation: Use a battle-tested library in production. But never trust it blindly — always configure it with the correct issuer, audience, and algorithm allowlist. If you’re building for multiple providers, stick to open standards and pyjwt-style verification.
Variations
- Use
pyjwt’sjwt.decodewithoptions={"verify_aud": True}— it gives fine-grained control over claim checking without manual code. - Caching the JWKS to avoid fetching on every request — critical for performance; libraries like
jwks-rsacache and rotate keys automatically. - For symmetric algorithms (
HS256) — you’d use the client secret, but this is strongly discouraged in OIDC because the secret is shared between client and provider, making the token forgeable by any client. Stick to asymmetricRS256whenever possible.
Troubleshooting & edge cases
Signature verification failed— most often means the JWKS key doesn’t match the token’skid. Make sure you’re using the right issuer and that the provider’s keys haven’t rotated. Solution: re-fetch the JWKS and pick the key with the matchingkid.Audience (aud) mismatch— the token was issued for a different client. Check that yourCLIENT_IDis exactly the one you used in the auth request (providers often make client IDs case-sensitive).Token expired— your system clock is off, or the token’sexphas passed. Add a smallleeway(e.g., 60 seconds) to accommodate clock drift, but don’t set it too large or you risk accepting stale tokens.Issuer mismatch— you’re verifying against the wrong issuer (maybe you used the auth server’s base URL instead of the exact issuer string from the discovery document). Always use theissuerfield from the well-known config.- Token with no
kid— some providers always send it; if it’s missing, you might need to use a fallback or the only key in the set. Better to reject unless you have a single key. - Clock skew issues — always use
leewayin production, but never more than 1–2 minutes.
What you learned & what's next
You’ve learned that verifying ID tokens and their claims is a non-negotiable part of any OIDC integration. You can now fetch a provider’s JWKS, verify the JWT signature, and validate critical claims like iss, aud, and exp. You’ve also seen how to avoid common pitfalls like audience mismatches and key rotation.
Now that you trust your ID tokens, the next step is to decide what to do with them — that’s where the next lesson on managing token revocation and silent refresh comes in. You’ll learn how to keep sessions alive securely without embedding long-lived tokens in client storage.
Keep this checklist close:
- Always verify the signature before trusting any claim.
- Always validate
iss,aud,exp— andnoncein implicit flows. - Use
subas your stable user ID, neveremail. - Use a library in production, but understand what it does under the hood.
- Watch for key rotation and clock skew.
Practice recap
Next step: Write a small function that verifies a test ID token using a mock JWKS (you can simulate one with the jwt library). Try playing with incorrect audiences or expired tokens and observe the errors. Then, head to the next lesson on token refresh and revocation to keep your sessions alive securely.
Common mistakes
- Skipping signature verification and trusting a token's payload without checking the issuer — this is the most common and severe security flaw.
- Not validating the
audclaim against your exactCLIENT_ID— tokens meant for another app can wreak havoc. - Using the
emailclaim as a primary user key — emails can change;subis the only stable identifier. - Ignoring clock skew and rejecting valid tokens just because your server clock is a few seconds off — use a small
leeway.
Variations
- Use
pyjwt's built-inoptionsparameter to enable or disable specific claim verifications (e.g.,verify_aud,verify_exp). - Cache the JWKS in memory and refresh it only when the key ID changes (using a library like
jwks-rsa) for better performance. - For symmetric
HS256— not recommended — you'd use the client secret, but preferRS256to avoid shared-secret forgeries.
Real-world use cases
- A SPA (single-page app) that calls a backend API — you verify the ID token on the backend with the provider's public key to identify the user.
- A mobile app that uses an authorization code flow and needs to extract user profile data (name, email) server-side without trusting the client.
- A microservices architecture where each service verifies the ID token independently to enforce per-user permissions and avoid a central auth service bottleneck.
Key takeaways
- ID tokens are JWTs with claims that must never be trusted without verification.
- Verification = signature check + claim validation (iss, aud, exp, nonce).
- Always fetch the provider's JWKS and use the key matching the token's
kid. - Use the
subclaim as the stable user identifier — notemail. - Use a battle-tested OIDC library in production, but know what it verifies under the hood.
- Always account for key rotation and clock skew with a small leeway.
Keep learning
Related tutorials, quizzes, and articles for this topic.
Discussion
Questions, corrections, and tips help everyone reading this page.
0 comments
Add a comment
No comments yet — start the thread.