Tokens: ID, Access, Refresh
Three OIDC tokens, three jobs. Mixing them up is the most common interview slip. Each has a specific audience, format expectation, and lifetime.
The cheat sheet
| ID token | Access token | Refresh token | |
|---|---|---|---|
| Purpose | tell your app who the user is | call APIs on the user’s behalf | get new access tokens |
Audience (aud) |
your client_id | the API (resource server) | (opaque to client) |
| Format | always JWT | may be JWT or opaque | usually opaque |
| Sent to | your app only — never to APIs | resource servers (APIs) | only to the token endpoint |
| Lifetime | minutes to hour | minutes (5–60) | hours to days/weeks |
| Where to store | parse and discard; keep session | secure (memory, HttpOnly cookie) | very secure (HttpOnly cookie, secure storage) |
ID token — “the user is this person, who logged in this way, at this time”
eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiIsImtpZCI6ImFiYzEyMyJ9.
{
"iss": "https://idp.example.com",
"sub": "248289761001",
"aud": "myapp_client_id",
"exp": 1736251200,
"iat": 1736247600,
"nbf": 1736247600,
"auth_time": 1736247500,
"nonce": "n-0S6_WzA2Mj",
"acr": "urn:mace:incommon:iap:silver",
"amr": ["pwd", "mfa"],
"email": "alice@example.com",
"email_verified": true,
"name": "Alice Smith"
}.signatureWhat the RP (your app) does:
- Fetch the JWKS from
<issuer>/.well-known/jwks.json(cached). - Verify signature using the key whose
kidmatches the token header. - Check
issmatches the configured OP. - Check
audis yourclient_id(may be array; check yours is in it). - Check
exp> now (with leeway for clock skew, e.g. 60s). - Check
noncematches what you sent in the authorize request. - Optionally check
acr/amrif you required MFA.
After verification, parse claims and create a local session. The ID token is for your app’s eyes only.
Access token — “this principal has these permissions for this resource server”
Used to call APIs:
GET /api/me HTTP/1.1
Host: api.example.com
Authorization: Bearer eyJ...Two formats:
JWT access token
Self-contained — the resource server can validate locally with the OP’s public key.
{
"iss": "https://idp.example.com",
"sub": "248289761001",
"aud": "api.example.com", // the resource server
"azp": "myapp_client_id", // authorized party — the client that requested it
"exp": 1736251200,
"scope": "openid email read:posts write:posts",
"client_id": "myapp_client_id"
}The API checks: signature, iss, aud (must be the API), exp, scope (does the requested operation need a scope present here?).
Opaque access token
Random-looking string. Resource servers can’t validate locally — they call introspection:
POST /introspect HTTP/1.1
Host: idp.example.com
Authorization: Basic <client_id:client_secret of the resource server>
Content-Type: application/x-www-form-urlencoded
token=opaque_token_valueResponse:
{
"active": true,
"scope": "read:posts",
"client_id": "myapp_client_id",
"exp": 1736251200,
"sub": "248289761001"
}Introspection adds a round trip per request unless cached. Some OPs (Okta) cache aggressively; others recommend short cache TTLs.
JWT vs opaque trade-off:
| JWT | Opaque | |
|---|---|---|
| Validation cost | local crypto | network call to OP |
| Revocation | hard (token valid until expiry) | easy (introspection returns active: false) |
| Size | larger (claims in payload) | small |
| Privacy | claims visible to anyone with token | only OP knows what the token represents |
For internal microservices: JWT (fast). For privileged ops where revocation matters: opaque with introspection (or short-lived JWTs).
Refresh token — for getting fresh access tokens
POST /token HTTP/1.1
grant_type=refresh_token&refresh_token=<rt>&client_id=...&client_secret=...Response:
{
"access_token": "new_access_token",
"refresh_token": "new_refresh_token", // if rotation enabled
"expires_in": 3600,
"token_type": "Bearer"
}Refresh token rotation
Best practice: each refresh issues a NEW refresh token; the old one becomes invalid. If both old and new are presented (token replay by attacker), the OP detects this and revokes the entire token family.
Without rotation: a stolen refresh token works until expiry — could be days.
With rotation: a stolen RT is detected when both holders redeem from the same family; the family is revoked and the user re-authenticates. If the victim stays idle, nothing is presented twice, and the attacker keeps a live chain until the absolute or idle expiry. Details in Refresh Token Rotation.
Where refresh tokens go
| Client type | Where to store RT |
|---|---|
| SPA | HttpOnly, Secure, SameSite cookie (set by your backend, not by JS) |
| Native mobile | secure storage (Keychain, Keystore) |
| Confidential web app | server-side (DB, encrypted) |
| CLI | per-user file with restrictive perms |
The RT is essentially persistent auth. Treat it like a password.
offline_access scope
To request a refresh token in an OIDC flow, include scope=offline_access. Some OPs return RTs automatically; others require this scope explicitly.
Why not use the ID token as an access token?
Tempting, but wrong:
- Audience mismatch: ID token’s
audis your client_id, not the API. The API should reject it. - Claims overshare: ID token may include PII (email, name); access token shouldn’t.
- Lifetime: ID tokens are typically short; access tokens are tuned to API needs.
Always use the access token for API calls. ID token is for your app’s local session decisions.
Token storage and lifetimes
Both are covered once, in Access and Refresh Tokens, with one lifetime table and the IdP defaults. The short version: no browser storage makes a token XSS-safe — an in-memory token is readable by injected script, and an HttpOnly refresh cookie can be ridden to mint fresh access tokens. “Refresh token in an HttpOnly cookie, access token in memory” is RFC 10017’s token-mediating backend, not a BFF; in a BFF the browser holds no OAuth token at all.
Token revocation
OAuth 2.0 Token Revocation (RFC 7009) — the OP exposes a /revoke endpoint:
POST /revoke HTTP/1.1
Authorization: Basic <client creds>
token=<access_or_refresh_token>&token_type_hint=refresh_tokenEffect:
- Refresh tokens: revoked immediately. Subsequent uses fail.
- Access tokens: depends on OP. JWTs may stay valid until expiry; opaque may be invalidated immediately via introspection.
Call this on logout to make refresh tokens unusable. For “log out everywhere,” the user-level operation is at the OP (Okta dashboard, etc.).
Token introspection (RFC 7662)
For opaque access tokens, or to check “is this still valid”:
POST /introspect HTTP/1.1
Authorization: Basic <resource_server_creds>
token=<token>Returns:
{
"active": true,
"scope": "read:posts",
"client_id": "myapp",
"exp": 1736251200,
"iat": 1736247600,
"sub": "248289761001",
"aud": "api.example.com"
}If active: false, the token is invalid (expired, revoked, never existed).
Privacy note: introspection requires the resource server to authenticate. Don’t expose introspection publicly.
Token format vs structure — JWT internals quick
JWT = three base64url-encoded parts joined by dots:
header.payload.signatureHeader:
{ "alg": "RS256", "typ": "JWT", "kid": "abc123" }alg: signing algorithm — RS256 (RSA + SHA-256) most common, ES256 (ECDSA) growing. Never acceptalg: none.kid: key ID — tells you which JWKS key to verify with.
Payload: JSON with claims.
Signature: signs base64(header) + "." + base64(payload) with the algorithm in alg.
See JWT and the JOSE Family and JWT pitfalls for JWT deep dives.
Common interview confusions
- “The access token tells you who the user is.” — sort of, via
sub. But the access token’s purpose is authorization; the ID token is the identity statement. Don’t mix roles. - “You can decode a JWT in the browser to read claims.” — yes, base64 decode the middle part. You haven’t validated anything. Validation requires signature check.
- “Refresh tokens are stored client-side.” — yes, but only in secure storage. HttpOnly cookies (web), Keychain (mobile), encrypted disk (desktop). Never localStorage.
Related
Interview angle 7
- “What’s the difference between ID token, access token, and refresh token?” — ID token: identity statement, always JWT, audience=your app. Access token: API access grant, may be JWT or opaque, audience=resource server. Refresh token: long-lived, used to get new access tokens without re-auth.
- “What audience does each token have?” — ID token’s
audis your client_id (the RP). Access token’saudis the resource server / API. Refresh token is opaque to the client. - “How do you validate an ID token?” — fetch JWKS, find key by
kid, verify signature, checkissmatches OP,audincludes your client_id,exp> now (with skew),noncematches what you sent. - “JWT access token vs opaque — when each?” — JWT for internal microservices where revocation isn’t critical (fast local validation). Opaque + introspection for high-security where you need immediate revocation.
- “What’s refresh token rotation and why use it?” — each refresh issues a new RT; the old one becomes invalid. If an attacker steals an RT and uses it, the legitimate user’s next refresh fails — alarm. Detects token theft.
- “Where do you store an access token in an SPA?” — in memory only (lost on reload, silent re-auth via cookie-stored RT). The refresh token sits in an HttpOnly Secure cookie set by your backend. localStorage is XSS-vulnerable.
- “Can you use an ID token to call an API?” — no. ID token’s audience is your app, not the API. API should reject. Use the access token for API calls.