August 21, 2026
Why Your Login Form Looks Nothing Like It Did in 1996
A guided tour from HTTP Basic Auth to OAuth 2.0 — and why every step had to happen

By Ahmed Osama
17 min read
Estimated reading time: 14 minutes
Every authentication method you've ever used was invented because the one before it broke.
That's the thread most tutorials drop. They hand you a list — Basic Auth, sessions, JWT, OAuth — as if it were a menu of equally valid options, and you pick one based on vibes or whatever your framework scaffolds by default. Then somebody asks why OAuth has that awkward two-step dance with a code you immediately trade for a token, and the honest answer is "because that's what the docs said."
There's a better answer. Each of these exists because someone got burned, and the fix became the next standard. Understand the burns and the whole progression stops being arbitrary. You start being able to derive the design instead of memorizing it.
So let's walk it. Basic Auth to OAuth 2.0, in the order history actually happened, with the failure that forced each move.
1996: Just Send the Password
HTTP Basic Authentication is as simple as it sounds. The server asks, the client answers:
GET /account HTTP/1.1
Authorization: Basic YWxpY2U6czNjcjN0GET /account HTTP/1.1
Authorization: Basic YWxpY2U6czNjcjN0That gibberish is base64("alice:s3cr3t"). Not encrypted. Encoded. Anyone who can see the request can decode it in one command:
echo 'YWxpY2U6czNjcjN0' | base64 -d
# alice:s3cr3techo 'YWxpY2U6czNjcjN0' | base64 -d
# alice:s3cr3tI want to dwell on this, because "Base64 is not encryption" is one of those facts everyone nods along to and then quietly forgets when it matters. Base64 has no key. It's a way of writing bytes using only safe characters. Reversing it is not an attack — it's the intended operation.
Over HTTPS, this is survivable. Over plain HTTP it's a disaster. And there are worse problems than the wire:
- The password is sent on every single request. Not once at login — every request. Maximum exposure, for the entire session.
- There is no logout. The browser caches the credentials for the realm. There's nothing to invalidate. Closing the tab is your best option.
- No expiry, no scopes, no revocation. It's total access, forever, or nothing.
- The UI is that grey browser dialog you can't style, brand, or explain.
Basic Auth survives today in the places those flaws don't bite: server-to-server API keys over TLS, internal tools, curl scripts. It's genuinely fine there. It's just not a login system for humans.
The attempted fix that nobody wanted
Before we move on: there was an intermediate step. HTTP Digest Authentication kept the password off the wire by sending a hash of it instead, mixed with server-supplied randomness.
It worked. It also required the server to store something password-equivalent — which means you cannot use bcrypt or Argon2 on it. You've traded a wire problem for a database problem, and the database problem is worse, because that's the one that ends up on a torrent site.
TLS solved eavesdropping more thoroughly and with less ceremony. Digest is interview trivia now.
The Session Cookie: Stop Sending the Password
Here's the first genuinely good idea, and it's almost embarrassingly simple.
Send the password once. Get back a random string. Send the random string from then on.
POST /login { email, password }
↓
verify, create session, store server-side
↓
Set-Cookie: sid=8f14e45fceea167a…; HttpOnly; Secure; SameSite=LaxPOST /login { email, password }
↓
verify, create session, store server-side
↓
Set-Cookie: sid=8f14e45fceea167a…; HttpOnly; Secure; SameSite=LaxThat cookie holds an opaque ID. All the real state — who you are, when you logged in, when it expires — lives on the server, in Redis or Postgres. The browser holds a claim ticket, not the coat.
Look at what that one change buys you:
- The password crosses the network once, at login.
- You can revoke. Delete the row; the session is dead instantly.
- You can expire it, slide the window, cap it.
- You can list sessions and offer "sign out everywhere."
- The login page is yours — brand it, add MFA to it, whatever.
The cookie flags are doing real work, and they're worth knowing individually:
HttpOnly — JavaScript can't read the cookie, which blunts token theft via XSS.
Secure — never sent over plain HTTP.
SameSite=Lax — not attached to cross-site POSTs, which blunts CSRF.
__Host- prefix— the browser itself enforces Secure, Path=/, and no Domain.
The bug hiding in every naive login handler
Here's a login handler that looks completely reasonable and contains a real vulnerability:
import session from 'express-session';
import { RedisStore } from 'connect-redis';
app.use(session({
store: new RedisStore({ client: redis }),
name: '__Host-sid',
secret: process.env.SESSION_SECRET, // signs the cookie
resave: false,
saveUninitialized: false, // don't create sessions for anonymous visitors
cookie: {
httpOnly: true,
secure: true,
sameSite: 'lax',
maxAge: 1000 * 60 * 60 * 24 * 14,
},
}));
app.post('/login', async (req, res) => {
const user = await findUser(req.body.email);
if (!user || !await verifyPassword(req.body.password, user.passwordHash)) {
return res.status(401).render('login', { error: 'Invalid credentials' });
}
req.session.userId = user.id; // ← the bug
res.redirect('/dashboard');
});import session from 'express-session';
import { RedisStore } from 'connect-redis';
app.use(session({
store: new RedisStore({ client: redis }),
name: '__Host-sid',
secret: process.env.SESSION_SECRET, // signs the cookie
resave: false,
saveUninitialized: false, // don't create sessions for anonymous visitors
cookie: {
httpOnly: true,
secure: true,
sameSite: 'lax',
maxAge: 1000 * 60 * 60 * 24 * 14,
},
}));
app.post('/login', async (req, res) => {
const user = await findUser(req.body.email);
if (!user || !await verifyPassword(req.body.password, user.passwordHash)) {
return res.status(401).render('login', { error: 'Invalid credentials' });
}
req.session.userId = user.id; // ← the bug
res.redirect('/dashboard');
});The session middleware already created a session for this visitor before they logged in. That line marks that existing session as authenticated — the same session ID they were carrying as an anonymous visitor.
Which enables session fixation:
1. Attacker visits your site, gets an anonymous session: sid=AAA
2. Attacker plants that ID in the victim's browser
(crafted link, subdomain XSS, MITM on plain HTTP)
3. Victim clicks it, lands on /login, signs in - carrying sid=AAA
4. Your server marks AAA as "authenticated as victim"
5. Attacker still holds AAA. They are now logged in as the victim.1. Attacker visits your site, gets an anonymous session: sid=AAA
2. Attacker plants that ID in the victim's browser
(crafted link, subdomain XSS, MITM on plain HTTP)
3. Victim clicks it, lands on /login, signs in - carrying sid=AAA
4. Your server marks AAA as "authenticated as victim"
5. Attacker still holds AAA. They are now logged in as the victim.The attacker never needed the password. They only needed the session ID to survive the login.
The fix is one call:
req.session.regenerate(err => { // old ID destroyed, new one minted
if (err) return res.status(500).end();
req.session.userId = user.id; // mark the NEW session authenticated
res.redirect('/dashboard');
});req.session.regenerate(err => { // old ID destroyed, new one minted
if (err) return res.status(500).end();
req.session.userId = user.id; // mark the NEW session authenticated
res.redirect('/dashboard');
});Note that the assignment moved inside the callback. Setting userId before regenerate() writes it to the session that's about to be destroyed — the user ends up not logged in at all. Classic bug, easy to introduce while "cleaning up" the code.
The general rule is broader than login: regenerate the session ID on every privilege change. Sign-in, sign-out, password change, MFA completion, entering admin mode.
Worth saying plainly: for a single-domain web app in 2026, server-side sessions are still the correct default. Everything after this section is about problems sessions don't solve. If you don't have those problems, you don't need those solutions.
An Interlude on Storing Passwords
We skipped past verifyPassword a little fast, and it's where most breaches turn from bad to catastrophic.
You know not to store plaintext. The subtler trap: not all hashing is equal, and the fast ones are the wrong ones.
MD5, SHA-1, and SHA-256 are designed to be fast. That's a feature for checksums and a catastrophe for passwords, because "fast" means an attacker with a GPU tries billions per second. You need a deliberately slow, memory-hard function: Argon2id first choice, scrypt or bcrypt acceptable.
The salt, and the thing people get wrong about it
A salt is a unique random value per user, mixed in before hashing. And here's the part that trips people up:
The salt is not a secret. It sits in your database, in plaintext, right next to the hash.
That sounds useless. It isn't — you just have to be precise about what it does. Without a salt, hashing is a pure function, so identical passwords produce identical hashes:
alice 5e884898da28047151d0e56f8dc62927...
bob 5e884898da28047151d0e56f8dc62927... ← identical to alice's
carol ef92b778bafe771e89245b89ecbc08a4...alice 5e884898da28047151d0e56f8dc62927...
bob 5e884898da28047151d0e56f8dc62927... ← identical to alice's
carol ef92b778bafe771e89245b89ecbc08a4...An attacker who dumps that table immediately knows three things:
-
Alice and Bob share a password. That alone is a leak.
-
They can crack the entire table at once — hash a candidate once, scan every row for it. Cracking 10 million users costs the same as cracking one.
-
They don't even need to compute. Rainbow tables for common passwords already exist, pre-built, free to download.
Add a per-user salt and all three collapse. Same password, different salts, completely different hashes — no correlation, no precomputation, and every hash must be attacked individually. Ten million users becomes ten million separate jobs. Combine that with Argon2id at ~100ms per attempt and bulk cracking stops being viable.
What a salt does not do: save a weak password from a targeted attack. If Alice's password is 123456, an attacker going after her specifically still wins. Salt defeats bulk cracking, not bad passwords. That's what slow hashing and breach-list screening are for.
You don't implement any of this yourself — Argon2, bcrypt, and scrypt generate the salt and embed it in the output:
$argon2id$v=19$m=19456,t=2,p=1$c29tZXNhbHQ$RdescudvJCsgt3ub+b+dWRWJTmaaJObG
└─ algo ─┘ └v┘└─ parameters ─┘ └─ salt ─┘ └────────── hash ──────────┘$argon2id$v=19$m=19456,t=2,p=1$c29tZXNhbHQ$RdescudvJCsgt3ub+b+dWRWJTmaaJObG
└─ algo ─┘ └v┘└─ parameters ─┘ └─ salt ─┘ └────────── hash ──────────┘Everything needed to verify is in that one string, which is why _argon2.verify(hash, password)_ takes no salt argument. It parses it back out.
The attack that hides in ===
One more, because it's the sort of thing that looks like paranoia until you see the math.
When you compare a session ID or an API key with ===, you have a timing attack. String comparison returns as soon as it finds a mismatch, so the runtime depends on how many leading characters were correct:
guess "xxxxxx" → 1 byte compared before bailing ▏
guess "s3xxxx" → 3 bytes compared ▍
guess "s3cr3t" → 6 bytes, match █guess "xxxxxx" → 1 byte compared before bailing ▏
guess "s3xxxx" → 3 bytes compared ▍
guess "s3cr3t" → 6 bytes, match █That's a prefix oracle, and it turns an exponential search into a linear one. Brute-forcing a 32-character secret is 25⁶³² attempts. With timing, it's about 32 × 256 ≈ 8,000 — from "heat death of the universe" to "an afternoon."
The usual objection is that these differences are nanoseconds, drowned in network jitter. That's the wrong instinct: jitter is random noise, the timing difference is a constant bias, and averaging enough samples pulls the bias right out. Published research has extracted sub-microsecond differences across real networks — and attackers are often closer than you'd like, in the same datacenter or a co-tenant VM.
The fix is to compare every byte every time:
crypto.timingSafeEqual(Buffer.from(a), Buffer.from(b));crypto.timingSafeEqual(Buffer.from(a), Buffer.from(b));Use it for session IDs, API keys, CSRF tokens, password reset tokens, and webhook signatures. You don't need it for _argon2.verify()_ or _bcrypt.compare()_ — those already compare in constant time internally.
The same idea shows up one level higher. If a wrong password takes 120ms (running Argon2) but a nonexistent email returns in 3ms (nothing to hash), your response time is a user enumeration oracle. Fix: hash against a dummy value even when the account doesn't exist, so both paths cost the same.
JWT: The One Everyone Reaches For Too Early
Sessions need shared server state. Once you have mobile clients, an SPA, and five backend services, that starts to chafe — so teams reach for stateless tokens, usually JWTs.
A JWT is three Base64URL chunks joined by dots: header, payload, signature. The payload holds claims:
{
"iss": "https://auth.ahmedosama.com",
"sub": "user_123",
"aud": "https://api.ahmedosama.com",
"exp": 1767225600,
"scope": "read:orders"
}{
"iss": "https://auth.ahmedosama.com",
"sub": "user_123",
"aud": "https://api.ahmedosama.com",
"exp": 1767225600,
"scope": "read:orders"
}The server signs it. Any service holding the public key can verify it without a database round-trip. That's the appeal, and it's real.
Now the part that gets skipped.
JWTs are signed, not encrypted. Anyone holding one can read every claim. Base64 again — decode it in your browser console. Don't put anything in there you wouldn't hand to the user.
You cannot revoke a purely stateless JWT. It's valid until _exp_. There's no row to delete, so "log out everywhere" isn't implementable — you can only wait it out. This is the trade people don't price in, and it's the main reason access tokens must be short: 5 to 15 minutes, so the un-revocable window stays small.
You can buy revocation back, and the standard hook is the ****_jti_**** claim — a unique ID per token (JWT ID). Add it to the payload, and now each token has something you can point at:
{
"sub": "user_123",
"jti": "b1f2c3d4-...", // unique per token
"exp": 1767225600
}{
"sub": "user_123",
"jti": "b1f2c3d4-...", // unique per token
"exp": 1767225600
}Then keep a denylist: on logout, write that _jti_ to Redis with a TTL equal to the token's remaining lifetime — it expires itself right when the token would have died anyway, so the list stays small. Every request checks it before honoring the token. For "log out everywhere," store a _family_id_ or a _tokens_valid_after_ timestamp per user and reject anything issued before it, rather than enumerating individual IDs.
The catch is the obvious one: you've reintroduced a lookup on every request, which is the exact thing statelessness was supposed to eliminate. The reasonable middle ground is that the check is a single Redis _EXISTS_ against a small set — far cheaper than loading a full session — and you only pay it where it matters. There's a real design space here (allowlists, denylists, _jti_ matchers) with genuinely different trade-offs.
I'm not going to do that comparison justice inside a section about something else — I wrote a separate post working through the strategies in detail if you want the depth.
The short version for here: stateless and revocable are the two ends of a dial, not a checkbox. Short expiry is how you avoid having to turn it.
The ****_alg_**** header is attacker-controlled. Two classic exploits: setting _alg: "none"_ (some libraries once accepted an unsigned token as valid), and taking an RS256 token, flipping it to HS256, and signing it with the public key — which the library then dutifully verifies with that same public key as the HMAC secret. Always pin the algorithm server-side:
const { payload } = await jwtVerify(token, JWKS, {
issuer: 'https://auth.ahmedosama.com',
audience: 'https://api.ahmedosama.com',
algorithms: ['ES256'], // pin it — never trust the header
});const { payload } = await jwtVerify(token, JWKS, {
issuer: 'https://auth.ahmedosama.com',
audience: 'https://api.ahmedosama.com',
algorithms: ['ES256'], // pin it — never trust the header
});And validate _aud_. A token minted for service A being accepted by service B is a real and common bug.
The honest summary: a JWT session in an
_HttpOnly_cookie is a session cookie with extra steps and worse revocation. Use JWTs for what they're genuinely good at — short-lived, cross-service assertions — and use sessions for browser login. "Stateless" is not a synonym for "better."
Refresh tokens, and the alarm you should wire up
Short access tokens mean frequent re-authentication, which users hate. So you pair them: a 10-minute access token plus a 30-day refresh token that buys new ones.
Which just relocates the problem. That refresh token is now the crown jewel — steal it, get a month of access.
Rotation with reuse detection is what makes theft survivable. Every refresh request returns a new refresh token and marks the old one used. Tokens from one login session share a _family_id_.
Now here's the elegant bit. If a token is stolen, both parties eventually try to refresh, and either ordering ends with the attacker locked out:
Case A — attacker refreshes first:
Attacker: RT1 → gets RT2 (RT1 now marked used)
User: RT1 → REJECTED, already used
└─► reuse detected → revoke the whole family, RT2 included.
Both logged out. User re-authenticates with their password.
Attacker can't.
Case B — user refreshes first:
User: RT1 → gets RT2
Attacker: RT1 → REJECTED, already used
└─► same outcome. Attacker dead.Case A — attacker refreshes first:
Attacker: RT1 → gets RT2 (RT1 now marked used)
User: RT1 → REJECTED, already used
└─► reuse detected → revoke the whole family, RT2 included.
Both logged out. User re-authenticates with their password.
Attacker can't.
Case B — user refreshes first:
User: RT1 → gets RT2
Attacker: RT1 → REJECTED, already used
└─► same outcome. Attacker dead.A used token showing up means two parties hold the same token. That's not an error to log — it's an alarm. Revoke the family, email the user, alert your security channel.
The cost is honest: in Case B a legitimate user gets logged out once. That's the trade — an occasional forced re-login for a theft window that shrinks from 30 days to "until whoever refreshes second."
Two implementation notes that bite people:
Store the hash, not the token. SHA-256 is fine here, unlike passwords — the token is already 256 bits of randomness, so there's nothing to brute-force and a slow KDF buys nothing.
De-duplicate refreshes on the client. Five parallel API calls hitting 401 at once will fire five refreshes; four of them present an already-rotated token and trip false reuse detection, logging out an innocent user. One shared promise fixes it:
let refreshPromise = null;
export async function refreshAccessToken() {
if (refreshPromise) return refreshPromise; // everyone awaits the same one
refreshPromise = doRefresh().finally(() => { refreshPromise = null; });
return refreshPromise;
}let refreshPromise = null;
export async function refreshAccessToken() {
if (refreshPromise) return refreshPromise; // everyone awaits the same one
refreshPromise = doRefresh().finally(() => { refreshPromise = null; });
return refreshPromise;
}The Problem That Actually Required OAuth
Everything so far has been about your users logging into your app. Now a genuinely different problem.
Your app, PhotoPrint, wants to read a user's Google Photos.
The 2006 answer was to ask for their Google password:
Enter your Google email and password so we can fetch your photos.
This is the password anti-pattern, and it fails on every axis at once:
- PhotoPrint now holds credentials to the user's entire Google account. Mail, documents, everything. There is no way to say "photos only."
- The user cannot revoke PhotoPrint without changing their Google password — which breaks every other app they did this with.
- MFA is impossible to represent in a form field.
- PhotoPrint must store a live password. Its breach is now Google's breach.
Nothing we've built so far helps. Sessions, JWTs, Argon2 — all of it assumes the user is authenticating to you. This is a different shape of problem: delegation. The user wants to grant a third party narrow, revocable access to data held somewhere else.
What we actually need:
-
The user authenticates at Google, never at PhotoPrint.
-
Google issues PhotoPrint a narrowly scoped, expiring, revocable credential.
-
That credential is useless for anything outside the granted scope.
That list is the OAuth design brief. Every awkward-looking part of the protocol traces back to one of those three lines.
OAuth 2.0, Derived Rather Than Memorized
Four roles, and once you name them the flow stops being arbitrary:
Resource Owner — the human. In our example, the user.
Client — the app that wants access. PhotoPrint.
Authorization Server — issues the tokens. Google's OAuth endpoints.
Resource Server — holds the data. The Google Photos API.
The flow you want is Authorization Code with PKCE. Here it is end to end:
User PhotoPrint Browser Google AS Photos API
| | | | |
| "Connect" | | | |
|----------->| make code_verifier | | |
| | challenge=S256(v) | | |
| | make state | | |
| |--- 302 /authorize->|---------------->| |
| | | login + consent| |
|<---------------------------------------------- (at Google) --------|
| approve | | | |
|-----------------------------------------------> | |
| | |<- 302 ? code=..&state=.. |
| |<-------------------| | |
| | verify state | | |
| |== POST /token: code + verifier ====> | (back channel) |
| |<= access_token, refresh_token ====== | |
| | |
| |------ GET /v1/photos Bearer <token> ---------------->|
┌──────────────── FRONT CHANNEL (browser redirects — visible) ───────────────┐
1. User clicks "Connect Google Photos"
2. PhotoPrint prepares two secrets and keeps them in the user's session:
code_verifier = random 32 bytes (kept private)
code_challenge = SHA-256(code_verifier) (safe to send)
state = random (CSRF guard)
3. PhotoPrint redirects the browser to Google:
302 → accounts.google.com/o/oauth2/v2/auth
?client_id=...
&redirect_uri=https://photoprint.example/callback
&scope=photos.readonly
&state=<state>
&code_challenge=<challenge> // The code challenge
&code_challenge_method=S256 // <= SHA256
4. User logs in AT GOOGLE and approves. ← PhotoPrint never sees this
password, MFA, none of it
5. Google redirects the browser back, carrying a short-lived code:
302 → https://photoprint.example/callback?code=<code>&state=<state>
└────────────────────────────────────────────────────────────────────────────┘
6. PhotoPrint checks the returned `state` matches the one it stored.
Mismatch → abort. (This is the CSRF check.)
┌──────────── BACK CHANNEL (server-to-server — attacker can't see) ──────────┐
7. PhotoPrint's SERVER calls Google directly — no browser involved:
POST oauth2.googleapis.com/token
grant_type=authorization_code
code=<code>
code_verifier=<the original 32 random bytes> ← proves PhotoPrint started this
client_id / client_secret
8. Google verifies SHA-256(code_verifier) == the challenge from step 3,
then returns the actual credentials:
{ access_token, refresh_token, expires_in }
└────────────────────────────────────────────────────────────────────────────┘
9. PhotoPrint calls the API with the access token:
GET photoslibrary.googleapis.com/v1/mediaItems
Authorization: Bearer <access_token>User PhotoPrint Browser Google AS Photos API
| | | | |
| "Connect" | | | |
|----------->| make code_verifier | | |
| | challenge=S256(v) | | |
| | make state | | |
| |--- 302 /authorize->|---------------->| |
| | | login + consent| |
|<---------------------------------------------- (at Google) --------|
| approve | | | |
|-----------------------------------------------> | |
| | |<- 302 ? code=..&state=.. |
| |<-------------------| | |
| | verify state | | |
| |== POST /token: code + verifier ====> | (back channel) |
| |<= access_token, refresh_token ====== | |
| | |
| |------ GET /v1/photos Bearer <token> ---------------->|
┌──────────────── FRONT CHANNEL (browser redirects — visible) ───────────────┐
1. User clicks "Connect Google Photos"
2. PhotoPrint prepares two secrets and keeps them in the user's session:
code_verifier = random 32 bytes (kept private)
code_challenge = SHA-256(code_verifier) (safe to send)
state = random (CSRF guard)
3. PhotoPrint redirects the browser to Google:
302 → accounts.google.com/o/oauth2/v2/auth
?client_id=...
&redirect_uri=https://photoprint.example/callback
&scope=photos.readonly
&state=<state>
&code_challenge=<challenge> // The code challenge
&code_challenge_method=S256 // <= SHA256
4. User logs in AT GOOGLE and approves. ← PhotoPrint never sees this
password, MFA, none of it
5. Google redirects the browser back, carrying a short-lived code:
302 → https://photoprint.example/callback?code=<code>&state=<state>
└────────────────────────────────────────────────────────────────────────────┘
6. PhotoPrint checks the returned `state` matches the one it stored.
Mismatch → abort. (This is the CSRF check.)
┌──────────── BACK CHANNEL (server-to-server — attacker can't see) ──────────┐
7. PhotoPrint's SERVER calls Google directly — no browser involved:
POST oauth2.googleapis.com/token
grant_type=authorization_code
code=<code>
code_verifier=<the original 32 random bytes> ← proves PhotoPrint started this
client_id / client_secret
8. Google verifies SHA-256(code_verifier) == the challenge from step 3,
then returns the actual credentials:
{ access_token, refresh_token, expires_in }
└────────────────────────────────────────────────────────────────────────────┘
9. PhotoPrint calls the API with the access token:
GET photoslibrary.googleapis.com/v1/mediaItems
Authorization: Bearer <access_token>The question everyone asks: why the two-step? Why hand back a _code_ that immediately gets traded for a token, instead of just returning the token?
Because the first leg travels through the browser — a redirect, visible in the URL bar, in browser history, in Referer headers, in server logs, and to any script on the page. The second leg is a direct server-to-server HTTPS call. The code is deliberately near-useless on its own: single-use, expires in seconds, and worthless without the client secret or PKCE verifier. The actual credential only ever travels the channel an attacker can't watch.
That's the whole design. Front channel carries a claim ticket; back channel carries the goods.
Every parameter is a scar
****_state_**** stops CSRF on the callback. Without it, an attacker obtains their own authorization code and tricks your browser into redeeming it — silently linking their account to your session. Everything you upload afterwards goes to them.
****_code_challenge_**** / ****_code_verifier_**** (PKCE) stops code interception. On mobile, a malicious app can register the same custom URL scheme and receive the redirect. Without PKCE it redeems the code. With PKCE it can't — it never had the verifier.
Exact ****_redirect_uri_**** matching stops code exfiltration to an attacker-controlled URL. This is why wildcards are forbidden.
****_nonce_**** stops ID token replay in OIDC, by binding the token to this specific request.
PKCE (Proof Key for Code Exchange) is worth one more sentence because the "P" makes it sound optional. The client generates a random code_verifier, sends only its SHA-256 hash up front, and reveals the original when redeeming the code. Anyone who intercepts the code has the hash but not the preimage. It used to be mobile-only advice; the current security BCP says use it everywhere, public and confidential clients alike.
Three things go wrong most often in real implementations:
-
Loose
redirect_urimatching. Must be exact string comparison against a pre-registered value. -
****_state_****checked for presence but not bound to the session. Then it stops nothing. -
Refresh token handed to the browser. Once it's readable by JS, XSS means permanent account access.
The grants that got removed
OAuth 2.1 formally kills two:
- Implicit returned the access token directly in the URL fragment — landing it in history,
_Referer_headers, and every script on the page, with no client authentication at all. Code + PKCE does the same job without the exposure. - Resource Owner Password Credentials (
_grant_type=password_) is the password anti-pattern wearing a standards badge. The client sees the actual password. It can't support MFA or federation.
If a tutorial recommends either, it predates 2020. Move on.
The Last Twist: OAuth Doesn't Log Anyone In
Here's the thing that trips up nearly everyone, and it's a decent interview question.
OAuth 2.0 is an authorization protocol. It does not authenticate users.
It answers "may this app access that resource?" — not "who is this person?" Using an access token as proof of identity is broken: it's opaque to the client, it carries no audience meaningful to you, and it can be replayed from a different app. That's the classic confused deputy problem. App A gets a token from a user, hands it to your app, and your app concludes "this must be App A's user."
OpenID Connect is the thin identity layer that fixes exactly this. Add _scope=openid_ and you get back an ID token: a JWT about the authentication event, minted specifically for your client.
{
"iss": "https://accounts.google.com",
"sub": "110169484474386276334", // stable user ID at this issuer
"aud": "1234.apps.googleusercontent.com", // YOUR client_id
"exp": 1767225600,
"nonce": "n-0S6_WzA2Mj",
"email": "alice@example.com",
"email_verified": true
}{
"iss": "https://accounts.google.com",
"sub": "110169484474386276334", // stable user ID at this issuer
"aud": "1234.apps.googleusercontent.com", // YOUR client_id
"exp": 1767225600,
"nonce": "n-0S6_WzA2Mj",
"email": "alice@example.com",
"email_verified": true
}_aud_ is the fix. A token minted for someone else's client simply fails your validation.
Two rules when you consume one:
Validate it fully — signature against the issuer's JWKS, plus _iss_, _aud_, _exp_, and _nonce_. Skipping any of these reopens a real attack.
Key your users off ****_(iss, sub)_****, never off email. Emails change hands and _email_verified_ is sometimes false. _sub_ is the stable identifier.
So: "Sign in with Google" is OIDC. "Let this app read my Drive" is OAuth. Same handshake, different artifact, and conflating them is how confused-deputy bugs get shipped.
Where That Leaves You
The whole progression, compressed:
1996 Basic Auth password on every request, no logout, no scopes
1999 Digest forced password-equivalent storage; TLS won instead
2000s Sessions still correct — just doesn't cross domains
2010s JWT can't revoke; footguns in alg and aud
2010s OAuth 1.0a secure, but signature hell
NOW OAuth 2.0 + PKCE / OIDC1996 Basic Auth password on every request, no logout, no scopes
1999 Digest forced password-equivalent storage; TLS won instead
2000s Sessions still correct — just doesn't cross domains
2010s JWT can't revoke; footguns in alg and aud
2010s OAuth 1.0a secure, but signature hell
NOW OAuth 2.0 + PKCE / OIDCAnd the practical decision:
Is a human logging in?
├── No → OAuth 2.0 Client Credentials
└── Yes
├── Only into your app, one domain?
│ └── Session cookies + Argon2id + passkeys. Not JWT.
├── "Sign in with Google/Apple/GitHub"?
│ └── OpenID Connect (Authorization Code + PKCE)
├── Your app acting on data in a third-party service?
│ └── OAuth 2.0 Authorization Code + PKCE, narrow scopes
└── No browser (TV, CLI)?
└── Device Authorization GrantIs a human logging in?
├── No → OAuth 2.0 Client Credentials
└── Yes
├── Only into your app, one domain?
│ └── Session cookies + Argon2id + passkeys. Not JWT.
├── "Sign in with Google/Apple/GitHub"?
│ └── OpenID Connect (Authorization Code + PKCE)
├── Your app acting on data in a third-party service?
│ └── OAuth 2.0 Authorization Code + PKCE, narrow scopes
└── No browser (TV, CLI)?
└── Device Authorization GrantThree things I'd want you to take away:
Sessions are not obsolete. The industry's enthusiasm for JWT has convinced a lot of people that cookies are legacy. They aren't. For a single-domain web app, server-side sessions remain the better engineering choice, and "stateless" is a trade-off, not an upgrade.
OAuth is about delegation, not login. If nobody is granting a third party access to something, you may not need OAuth at all. And if you are doing login through it, you want OIDC and the ID token — not an access token you've decided to treat as identity.
Every weird parameter is a scar. _state_, _nonce_, PKCE, exact redirect matching — none of it is ceremony. Each one is a specific attack somebody ran on somebody else's users first. That's why you can't safely skip the ones that seem redundant.
The forward-looking note: passkeys (WebAuthn) are the first thing in decades that might actually retire the password. Public-key credentials bound to an origin, which makes them phishing-resistant by construction — the browser will not release a credential registered for example.com to examp1e.com. No shared secret means nothing to breach, phish, or reuse.
// Registration (browser)
const cred = await navigator.credentials.create({
publicKey: {
challenge: serverChallenge, // random, from your server
rp: { name: 'Example App', id: 'example.com' },
user: { id: userIdBytes, name: 'alice@example.com', displayName: 'Alice' },
pubKeyCredParams: [{ type: 'public-key', alg: -7 }, { type: 'public-key', alg: -257 }],
authenticatorSelection: { residentKey: 'required', userVerification: 'preferred' },
attestation: 'none',
},
});
// POST cred to the server; store credentialId + publicKey + signCount
// Authentication (browser)
const assertion = await navigator.credentials.get({
publicKey: { challenge: serverChallenge, rpId: 'example.com', userVerification: 'preferred' },
});
// Server verifies the signature against the stored public key,
// and checks that clientData.origin and challenge match.// Registration (browser)
const cred = await navigator.credentials.create({
publicKey: {
challenge: serverChallenge, // random, from your server
rp: { name: 'Example App', id: 'example.com' },
user: { id: userIdBytes, name: 'alice@example.com', displayName: 'Alice' },
pubKeyCredParams: [{ type: 'public-key', alg: -7 }, { type: 'public-key', alg: -257 }],
authenticatorSelection: { residentKey: 'required', userVerification: 'preferred' },
attestation: 'none',
},
});
// POST cred to the server; store credentialId + publicKey + signCount
// Authentication (browser)
const assertion = await navigator.credentials.get({
publicKey: { challenge: serverChallenge, rpId: 'example.com', userVerification: 'preferred' },
});
// Server verifies the signature against the stored public key,
// and checks that clientData.origin and challenge match.If you're building new login in 2026: passkeys first, password as fallback, OIDC for federation. And if you're building an OAuth provider rather than consuming one — don't. Use Keycloak, Ory Hydra, Auth0, or WorkOS. That's a multi-year security commitment, not a sprint.
If you want to go deeper
The specs are more readable than their reputation: RFC 6749 (OAuth 2.0), RFC 7636 (PKCE), RFC 9700 (Security BCP), and OpenID Connect Core 1.0. For password specifics, NIST SP 800–63B — the source of "stop forcing 90-day rotation," which is still news in a lot of organizations.
And build the broken versions yourself. Implement login without state and write the CSRF attack. Store a password with SHA-256 and crack it with hashcat, then switch to Argon2id and watch the same attack die. Nothing makes these designs stick like watching them fail.
If this was useful, the thing I'd most like you to remember is the framing: every one of these was a response to a specific failure. When you hit an auth design decision that seems arbitrary, ask what attack it's a scar from. There's almost always an answer.