An OIDC token endpoint issues both an ID token and an access token. They are both JWTs (usually), they both have a sub claim, and they both expire. It is tempting to treat them as equivalent or to use whichever one is most convenient. This is a common mistake. They have different audiences, different validation requirements, and different security implications when misused.
What ID tokens are for
The ID token is defined by the OpenID Connect specification as a security token that conveys claims about the authentication event. It is addressed to the client application — the thing that requested the login flow. The aud claim in the ID token is the client ID of the application that initiated the authorization request. It says: "Here is information about who just logged in, for your application's use."
The ID token answers the question: "Who logged in, when did they log in, and on which device?" It carries user identity claims (sub, name, email), authentication context (amr, acr), and the auth_time indicating when the user last authenticated. The frontend reads these to personalize the UI, populate the user's profile page, or decide whether to require step-up authentication.
// Correct use of ID token: display user profile in the frontend
import { jwtDecode } from 'jwt-decode';
function updateUserProfile(idToken) {
const claims = jwtDecode(idToken);
// ID token claims are for display only — this is their intended use
document.getElementById('user-name').textContent = claims.name;
document.getElementById('user-email').textContent = claims.email;
document.getElementById('user-avatar').src = claims.picture;
}
// ID token claims you will find:
// sub: "usr_01H9XM3K" — unique user identifier at this IdP
// name: "Alice Nguyen"
// email: "alice@example.com"
// email_verified: true
// auth_time: 1698012345 — when authentication occurred
// amr: ["pwd", "totp"] — authentication methods used
// acr: "urn:acr:mfa" — authentication context class
What access tokens are for
The access token authorizes calls to resource servers. Its audience is the API, not the frontend. A resource server receiving an access token in an Authorization: Bearer header verifies the signature, validates the audience matches itself, checks the scopes, and then uses the sub to determine which user's resources are being accessed.
The access token is not meant to be decoded by the frontend. Whether it is a JWT or an opaque string is an implementation detail of the authorization server. From the client's perspective, it is a credential to be included in API calls and refreshed when it expires.
// Correct use of access token: authorize API calls
async function fetchUserData(accessToken) {
const response = await fetch('https://api.example.com/v1/user/data', {
headers: {
'Authorization': `Bearer ${accessToken}`,
},
});
return response.json();
}
// On the API server — validate the access token
import { jwtVerify, createRemoteJWKSet } from 'jose';
const JWKS = createRemoteJWKSet(new URL('https://auth.example.com/.well-known/jwks.json'));
async function validateAccessToken(token) {
const { payload } = await jwtVerify(token, JWKS, {
issuer: 'https://auth.example.com',
audience: 'https://api.example.com', // Must match this API
});
return payload;
}
Why using the ID token as an API credential is wrong
If a frontend sends an ID token to a backend API, and the API accepts it, several problems arise:
- Audience mismatch: the ID token's
audis the client ID (app_xyz), not the API URL. If the API validates the audience correctly, it will reject the ID token. If the API does not validate the audience, it has a security vulnerability — any valid ID token from any client can be used to call the API. - Scopes: ID tokens do not carry OAuth scopes. If your API authorization model is scope-based, the ID token provides no scoping information.
- Confusion about refresh: ID tokens are not refreshed via the refresh token flow. Using an ID token as an API credential means it will expire and the user will get a 401 with no clean refresh path.
- Disclosure: ID tokens often carry personal data (name, email). Including them in every API call means that data is logged in server access logs, forwarded by proxies, and potentially stored in CDN caches.
Nonce binding
The nonce parameter prevents replay attacks against the ID token. The client generates a cryptographically random nonce, includes it in the authorization request, and the authorization server embeds it in the ID token. The client verifies that the nonce in the ID token matches what was sent. An attacker who intercepts an ID token cannot replay it — it was bound to a specific nonce that the attacker does not control.
// Generate and store nonce before redirecting to IdP
const nonce = crypto.randomUUID();
sessionStorage.setItem('auth_nonce', nonce);
const authUrl = new URL('https://auth.example.com/oauth/authorize');
authUrl.searchParams.set('response_type', 'code');
authUrl.searchParams.set('client_id', CLIENT_ID);
authUrl.searchParams.set('redirect_uri', REDIRECT_URI);
authUrl.searchParams.set('scope', 'openid profile email');
authUrl.searchParams.set('nonce', nonce);
authUrl.searchParams.set('state', generateState());
window.location.href = authUrl.toString();
// After receiving the ID token, verify the nonce
const claims = await verifyIdToken(idToken);
const storedNonce = sessionStorage.getItem('auth_nonce');
if (claims.nonce !== storedNonce) {
throw new Error('Nonce mismatch — possible replay attack');
}
sessionStorage.removeItem('auth_nonce');
The at_hash binding
When an ID token is issued alongside an access token (as in the hybrid flow or implicit flow), the ID token may contain an at_hash claim. This is the left-most half of the SHA-256 hash of the access token, base64url-encoded. It cryptographically binds the ID token to the specific access token it was issued with. Verifying at_hash prevents an attacker from substituting a different access token for the one the ID token was paired with.
// Verify at_hash after receiving tokens in hybrid flow
import { createHash } from 'crypto';
function verifyAtHash(accessToken, atHash) {
const hash = createHash('sha256').update(accessToken, 'ascii').digest();
const leftHalf = hash.slice(0, hash.length / 2);
const computed = leftHalf.toString('base64url');
return computed === atHash;
}
// In your token callback handler
if (idTokenClaims.at_hash) {
if (!verifyAtHash(accessToken, idTokenClaims.at_hash)) {
throw new Error('at_hash mismatch — tokens were not issued together');
}
}
Most high-level OIDC client libraries perform nonce and at_hash verification automatically. If you are writing low-level verification code, these checks are not optional for a correct implementation.