Website login gets messy when session cookies, JWTs, OAuth and OpenID Connect are treated as synonyms. They are not. Here is the model I use when a callback loops, a cookie vanishes, or a perfectly good token still gets a 401.
🎙️ Published & recorded:
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}
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.
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
issuer + subject. Validate signature, issuer, audience, expiry and nonce before trusting an ID token. Decoding its JSON is not validation.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
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
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.
next=https://attacker.example creates an open redirect that makes your trusted login domain useful in phishing.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
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.