backend / authentication / sso / 03_tokens_id_access_refresh.md

Tokens: ID, Access, Refresh

7 interview angles 7 min read source

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:

  1. Fetch the JWKS from <issuer>/.well-known/jwks.json (cached).
  2. Verify signature using the key whose kid matches the token header.
  3. Check iss matches the configured OP.
  4. Check aud is your client_id (may be array; check yours is in it).
  5. Check exp > now (with leeway for clock skew, e.g. 60s).
  6. Check nonce matches what you sent in the authorize request.
  7. Optionally check acr / amr if 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 aud is 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 (/login returns 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 accept alg: 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 aud is your client_id (the RP). Access token’s aud is 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, check iss matches OP, aud includes your client_id, exp > now (with skew), nonce matches 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.