Published

JWTs in OAuth 2.0 and OpenID Connect

OAuth 2.0 is an authorization framework; OpenID Connect (OIDC) is an identity layer built on top of it. Together they define how applications obtain tokens, what those tokens mean, and how they should be verified. JWTs are the token format that makes OIDC work — the ID token is always a JWT, and most modern access tokens are JWTs too. Understanding where JWTs fit in these protocols is essential for integrating with any modern identity provider.

OAuth 2.0 Token Types

OAuth 2.0 defines three token types, each with a different purpose and lifetime:

  • ·Access token: A credential that grants access to a protected resource. Presented to the resource server (your API) on every request. Short-lived (minutes to hours). In modern deployments, usually a JWT.
  • ·Refresh token: A credential used to obtain new access tokens without re-authenticating the user. Long-lived (days to weeks). Almost always an opaque random string — not a JWT — because it is stored server-side and benefits from revocability.
  • ·Authorization code: A short-lived, single-use code issued by the authorization server after the user logs in. Exchanged for access and refresh tokens via a POST request. Not a JWT — it is an opaque value that is only valid for one exchange.

OAuth 2.0 itself says nothing about the format of access tokens — they can be opaque strings or JWTs. The resource server must be able to validate them, but the spec leaves the mechanism open. In practice, modern identity providers issue JWT access tokens because they can be validated locally without calling back to the auth server.

OpenID Connect and the ID Token

OpenID Connect adds an ID token to the OAuth 2.0 flow. The ID token is always a JWT and it carries identity claims about the authenticated user:

// Typical OIDC ID token payload
{
  "iss": "https://accounts.google.com",
  "sub": "110169484474386276334",        // unique, stable user ID
  "aud": "my-client-id.apps.googleusercontent.com",
  "exp": 1716480961,
  "iat": 1716477361,
  "nonce": "abc123def456",              // replay protection
  "email": "jane@example.com",
  "email_verified": true,
  "name": "Jane Developer",
  "picture": "https://lh3.googleusercontent.com/...",
  "given_name": "Jane",
  "family_name": "Developer",
  "locale": "en-GB",
  "at_hash": "HK6E_P6Dh8Y93mRNtsDB1Q"  // access token hash
}

The ID token is meant for the client application — to establish who the user is. It is not meant to be sent to the resource server (API). The access token is what gets sent to the API. Mixing these up is a common mistake.

ID token vs access token — what goes where

PropertyID tokenAccess token
FormatAlways JWT (OIDC spec mandates this)JWT or opaque (provider-dependent)
PurposeAuthenticate the user to the client appAuthorize requests to the resource server
Audience (aud)The client_id of the applicationThe API / resource server identifier
Who reads itThe client applicationThe resource server (API)
Sent to API?No — never send ID tokens to APIsYes — in Authorization: Bearer header
Contains PII?Yes — name, email, profile claimsMinimal — sub, scope, permissions
Typical lifetimeShort (1 hour)Short (5 minutes to 1 hour)
!
Never send an ID token to an API in the Authorization header. ID tokens have your application's client_id as the audience — any API that validates the aud claim will reject it. Sending ID tokens to APIs is also a security risk if the API does not validate the audience, as it may accept tokens containing sensitive PII that were never intended for it.

nonce and at_hash: Anti-Replay and Token Binding

nonce

The nonce claim is a string value set by the client in the initial authorization request. The authorization server echoes it back in the ID token. The client verifies that the nonce in the ID token matches the value it sent — preventing replay attacks where an attacker intercepts and reuses a previously issued ID token.

// Client generates a random nonce and stores it
const nonce = generateRandomString(32)
sessionStorage.setItem('auth_nonce', nonce)

// Nonce is included in the authorization request
GET /authorize?
  response_type=code
  &client_id=my-app
  &nonce=<nonce>
  ...

// After receiving the ID token, verify the nonce
const storedNonce = sessionStorage.getItem('auth_nonce')
if (idTokenPayload.nonce !== storedNonce) {
  throw new Error('Nonce mismatch — possible replay attack')
}
sessionStorage.removeItem('auth_nonce')  // one-time use

at_hash

The at_hash (access token hash) claim binds the ID token to a specific access token. It is computed as the base64url encoding of the left half of the SHA-256 hash of the ASCII representation of the access token:

// at_hash verification (pseudocode)
const hash = sha256(accessToken)           // full 32-byte hash
const leftHalf = hash.slice(0, 16)         // first 16 bytes
const atHash = base64url(leftHalf)

if (idToken.at_hash !== atHash) {
  throw new Error('Access token does not match ID token')
}

Verifying at_hash confirms that the access token and ID token were issued together in the same authorization response. This prevents an attacker from substituting a different access token obtained from another flow.

The Discovery Endpoint

OIDC providers publish a discovery document at a standardised URL:{issuer}/.well-known/openid-configuration. This JSON document describes the provider's capabilities and the URLs of all its endpoints, including the JWKS endpoint:

// Example: GET https://accounts.google.com/.well-known/openid-configuration
{
  "issuer": "https://accounts.google.com",
  "authorization_endpoint": "https://accounts.google.com/o/oauth2/v2/auth",
  "token_endpoint": "https://oauth2.googleapis.com/token",
  "userinfo_endpoint": "https://openidconnect.googleapis.com/v1/userinfo",
  "jwks_uri": "https://www.googleapis.com/oauth2/v3/certs",
  "response_types_supported": ["code", "token", "id_token", ...],
  "id_token_signing_alg_values_supported": ["RS256"],
  "scopes_supported": ["openid", "email", "profile"],
  ...
}

The jwks_uri value tells you exactly where to fetch the public keys for verifying tokens from this provider. Your JWT library or OIDC SDK should fetch this document automatically. When integrating manually, use the jwks_uri from the discovery document rather than hardcoding a key URL — it can change.

Connecting the Discovery Document to Token Verification

The complete verification flow for an OIDC token uses all three of: the discovery document, the JWKS endpoint, and the token's kid header:

// Step 1: Fetch the discovery document (cache this — it rarely changes)
const discovery = await fetch(
  'https://accounts.google.com/.well-known/openid-configuration'
).then(r => r.json())

// Step 2: Fetch the JWKS from the jwks_uri (cache with HTTP headers)
const jwks = await fetch(discovery.jwks_uri).then(r => r.json())

// Step 3: Decode the token header to get the kid
const { kid } = decodeHeader(token)

// Step 4: Find the matching key in JWKS by kid
const signingKey = jwks.keys.find(key => key.kid === kid)
if (!signingKey) {
  // Unknown kid — refresh JWKS cache and try again before rejecting
  throw new Error('Unknown key ID')
}

// Step 5: Verify the token using the matching public key
const payload = jwt.verify(token, publicKeyFromJwk(signingKey), {
  algorithms: ['RS256'],
  issuer: discovery.issuer,
  audience: 'my-client-id',
})

Token Shapes from Major Identity Providers

Each identity provider structures its tokens slightly differently. These examples show representative access token payloads from popular providers:

Auth0

{
  "iss": "https://your-tenant.auth0.com/",
  "sub": "auth0|5f7d9c8b2a1e3f4d5e6f7a8b",
  "aud": ["https://your-api.example.com", "https://your-tenant.auth0.com/userinfo"],
  "iat": 1716477361,
  "exp": 1716480961,
  "azp": "your-client-id",
  "scope": "openid profile email read:posts",
  "permissions": ["read:posts", "write:posts"]
}

AWS Cognito

{
  "sub": "aaaa1111-bbbb-2222-cccc-3333dddd4444",
  "iss": "https://cognito-idp.eu-west-1.amazonaws.com/eu-west-1_AbCdEfGhI",
  "client_id": "1a2b3c4d5e6f7g8h9i0j",
  "origin_jti": "aaaa1111-2222-3333-4444-5555bbbbcccc",
  "token_use": "access",
  "scope": "phone openid profile email",
  "auth_time": 1716477361,
  "exp": 1716480961,
  "iat": 1716477361,
  "jti": "aaaa1111-...",
  "username": "jane@example.com"
}

Okta

{
  "ver": 1,
  "jti": "AT.abc123",
  "iss": "https://your-org.okta.com/oauth2/default",
  "aud": "api://default",
  "iat": 1716477361,
  "exp": 1716480961,
  "cid": "your-client-id",
  "uid": "00u1aaabbb2CcDdEeFF3",
  "sub": "jane@example.com",
  "scp": ["openid", "profile", "email"]
}

Each provider uses slightly different claim names and structures. Auth0 uses permissions for fine-grained permissions, Okta uses scp (an array) instead of scope (a space-delimited string), and Cognito uses token_use to distinguish access tokens from ID tokens. Always consult your provider's documentation for the exact claim names your application should read.

OAuth 2.0 Scopes and What They Appear in Tokens

OAuth 2.0 scopes represent the permissions the client is requesting. They appear in the access token as a scope claim (space-delimited string in most providers) and determine what data the client can access:

  • ·openid: Required for OIDC flows. Causes an ID token to be returned alongside the access token.
  • ·profile: Returns the user's name, locale, picture, and other profile claims in the ID token.
  • ·email: Returns email and email_verified in the ID token.
  • ·offline_access: Requests a refresh token (not all providers support this scope; some issue refresh tokens by default based on configuration).
  • ·Custom API scopes (e.g., read:orders, write:profile): Application-defined scopes registered with the identity provider. They appear in the access token and the resource server uses them for authorization decisions.

The client requests scopes in the authorization URL. The user may be shown a consent screen listing the permissions being requested. The identity provider includes only the approved scopes in the issued token.

Scopes control what the client app can do on behalf of the user. They are not a substitute for server-side authorization logic. Always verify that the token's scope includes the required permission before serving a protected endpoint — and never grant a scope to a client that should not have access to that functionality.
Ready to decode a token?
Use the free JWT decoder — paste any token for instant results, entirely in your browser.
Open JWT Decoder