Understand OAuth 2.0 + PKCE
one layer at a time.
"Log in with Google." A photo app posting to your socials. Your phone staying signed in for weeks. They all run on the same handful of moves — an authorization code, a one-way hash, a short-lived access token and a refresh token. This builds that picture up slowly, with diagrams you can poke at and real cryptography that runs in your browser.
You're in Simple. Go top to bottom — each chapter assumes only the ones before it. When a section clicks, flip to Detailed to see the wire-level truth, or hit Map for the bird's-eye system view. Nothing here calls a real server.
01 Why OAuth exists
The problem it solves, before any tokens show up.
A valet key starts the engine and opens the door but not the trunk or glovebox. You hand it to a stranger without worry. OAuth is the valet key for your data: the app gets a credential that does a few specific things (your scopes), not your master key (your password) — and you can deactivate that one key any time.
Two precise distinctions worth nailing down early, because they get muddled constantly:
- Authentication (who you are) vs authorization (what you may do). Plain OAuth 2.0 is an authorization framework. OpenID Connect (OIDC) is a thin identity layer on top that adds login — it's where the
id_tokencomes from. "Log in with Google" is OIDC; "let this app read my Google Photos" is OAuth. - Spec lineage: OAuth 2.0 is RFC 6749 (2012). Modern security rules live in RFC 9700, the Security Best Current Practice (2025), and the in-progress OAuth 2.1 draft, which deletes the old insecure flows and makes PKCE mandatory for everyone.
Not a new protocol — OAuth 2.0 with the footguns removed: the Implicit and Password grants are gone, redirect URIs must match by exact string, and PKCE is required on every authorization-code flow. Build new today and you build 2.1.
02 The four players
The colours here match the system map. Keep them in your head.
You + your Browser
The human who owns the data and clicks "Allow," plus the browser that carries the redirects. You grant access; you never hand the app your password.
The App
The thing that wants access. A confidential client (a web server) can keep a secret; a public client (SPA, mobile) can't. Collects and uses the tokens.
The API
Where your data lives. Doesn't know your password — it just checks that the access token presented is valid, then serves the request.
The Issuer
Google, GitHub, Okta, your SSO. Authenticates you, asks consent, and issues the tokens. The only party that ever sees your password.
The browser deserves special mention: it's the courier that carries redirects between the App and the Issuer, but it is not trusted — it's a public hallway. That untrusted hallway is exactly why the front-channel / back-channel split (next) matters so much.
03 Front vs back channel
The single most useful mental model for the whole protocol.
Front channel
Data passed through the browser as redirects. Visible in history, logs, extensions. Assume it's public — so it carries only non-secrets and a one-time code.
Back channel
A direct server-to-server HTTPS call that never touches the browser. The safe place — where secrets are exchanged and real tokens are delivered.
This is also why the old Implicit flow died. It returned the access token itself on the front channel (in the URL fragment) to save a round-trip — the most valuable secret, on a postcard. Modern guidance (RFC 9700, OAuth 2.1) forbids it: always use the Authorization Code flow, always with PKCE, even for pure browser apps. The clever part of PKCE (chapter 5) is how it keeps a postcard-delivered code safe even when someone photographs the postcard.
04 The flow, step by step
The message sequence. Watch each party's held secrets light up.
Step through the dance. The glowing lanes are the sender and receiver; the badges show which secret each party holds right now.
Where the Map shows where things live, this shows what travels and when. It follows the view toggle: 5 beats in Simple, all 11 steps with real HTTP in Detailed. Use ←/→ too.
—
—
Why this step matters
05 PKCE up close
"Proof Key for Code Exchange" — what makes a postcard-delivered code safe.
one-way
The authorization code comes back on the front channel. On a phone, a malicious app can sometimes register the same redirect address and intercept it. Without PKCE the attacker trades the stolen code for your tokens. With PKCE, redeeming the code requires the secret verifier — which never left the real app, and can't be computed backwards from the hash.
Try it for real
This runs the actual PKCE math in your browser. Generate a verifier, watch the real challenge appear, then edit the verifier and watch it change completely.
code_challenge = BASE64URL( SHA-256( code_verifier ) ) · method S256
① → ③ is instant. ③ → ① is computationally impossible — that's "one-way." So it's safe to shout the challenge across the public front channel while the verifier stays hidden until the private back-channel token request.
The exact rules RFC 7636
| Thing | Rule |
|---|---|
code_verifier | 43–128 chars from [A-Z] [a-z] [0-9] - . _ ~. ≥256 bits of entropy — i.e. 32 random bytes → Base64URL → 43 chars. |
code_challenge | BASE64URL(SHA256(ASCII(verifier))). Base64URL = Base64 with +/→-_ and no = padding. |
code_challenge_method | S256 (required for new clients) or plain (deprecated by RFC 9700). The AS stores method + challenge with the pending code. |
| Verification | At the token endpoint the AS recomputes SHA256(verifier) and compares to the stored challenge. Mismatch ⇒ rejected. |
PKCE began as a mobile defence, but OAuth 2.1 now mandates it for every client — including server-side ones — as defence-in-depth against authorization-code injection. It costs nothing and closes a whole attack class.
06 Hash vs encode vs encrypt vs sign
Four words people swap by mistake. OAuth uses all four, for different jobs.
Type into the box and watch the same input behave four different ways. That difference is the whole chapter.
You just relied on hashing for PKCE; tokens rely on encoding and signing; transport relies on encryption. Mixing these up is the #1 source of "wait, is this secure?" confusion.
A different alphabet for the same bytes, so binary survives a URL. Anyone can reverse it — hides nothing.
↳ This is what a JWT's header & payload are: encoded, not encrypted. Readable by anyone.
A fingerprint. Same input → same hash, always. Change one character → avalanche (try it). No way back.
↳ The engine of PKCE and of password storage. No "decrypt" button — because there's no way back.
A hash mixed with a secret key: "someone who holds the key wrote exactly this, unaltered." It vouches for the message; it doesn't hide it.
↳ This is a JWT's signature. Change one byte of the token and verification fails. Real JWTs usually use RS256/ES256 (asymmetric) rather than shared-secret HMAC.
Scrambles the message unreadable — but recoverable by anyone with the key. Unlike hashing there's a way back; unlike encoding you need the secret.
↳ Try decrypting after changing the passphrase — it fails. That's the difference from encoding. This is what TLS/HTTPS does to every message in transit.
The cheat-sheet
| Technique | Reversible? | Needs a key? | Job | Where in OAuth |
|---|---|---|---|---|
| Encoding Base64URL | Yes — by anyone | No | URL-safe, readable bytes | JWT header/payload; PKCE output format |
| Hashing SHA-256 | No — one way | No | Fingerprint; prove knowledge of a secret | PKCE challenge; password/token storage |
| Signing HMAC / RS256 | re-verify | Yes | Integrity + authorship | JWT signature; client assertions |
| Encryption AES / TLS | Yes — with the key | Yes | Hide content | HTTPS transport; tokens at rest; JWE |
"It's a JWT, so it's encrypted and safe in the URL." No. A standard JWT is signed and Base64URL-encoded, not encrypted — anyone can decode and read every claim (you'll do exactly that next chapter). Never put secrets inside a JWT payload.
07 state and nonce
Two small random values, two different attacks. Separate jobs from PKCE.
state → anti-CSRFThe app sends a random state; the issuer echoes it back with the code; the app checks they match. If not, someone is trying to feed your session a code they obtained. Reject it. Protects the redirect coming back.
nonce → anti-replayFor OIDC login, the app sends a random nonce; the issuer bakes it inside the signed id_token; the app checks it matches — proving the identity token was minted for this login, not replayed. Protects the id_token.
PKCE binds the code to the app that started the flow. state binds the response to the request your app sent. nonce binds the id_token to this login. Use all three; none substitutes for another.
08 The three tokens
Access, refresh, ID — three jobs, three lifetimes.
| Token | Sent to | Lifetime | Form | Job |
|---|---|---|---|---|
| Access | the API, as Authorization: Bearer … | ~5–60 min | JWT or opaque | The working key to call the API. |
| Refresh | only the issuer's /token | days–weeks | usually opaque | Mint new access tokens without re-login. |
| ID OIDC | read by the app itself | short | signed JWT | Tell the app who logged in. Not a key to call APIs. |
Sending the ID token to your API as the credential. ID token = proof of login to the app; access token = the key to the API. Different aud on purpose. Use each for its job.
Anatomy of a JWT — decode one live
Three Base64URL chunks joined by dots: header.payload.signature. The default below is a sample access token — remember chapter 6: the first two parts are merely encoded, so you're about to read them in the clear.
All local. The signature is shown but not verified (that needs the issuer's public key).
——You just read every claim with no key. A JWT is signed, not secret. Its safety comes from the signature (you can't forge a valid one) and from HTTPS + storage — never from the payload being hidden.
JWT vs opaque access tokens
| JWT (self-contained) | Opaque (reference) | |
|---|---|---|
| Validation | Local: verify signature with the issuer's cached public key. No network call. | Remote: call the issuer's /introspect (RFC 7662). |
| Revocation | Hard — valid until exp. Keep lifetimes short. | Instant — issuer says "inactive." |
| Trade-off | Fast, stateless, scalable; leak lasts until expiry. | Centrally controllable; a round-trip per request (often cached). |
09 How the API validates a token
What the resource server checks before serving your data.
When a request arrives with Authorization: Bearer …, the API asks four quick questions: real? (signature), still valid? (expiry), meant for me? (audience), allowed to do this? (scope). For a JWT all four are usually a sub-millisecond local check — a big reason this design scales.
The JWT validation checklist
- Signature — fetch the issuer's public keys from its JWKS endpoint (
/.well-known/jwks.json, RFC 7517), pick the key by the header'skid, verify. expnot past (andnbfnot future).issequals the trusted issuer.audequals this API — so a token for service A can't be replayed at B.scopecovers the requested action.
The API caches the JWKS public keys (they rotate rarely) and validates millions of JWTs with zero network calls — fully stateless. It refetches only when a new kid appears. Opaque tokens instead cache introspection results for a few seconds. Either way: cache the validation material, never the password.
10 Where tokens live — storage & caching
Getting a token is half the battle; holding it without leaking it is the other half.
A token is a bearer credential — whoever holds it can use it. So where you keep it decides how badly an XSS bug or a stolen device hurts. Every browser option is a trade-off; the industry now keeps tokens off the browser when stakes are high.
| Where | Exposed to XSS? | Verdict |
|---|---|---|
localStorage | Yes — any script reads it | Discouraged for tokens. One XSS = full theft. |
| In-memory (JS variable) | Partly — still scriptable | Better; gone on reload, but XSS-reachable while live. |
HttpOnly + Secure cookie | No — invisible to JS | Strong vs XSS. Add SameSite + anti-CSRF. |
| Native Keychain / Keystore | N/A | The right home on mobile — OS-encrypted. |
For browser apps handling anything sensitive, the current IETF Best Current Practice says: don't let the browser hold OAuth tokens at all. A small backend you own (the Web Server in the Map) is the confidential client. It does the token exchange, stores access & refresh tokens server-side, and hands the browser only a hardened HttpOnly, Secure session cookie. The browser can be fully XSS-compromised and still have no token to steal.
The Browser-Based Apps BCP ranks three architectures, most-secure first:
- Backend-For-Frontend (BFF) — backend holds tokens, browser gets a cookie. Recommended for business/sensitive/PII apps.
- Token-Mediating Backend — a backend brokers tokens but passes the access token to the browser.
- Browser-based OAuth client — the SPA does it all (PKCE in-browser, tokens in memory). OK for low-risk apps; most exposed.
What's safe to cache, and for how long
- Access token — until
exp; refresh just before. Memory or server session, never logs/URLs. - Refresh token — crown jewel. Server-side (BFF) or OS secure storage; with rotation so a leak self-destructs.
- JWKS public keys (API side) — cache aggressively; public, rotate rarely. This is what makes stateless validation fast.
- Authorization code — never "stored." Single-use, lives seconds; redeem immediately.
Sender-constrained tokens are the frontier: DPoP (and mTLS) cryptographically bind a token to a key the client holds, so a stolen bearer token is useless without it.
11 Staying logged in — refresh & rotation
How you stay signed in for weeks while access tokens expire in minutes.
When the access token expires, the app quietly trades the refresh token for a new one on the back channel — no password, no interaction. That's why apps "remember" you.
The refresh token is a day wristband; access tokens are single-ride tickets that expire fast. Each time a ticket runs out you flash the wristband for a new one. Lose a ticket → someone gets one ride. The wristband is the valuable thing — so modern systems make it self-destruct if copied.
POST /token
grant_type=refresh_token
&refresh_token=v2.aBc… ← the current one
200 OK
{ "access_token":"…new…",
"refresh_token":"v3.dEf…", ← ROTATED: v2 is now dead
"expires_in":900 }
Later, if v2 appears again ⇒ AS assumes theft
⇒ revoke the whole family (v1,v2,v3…) ⇒ re-auth
After a rotation, the real client holds the newest token and a thief holds a stale copy (or vice-versa). Whoever presents the old one trips the alarm. A small grace/overlap window tolerates a dropped response. Pair with short lifetimes, revocation (RFC 7009), and event logging.
12 Threat → defence cheat-sheet
Every mechanism here exists to stop a specific attack.
| The attack | What goes wrong | The defence |
|---|---|---|
| Code interception | A rogue app grabs the code off the front channel | PKCE (ch. 5) |
| CSRF on the redirect | Attacker injects their code into your session | state (ch. 7) |
| ID-token replay | An old identity token is reused | nonce (ch. 7) |
| Token theft via XSS | Malicious JS reads tokens from storage | HttpOnly / BFF (ch. 10) |
| Leaked access token | A stolen token gets used | Short lifetimes + aud/scope; DPoP (ch. 9–10) |
| Stolen refresh token | Long-term silent access | Rotation + reuse detection (ch. 11) |
| Wrong-audience replay | Token for A used at B | aud validation (ch. 9) |
Keep secrets off the postcard, prove possession instead of revealing it, make the powerful tokens short-lived or self-destructing, and keep them out of reach of scripts. Everything else is detail.
13 Glossary & the real specs
The vocabulary in one place, plus primary sources.
Quick glossary
| Resource Owner | You — the user who owns the data. |
| Client | The app requesting access. |
| Authorization Server | Issues tokens after authenticating you. |
| Resource Server | The API holding your data. |
| Scope | The specific permissions granted. |
| Auth code | Single-use, short-lived; traded for tokens. |
| Access token | Short-lived key sent to the API. |
| Refresh token | Long-lived; renews access tokens. |
| ID token | OIDC proof of who logged in. |
| PKCE | Verifier/challenge binding the code to the client. |
| JWT | Signed, encoded claims: header.payload.signature. |
| JWKS | The issuer's public keys for verifying JWTs. |
| BFF | Backend-for-Frontend: tokens off the browser. |
Primary sources
- RFC 6749 — OAuth 2.0 core
- RFC 7636 — PKCE
- RFC 6750 — Bearer tokens
- RFC 7519 — JWT
- RFC 9068 — JWT access tokens
- RFC 7517 — JWK / JWKS
- RFC 7662 — Introspection
- RFC 7009 — Revocation
- RFC 9449 — DPoP
- RFC 9700 — Security BCP (2025)
- OAuth 2.1 — consolidating draft
- Browser-Based Apps BCP
- OpenID Connect Core
You now have the whole map: why OAuth exists, the four players, the front/back-channel rule, the full PKCE flow, the crypto behind it, the three tokens, validation, storage, and refresh. Re-read any chapter in Detailed, or take the Map for the system view — both should read very differently now.