OAuth Login, Without the Token Soup

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:

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}
My default
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
Do not key users by email
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
Skip the old shortcuts
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.

05Cookie attributes decide whether login sticks

HttpOnly keeps JavaScript from reading the cookie. Secure limits it to HTTPS. SameSite=Lax is a sensible website default and still permits a top-level OAuth callback. The __Host- prefix requires Secure, Path=/ and no Domain, which prevents a sibling subdomain from planting that cookie.

Set-Cookie: __Host-session=abc123; Path=/; Secure; HttpOnly; SameSite=Lax

This Set-Cookie was blocked because it had the "SameSite=None"
attribute but did not have the "Secure" attribute.
# Fix production: add Secure and serve HTTPS.
# For local HTTP, prefer Lax on localhost; don't weaken production settings.

If login succeeds and the next request looks logged out, inspect the callback response in browser DevTools. Check whether the cookie was set, blocked, scoped to the wrong host, or omitted on the next request. Re-running OAuth will not repair a Domain or SameSite mistake.

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
Do not refresh forever
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.
Safe return paths
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
The practical defense
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.
Debug once, not by retry storm
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.

10A 401 token playbook

A 401 after successful login does not prove login failed. Your app may be sending an ID token to an API, an access token for the wrong audience, an expired token, or nothing at all. Inspect what the resource server expects. Treat token contents as sensitive even while debugging.

HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer error="invalid_token",
  error_description="The audience 'web-client' is invalid"

1. Is the Authorization header present and exactly "Bearer <token>"?
2. Is this an access token, not an ID token?
3. Do iss and aud match this API?
4. Are exp/nbf valid with modest clock skew?
5. Does the API trust the token's signing key and algorithm?
6. If valid but disallowed, that is normally 403: inspect scopes/roles.
Final architecture
Use OIDC to establish identity, then create your own application session. Keep provider access and refresh tokens server-side unless the browser truly must call that provider directly. For an ordinary website, boring cookies plus a backend are easier to revoke, audit and explain at 3 a.m.

That is the whole working model: OAuth delegates access, OIDC supplies identity, cookies carry your app session, and each token has one intended audience. Mixing those jobs is where the bugs start.