OAuth 2.0 — Flows, state, and Attack Surface
OAuth 2.0 — Flows, state, and Attack Surface
Section titled “OAuth 2.0 — Flows, state, and Attack Surface”Core idea: delegated authorization
Section titled “Core idea: delegated authorization”Let an app access your data on another service without giving it your password. The app never sees your credentials — it receives a scoped, revocable token instead.
Valet key analogy: You don't hand a valet your house keys. The front desk issues a key that only opens your car. → limited scope, issued by an authority, revocableThe four roles
Section titled “The four roles”| Role | Who | Example |
|---|---|---|
| Resource Owner | the user | you |
| Client | the app wanting access | a photo printer app |
| Authorization Server | issues tokens | Google’s OAuth server |
| Resource Server | holds the data | Google Photos API |
Note the client is not the resource server. That split is the entire design.
Client registration — what you get
Section titled “Client registration — what you get”| Value | Visibility | Used at |
|---|---|---|
client_id |
public — appears in browser URLs | authorize endpoint + token endpoint |
client_secret |
confidential — server-side only | token endpoint only |
client_id identifies; client_secret proves. Same split as username vs password, for applications.
client_secret exists for exactly one reason: the authorization code travels the front channel (URL, browser history, Referer, logs) and must be assumed leakable. The secret never does — it only moves in a direct back-channel POST. So a stolen code alone is useless.
Code = has been in a URL → assume it can leakSecret = only in a server-to-server POST → never in a URLConfidential vs public clients decides the mechanism:
| Client type | Can hold a secret? | Mechanism |
|---|---|---|
| Confidential — server-side web app | ✓ | client_id + client_secret |
| Public — SPA, mobile, desktop, CLI | ✗ anything shipped can be extracted | PKCE |
| Device flow (CLI/IoT/headless) | ✗ and no redirect URI | device code, no secret |
A secret inside a mobile binary or JS bundle is not a secret — decompile the APK or open DevTools. Leaked client_secrets are a common real finding (hardcoded in bundles, committed to public repos).
Authorization Code flow
Section titled “Authorization Code flow”1. Client → Auth Server GET /authorize?response_type=code &client_id=..&redirect_uri=..&scope=.. &state=..&code_challenge=..&code_challenge_method=S2562. Auth Server → User "Photo Printer wants your photos. Allow?"3. User allows4. Auth Server → Client redirect_uri?code=AUTH_CODE&state=..5. Client → Auth Server POST /token (code + client_secret + code_verifier) ← back channel6. Auth Server → Client { access_token, refresh_token, expires_in, token_type }7. Client → Resource Server GET /photos Authorization: Bearer <token>Why step 4 sends a code and not the token — the critical design detail. The redirect goes through the browser (URL bar, history, Referer, proxy logs). A token there would leak. So a short-lived, single-use code travels the front channel, and the token is fetched over a direct back-channel POST.
Note response_type distinguishes the flow: code = authorization code, token = implicit (deprecated).
The channel split — front vs back
Section titled “The channel split — front vs back”The whole shape of this flow exists to keep secrets off the leaky channel.
FRONT channel (browser) BACK channel (server → server, direct)────────────────────────── ─────────────────────────────────────2. GET /authorize 5. POST /token4. GET /callback?code=.. code + client_secret + code_verifier &state=..| Front | Back | |
|---|---|---|
| Travels via | the browser | a direct HTTPS POST |
| Visible in | URL bar, history, Referer, proxy/server logs, bookmarks |
nowhere the user or a proxy sees |
| Carries | client_id, state, code_challenge, then the code |
client_secret, code_verifier, the token |
| Safe to leak? | client_id yes (public). Code — assume it can leak, hence single-use |
Never. Secret and verifier must never appear in a URL |
Rule: anything that grants access (token, secret, verifier) stays off the front channel. The code is the deliberate exception — it’s short-lived and dies on first use, which is what makes it safe to carry through the browser.
Observed flow in the lab (Archie + Caido)
Section titled “Observed flow in the lab (Archie + Caido)”1. GET /login [APP] generate state + PKCE verifier → store in server-side session Set-Cookie: sid=...; HttpOnly; SameSite=Lax 302 → github.com/login/oauth/authorize
2. GET github.com/login/oauth/authorize [FRONT channel — browser] ?client_id&redirect_uri&scope&state&code_challenge&code_challenge_method=S256 note: state and VERIFIER are NOT here — only the challenge
3. user clicks Authorize [GitHub's consent screen]
4. 302 → localhost:8888/callback?code=..&state=.. [FRONT channel] This is a REDIRECT, not a refresh: GitHub returns 302 with a Location header and the browser issues a NEW GET to that URL. Different origin, fresh request. It can look like a self-refresh in devtools; it isn't.
5. POST github.com/login/oauth/access_token [BACK channel] body: client_id + client_secret + code + redirect_uri + code_verifier → { access_token, token_type, scope, ... }
6. token stored in the server-side session NOTE: /callback receives the TOKEN, not resource data. Two separate hops.
7. GET api.github.com/user [RESOURCE server] Authorization: Bearer <access_token> → actual dataStep 4 vs step 5 is the exam/interview point. The code arrives in a URL; the token does not. Anyone who can read the URL saw the code — but it’s already dead by then, and it was useless without the secret and verifier that only travel in step 5.
Why the app must be stateful
Section titled “Why the app must be stateful”SESSIONS[sid] = { state, verifier, token }
The session is load-bearing, not a convenience:
stateis compared in step 5 against a value stored server-side — the client cannot read or forge it. That’s what makes the CSRF check real. A stateless design would need a signed cookie carrying the same binding.code_verifierlives only in the session between steps 1 and 5. If you sent it to the browser, PKCE would protect nothing.- The token is held server-side so the browser never sees it — that’s the entire point of step 7 going through your backend.
Cookie flags that matter here:
| Flag | Why |
|---|---|
HttpOnly |
XSS cannot read sid → cannot hijack the session |
SameSite=Lax |
Required. Strict would strip the cookie on GitHub’s cross-site redirect in step 4 → session lookup fails → every login breaks. Lax permits top-level GET navigation while still blocking cross-site POSTs |
Secure |
not set locally (plain HTTP on localhost); mandatory in production |
Cache-Control: no-store |
the callback page (code + token) must never land in browser cache or history |
state — anti-CSRF, not a secret
Section titled “state — anti-CSRF, not a secret”An opaque random value the client generates per flow, stores server-side, sends in step 1, and verifies in step 4.
1. Client generates state=abc123, stores it in this user's session2. Client sends ...&state=abc1233. Server echoes it back verbatim: /callback?code=XYZ&state=abc1234. Client compares: session state === returned state? match → continue to /token mismatch → ABORTThe attack it stops — login CSRF / code injection:
1. Attacker starts an OAuth flow with their OWN account against the victim app2. Attacker abandons before the redirect and captures their own code3. Attacker crafts: victim-app.com/callback?code=ATTACKERS_CODE4. Victim loads it (link, iframe, img tag)5. Victim's app exchanges the code → receives the ATTACKER'S token → the attacker's account is now bound to the victim's session6. Victim uploads private files believing it's their own account → everything lands in the attacker's accountThe damage is not the attacker reading their own data — it is the victim silently operating inside the attacker’s account.
With state validated: the victim’s session holds no matching value → step 4 aborts. Attack dead.
What state is not:
| Not a secret | travels in the URL; it is a binding, not a credential |
| Not a session token | authenticates nothing |
| Not sufficient alone | must be bound to the session (server-side or signed cookie), else “returned state == whatever the attacker sent” and nothing was validated |
Not nonce |
OIDC adds nonce separately, to stop id_token replay — different threat |
Rule: no state → vulnerable. state unvalidated → same as not having it.
PKCE (RFC 7636)
Section titled “PKCE (RFC 7636)”For public clients that cannot hold a client_secret. Same idea as the secret, but the proof is derived per flow instead of pre-shared.
Client: verifier = random 43-128 chars (kept local) challenge = base64url(SHA256(verifier)) ← sent in step 1Step 5: send the verifier ← proves same client finishingServer: recompute SHA256(verifier), compare to the stored challengeOnly S256 is acceptable — plain offers no protection. GitHub enabled PKCE for OAuth Apps and GitHub Apps in July 2025 and recommends it alongside the client secret, not instead of it.
What PKCE does not fix: redirect_uri manipulation (PKCE returns tokens to whoever holds the verifier… but the redirect target still matters), and it is not required for the device flow.
Attack surface
Section titled “Attack surface”| Attack | Mechanism | Defense |
|---|---|---|
redirect_uri manipulation |
Lax redirect (open redirect, path append, subdomain, @ trick) → code/token delivered to attacker |
Strict exact-match allow-list registered at the provider |
| CSRF on the code | Attacker injects their own code into the victim’s session | state, validated server-side |
| Code interception | Code stolen from the front channel | PKCE + short code lifetime + single use |
| Implicit flow leakage | response_type=token puts the token in the URL fragment — history, logs, Referer |
Deprecated; use code + PKCE. GitHub does not support implicit at all |
| Scope creep | Client requests far more than it needs | Least-privilege scopes, user-visible consent |
| Token leakage | Tokens in logs, Referer, over-broad referrer policy |
Never in URLs; Referrer-Policy; short exp |
Leaked client_secret |
Hardcoded in JS/mobile/git → attacker impersonates the app | Server-side only; rotate; PKCE for public clients |
OAuth vs OIDC
Section titled “OAuth vs OIDC”OAuth 2.0 → "this app may access these resources" (authorization)OIDC → "and here is who the user is" (authentication)
OIDC = OAuth 2.0 + id_token (a JWT) + /userinfoAdd scope=openid and OIDC returns an id_token. OAuth alone never tells you who the user is — that is why “Sign in with Google” is OIDC, not OAuth.
Token lifetimes (GitHub as a concrete example)
Section titled “Token lifetimes (GitHub as a concrete example)”| Token | Lifetime |
|---|---|
| User access token (expiring enabled) | 8 hours (expires_in 28800) |
| Refresh token | 6 months (refresh_token_expires_in 15897600) |
| Installation access token (GitHub App) | 1 hour |
GitHub Apps opt in to expiring tokens; classic OAuth Apps historically issued non-expiring tokens. Refresh tokens exist so the 8-hour access token can be regenerated without re-prompting the user.
Hands-on lab
Section titled “Hands-on lab”~/oauth-lab/app.py — Python stdlib only (no Flask, no authlib) so every step is visible:
export OAUTH_CLIENT_ID=... # from the OAuth app you registeredexport OAUTH_CLIENT_SECRET=...python3 ~/oauth-lab/app.py# register http://localhost:8080/callback as the app's callback URL firstRoutes: / config summary · /login build authorize URL + set session · /callback verify state then exchange code · /me call the resource server · /logout.
The server logs each step, including the state comparison and the token POST (secret masked).
Exercises that teach the most:
- Run the happy path — watch
statego out, come back, and match in the log. - Break
state— call/callback?code=x&state=wrongwith a valid session cookie. Expect the abort. That abort is the CSRF defense. - Delete the state check in
callback()and repeat exercise 2. The forged flow now proceeds — the vulnerability made concrete. - Strip PKCE (drop
code_challenge/code_verifier) and compare. - Try the OAuth.com playground (oauth.com/playground) for the simulated variants: PKCE, Implicit, Device Code, OpenID Connect.
Verified behavior (2 Sept 2026 test run):
forged state → "state mismatch — flow aborted" ✓no session cookie → "No session cookie — start again" ✓no token at /me → HTTP 401 ✓correct state → reaches POST /login/oauth/access_token ✓secret in logs → masked as *** ✓secret in HTML → never rendered, never in a URL ✓PKCE → verifier 86ch, challenge 43ch, S256 verified ✓Sources
Section titled “Sources”- RFC 6749 (OAuth 2.0), RFC 7636 (PKCE), RFC 6750 (Bearer tokens), OpenID Connect Core
- OWASP API Security Top 10 — API2 Broken Authentication
- GitHub docs: authorizing OAuth apps; PKCE support changelog (2025-07-14); expiring user access tokens
- CAPIE course section 1.3 (Auth & Authorization)