Opaque session IDs vs self-contained JWTs
Two shapes for "you are signed in":
- Opaque session ID: a random string, looked up server-side against a store. Revoking is one delete. The store has to be highly available. Stateless services are harder.
- Self-contained token (JWT): signed payload carrying identity and claims. Stateless verification. Revoking before expiry requires a denylist (which reintroduces the store), and any leaked token is valid until it expires.
Rule of thumb:
- If you control both ends and have low session count: opaque IDs, period.
- If you serve many services that must accept tokens without a central call per request: short-lived JWTs (5 to 15 minutes), paired with refresh tokens.
- Never long-lived JWTs as session cookies. The blast radius of a leak is too large.
Refresh tokens and rotation
The standard pattern: a short-lived access token (minutes) plus a long-lived refresh token (hours to days). When the access token expires, the client posts the refresh token to the authorization server and gets a new pair.
Two critical defenses:
- Rotation: each refresh exchange returns a new refresh token and invalidates the old one. Refresh tokens are one-time-use.
- Family revocation: if a refresh token is presented twice, the server has detected token theft. Revoke the entire token family (the original and every descendant) and force the user to re-authenticate.
Without rotation, a stolen refresh token grants indefinite access. Without family revocation, an attacker can use a stolen token once even after the legitimate user rotates it.
PKCE on every public client
PKCE (Proof Key for Code Exchange) protects the authorization code from interception. The
client generates a high-entropy code_verifier, sends its SHA-256 hash
(code_challenge) with the auth request, and presents the verifier when
exchanging the code for tokens. An attacker who intercepts the code can't redeem it without
the verifier.
Originally designed for mobile apps. The current OAuth security best-current-practice (RFC 9700) recommends PKCE for every client, including confidential server-side apps. The cost is negligible; the defense-in-depth is real.
The four attacks that recur
-
Open redirect on the redirect_uri. The auth server allows any URI that matches a
loose registration pattern; the attacker sends the user to
https://app.example.com/redirect?next=https://evil.example; the auth server bounces back through evil.example carrying the code. Fix: exact-match redirect URIs only. - Authorization-code interception. On mobile, two apps register the same custom scheme; one steals codes intended for the other. Fix: PKCE plus, where possible, app-claimed universal links / app links.
-
OAuth mix-up. The client supports multiple identity providers and does not bind the
token response to the IDP that issued it. An attacker who controls a legitimate-but-malicious
IDP gets the client to send another IDP's code to it. Fix: include
issin the authorization response and verify it. -
IDP confusion / IDP-mix-up. Variant of the above where the attacker's IDP returns
claims for another user's account. Fix: bind
subclaims to a specificiss; do not match users across IDPs by email alone.
Logout in a multi-tab world
Logout has gotten complicated. Three layers:
- Local logout: drop the session cookie in this browser. Easy.
- Global logout: tell the auth server to revoke active tokens. The OAuth back-channel logout spec defines this.
- Cross-tab: a logout in one tab should propagate to others. Use the
storageevent or BroadcastChannel API to notify open tabs.
Federated identity layers complicate further: logging out of your app does not log you out of Google. The session at the IDP is separate. For high-security applications, prompt the user before reusing an existing IDP session.
Boring is good
For a typical web app: opaque session cookies with HttpOnly + Secure + SameSite=Lax, rotating refresh tokens for API access, PKCE everywhere, exact redirect URIs, and one round of threat modelling against the four attacks above. Boring is the goal.