OAuth2 and OIDC in Plain Words
The founding delegation story, the cast renamed into English, the one flow that survived (auth code + PKCE) with every step's attack labeled, ID vs access tokens untangled, and the do-you-even-need-this triage.
OAuth is the most implemented and least understood protocol in web development. Most engineers' knowledge is a cargo-culted "Login with Google" tutorial plus a vague fear of the terminology — grants, flows, audiences, PKCE — that reads like tax law. The tragedy is that the underlying ideas are genuinely simple; they're just buried under a decade of spec archaeology. Let me dig them out in plain words, with the security decisions labeled.
The founding problem, in one story
2010: a photo-printing site wants your Gmail contacts, so it asks for… your Gmail password. Full access, forever, to everything — to read one address book. OAuth2 exists to replace that horror with delegation: you authenticate at Google, approve a specific scope ("read contacts"), and the printing site receives a limited, revocable, expiring token instead of the keys to your life. Every OAuth concept traces back to this: never share credentials with the party that wants access; issue scoped tokens instead.
The cast, renamed into English
- Resource owner — the human. You.
- Client — the app that wants access (the printing site, your SPA, your mobile app).
- Authorization server — the trust anchor that authenticates humans and mints tokens (Google; or your own — Keycloak, Auth0, Laravel Passport, Duende).
- Resource server — the API holding the goods, which accepts tokens instead of passwords.
The one flow that matters: authorization code + PKCE
OAuth2's zoo of grants has been culled by a decade of incidents; current best practice (OAuth 2.1 makes it official) is essentially one flow for user-facing apps:
1. Client generates a random secret (code_verifier), hashes it (code_challenge)
2. Browser → auth server: "user wants in; here's my challenge + redirect_uri + scope + state"
3. User authenticates THERE (password, MFA — not the client's business)
4. Auth server → redirect_uri with a short-lived, single-use CODE (+ your state echoed)
5. Client (backend or SPA) → auth server, back-channel:
"here's the code + the original code_verifier"
6. Auth server checks hash(verifier) == challenge → issues tokens
Why the dance? Each step kills a real attack: the code passes through the browser (loggable, leakable) but is useless without the verifier that never did — that's PKCE, mandatory now for all clients, not just mobile. state (random, verified on return) kills CSRF on the callback. Exact-match redirect URIs kill token-stealing redirects. The deprecated grants — implicit (tokens in URL fragments — leaky), password grant (the exact anti-pattern OAuth exists to abolish) — died for good reasons; if a tutorial teaches them, close the tab. (The one legitimate survivor: client credentials for machine-to-machine — no human, client authenticates itself, gets a scoped token. That one's fine and everywhere.)
Enter OIDC: the "who are you" layer
Plain OAuth2 answers "may this app access this resource?" — it deliberately says nothing about identity. "Login with Google" needs a different question answered: "who is this human?" OpenID Connect is that answer bolted cleanly on top: request the openid scope, and alongside the access token you receive an ID token — a JWT asserting identity (subject, issuer, audience, auth time) that your app validates (signature via JWKS, issuer, audience, expiry, nonce) and then consumes. The role separation everyone mixes up: ID token → your app reads it to log the user in (then typically starts its own session). Access token → sent to APIs; your app treats it as an opaque ticket. Using an access token as identity proof, or forwarding ID tokens to APIs, are both category errors with CVEs to their names.
Refresh tokens: the part that makes short expiry livable
Access tokens should live minutes (revocation-by-expiry — the stateless revocation problem managed honestly). Refresh tokens make that ergonomic: long-lived, exchanged for fresh access tokens, and — modern requirement — rotated on every use with reuse detection: each refresh invalidates the old token, and if a dead one is ever presented again, two parties hold it — token theft — so the family is revoked and the user re-authenticates. Store them like the credentials they are: server-side for web apps, platform secure storage on mobile, never localStorage.
Do you even need this? (The honest triage)
Your Laravel/. NET app, its own users, first-party web UI
→ cookie sessions. OAuth adds nothing but moving parts.
"Login with Google/Microsoft/Apple"
→ you're an OIDC CLIENT: auth-code+PKCE against their server; use the
mature library (Socialite, OpenIddict clients); never hand-roll validation.
You provide APIs that THIRD PARTIES access on users' behalf
→ you need an AUTHORIZATION SERVER. Run Keycloak/Auth0/Passport —
hand-rolling one is a security project, not a sprint task.
Machine-to-machine internal calls
→ client credentials, short tokens, scoped narrowly. Simple and correct.
The protocol earned its complexity one incident at a time — every weird parameter is a scar with a story. Respect it by using current flows, mature libraries, and the triage above; the teams that get burned are almost always the ones who either hand-rolled the crypto or implemented a grant type that died in 2019. Plain words, boring libraries, short tokens: that's the whole religion.
Wiring SSO into a product, standing up Keycloak, or untangling an inherited token zoo? Identity plumbing is a specialty — bring the sequence diagram you're afraid of.