Published
JWTs in OAuth 2.0 and OpenID Connect
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
| Property | ID token | Access token |
|---|---|---|
| Format | Always JWT (OIDC spec mandates this) | JWT or opaque (provider-dependent) |
| Purpose | Authenticate the user to the client app | Authorize requests to the resource server |
| Audience (aud) | The client_id of the application | The API / resource server identifier |
| Who reads it | The client application | The resource server (API) |
| Sent to API? | No — never send ID tokens to APIs | Yes — in Authorization: Bearer header |
| Contains PII? | Yes — name, email, profile claims | Minimal — sub, scope, permissions |
| Typical lifetime | Short (1 hour) | Short (5 minutes to 1 hour) |
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 useat_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
emailandemail_verifiedin 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.