01Session vs JWT: choose boring first
For a normal website, I would start with an opaque session ID in an HttpOnly cookie. The server stores the session and can revoke it instantly. A JWT is a signed bundle of claims, useful when several services must verify the same short-lived credential without a shared session store. It is not a deluxe session cookie.
# Opaque session cookie: meaningless to the browser
Set-Cookie: __Host-session=s%3A8f1...; Path=/; Secure; HttpOnly; SameSite=Lax
# JWT payload is readable, not encrypted
{"sub":"user_42","aud":"api","exp":1784905200}
One web app and one backend: use server-side sessions. “Stateless” JWT logout usually grows a revocation list, at which point you rebuilt state with worse ergonomics. Never put secrets in a JWT payload; base64 is not encryption.
02The four OAuth roles
OAuth is delegated authorization. The resource owner is the user. The client is your app. The authorization server asks for consent and issues tokens. The resource server is the API accepting those tokens. “Client” does not mean browser, and the authorization server does not have to host the API.
resource owner: the person
client: your photo-printing site
authorization server: accounts.example
resource server: photos API
scope: permission such as photos.read
Scopes describe what the client may do, not what the person may do. A token with photos.read still must not read somebody else's album. The API checks both scope and ownership.
03OAuth is not an authentication protocol
OAuth does not tell your app who logged in. It delegates access to something. OpenID Connect adds the identity layer: an ID token, a UserInfo endpoint, discovery metadata and rules for validating identity claims. “Sign in with…” should use OIDC, not invent identity by poking an OAuth profile endpoint.
# Access token: for the API; audience is the resource server
Authorization: Bearer <access_token>
# ID token: for the client; establishes the login event
iss = https://accounts.example
aud = your_client_id
sub = stable-provider-user-id
nonce = value-bound-to-this-login
Email can change and may be recycled. Store the pair
issuer + subject. Validate signature, issuer, audience, expiry and nonce before trusting an ID token. Decoding its JSON is not validation.04Authorization Code plus PKCE
The current practical flow sends the browser to the authorization server, receives a short-lived one-time code, and exchanges that code through a back channel. PKCE ties the exchange to the app instance that started it. The app creates a random verifier, sends its hash as the challenge, then proves possession by sending the verifier at token exchange.
1. app stores code_verifier + state + nonce
2. /authorize?response_type=code&code_challenge=HASH&code_challenge_method=S256
3. callback?code=ONE_TIME_CODE&state=...
4. POST /token with code + code_verifier
5. validate ID token; create your own app session
Do not use the implicit flow for new browser apps. Do not ship a client secret in JavaScript; everybody can read it. PKCE protects a public client without pretending a browser can keep a secret.
06Refresh tokens are high-value credentials
Access tokens should be short-lived. A refresh token obtains new ones without asking the user to sign in again, which makes it the more valuable credential. Keep it on a trusted backend or in a tightly protected cookie design, rotate it on use, and revoke the token family when an old rotated token appears again.
POST /oauth/token
grant_type=refresh_token
refresh_token=<secret>
client_id=<client>
response: access_token + new_refresh_token
store the new refresh token; invalidate the old one
Set an absolute session lifetime as well as idle expiry. Rotation limits replay; it does not turn a stolen token into a harmless token. On password reset, account recovery or suspected theft, revoke server-side sessions and refresh-token families.
07CSRF: the browser sends cookies helpfully
CSRF works because a browser may attach your site's cookie to a request initiated by another site. SameSite helps, but login flows also need a random state value bound to the browser session. On callback, compare it exactly and consume it once. State is not decoration and it is not the return URL.
OAuthCallbackError: state mismatch
# Diagnose before retrying:
1. Was state stored before redirect?
2. Did the same browser/session return?
3. Did a proxy change host or scheme, losing the cookie?
4. Was the callback opened twice or state consumed early?
# Fix the session/cookie/proxy issue. Never skip state validation.
If you preserve “where to go after login,” allow only local paths or an explicit allowlist. A raw
next=https://attacker.example creates an open redirect that makes your trusted login domain useful in phishing.08XSS changes the storage decision
A token in localStorage is readable by any JavaScript that runs on the page, including a compromised dependency or injected script. An HttpOnly cookie hides the credential from JavaScript, though malicious JavaScript can still make requests while the page is compromised. There is no storage trick that makes XSS acceptable.
# Avoid this default for website login
localStorage.setItem("access_token", token)
# Prefer a backend-for-frontend session
browser --HttpOnly session cookie--> your backend
backend --access token--> provider/API
Escape untrusted output, sanitize intentional HTML, avoid inline script injection, deploy a restrictive Content Security Policy, and audit dependencies. HttpOnly reduces token theft; it does not clean a compromised page.
09Callback errors: compare exact bytes
OAuth callback failures are usually precise, not mystical. Providers compare redirect URIs exactly. Scheme, host, port, path and sometimes trailing slash must match the registered value. After that, authorization codes are short-lived, single-use and tied to the client, redirect URI and PKCE verifier.
Error 400: redirect_uri_mismatch
# Sent: http://localhost:3000/auth/callback
# Registered: http://localhost:3000/auth/callback/
# Fix the registration or generated URI so they match exactly.
{"error":"invalid_grant","error_description":"Bad Request"}
# Common causes: code reused/expired, wrong code_verifier,
# wrong redirect_uri, clock skew, or code issued to another client.
Log a request correlation ID, provider error code, callback URI, client ID and timestamps. Never log the code, verifier, tokens or client secret. Restarting the flow is fine for an expired code; it will not fix a deterministic URI mismatch.