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"
}.signature
What 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_value
Response:
{
"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: stolen RT is detected on first parallel use, family revoked, user is forced to re-authenticate.
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 — the security debate
For browser apps:
| Storage | XSS-safe? | CSRF-safe? | Notes |
|---|---|---|---|
localStorage / sessionStorage |
NO | yes | JS can read; one XSS leaks everything |
| memory (JS variable) | yes (lost on reload) | yes | needs silent re-auth on refresh |
| HttpOnly cookie | yes (JS can’t read) | no (needs CSRF protection) | the recommended pattern for web apps |
| Service worker scope | yes (with care) | yes | advanced |
The current consensus for SPAs talking to APIs:
- Refresh token in HttpOnly cookie set by your backend (
/loginreturns Set-Cookie). - Access token in memory only.
- On API call, frontend hits your backend’s proxy which adds the access token; or refreshes silently via the cookie when needed.
This pattern (sometimes called “BFF” — Backend For Frontend) avoids the SPA ever touching the refresh token.
Lifetime tuning
Conservative defaults:
| Token | Lifetime |
|---|---|
| Access token | 5–15 minutes |
| ID token | matches session (15 min – 1 hr) |
| Refresh token | 8 hours – 30 days (with rotation) |
| Session ID (your app’s cookie) | matches access token; auto-refresh in background |
Shorter = better security (less exposure window). Longer = better UX (fewer re-auths).
For high-security apps (banking, admin consoles): access 5 min, refresh 8 hr (sliding window). For consumer apps: access 1 hr, refresh 30 days.
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_token
Effect:
- 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.signature
Header:
{ "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 ../../25_security/06_jwt_pitfalls.md 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.
Interview angle
- “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.