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

Sponsored

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, and aud
  • 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:

  1. Decode the JWT without verifying first (to read the header) and note the kid (key ID).
  2. Fetch the provider’s discovery document (typically at /.well-known/openid-configuration) to get the jwks_uri.
  3. Retrieve the JSON Web Key Set (JWKS) from the jwks_uri and pick the key that matches the token’s kid header.
  4. Verify the signature using that public key. For RS256, this means checking that the RSA signature matches the signing input.
  5. Validate the claims in order: - Is iss exactly equal to your provider’s expected issuer? - Is aud your client ID? (Some providers allow an array; pick the one that matches) - Is exp in the future, and is the token not expired? - Is iat not in the far future (to prevent replay attacks)? - Check if nonce is present (if your flow included one) and matches the value you stored in the auth request.
  6. 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’s jwt.decode with options={"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-rsa cache 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 asymmetric RS256 whenever possible.

Troubleshooting & edge cases

  • Signature verification failed — most often means the JWKS key doesn’t match the token’s kid. 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 matching kid.
  • Audience (aud) mismatch — the token was issued for a different client. Check that your CLIENT_ID is 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’s exp has passed. Add a small leeway (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 the issuer field 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 leeway in 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 — and nonce in implicit flows.
  • Use sub as your stable user ID, never email.
  • 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 aud claim against your exact CLIENT_ID — tokens meant for another app can wreak havoc.
  • Using the email claim as a primary user key — emails can change; sub is 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

  1. Use pyjwt's built-in options parameter to enable or disable specific claim verifications (e.g., verify_aud, verify_exp).
  2. Cache the JWKS in memory and refresh it only when the key ID changes (using a library like jwks-rsa) for better performance.
  3. For symmetric HS256 — not recommended — you'd use the client secret, but prefer RS256 to 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 sub claim as the stable user identifier — not email.
  • 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.

Sponsored

Sponsored

Discussion

Questions, corrections, and tips help everyone reading this page.

0 comments

Add a comment

Shown publicly with your comment.

Be constructive · max 4,000 characters

No comments yet — start the thread.

Related tutorials, quizzes, and articles for this topic.