Skip to content

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”

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, revocable
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.

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 leak
Secret = only in a server-to-server POST → never in a URL

Confidential 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).

1. Client → Auth Server GET /authorize?response_type=code
&client_id=..&redirect_uri=..&scope=..
&state=..&code_challenge=..&code_challenge_method=S256
2. Auth Server → User "Photo Printer wants your photos. Allow?"
3. User allows
4. Auth Server → Client redirect_uri?code=AUTH_CODE&state=..
5. Client → Auth Server POST /token (code + client_secret + code_verifier) ← back channel
6. 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 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 /token
4. 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.

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 data

Step 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.

SESSIONS[sid] = { state, verifier, token }

The session is load-bearing, not a convenience:

  • state is 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_verifier lives 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

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 session
2. Client sends ...&state=abc123
3. Server echoes it back verbatim: /callback?code=XYZ&state=abc123
4. Client compares: session state === returned state?
match → continue to /token
mismatch → ABORT

The attack it stops — login CSRF / code injection:

1. Attacker starts an OAuth flow with their OWN account against the victim app
2. Attacker abandons before the redirect and captures their own code
3. Attacker crafts: victim-app.com/callback?code=ATTACKERS_CODE
4. 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 session
6. Victim uploads private files believing it's their own account
→ everything lands in the attacker's account

The 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.

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 1
Step 5: send the verifier ← proves same client finishing
Server: recompute SHA256(verifier), compare to the stored challenge

Only 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 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 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) + /userinfo

Add 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.

~/oauth-lab/app.py — Python stdlib only (no Flask, no authlib) so every step is visible:

Terminal window
export OAUTH_CLIENT_ID=... # from the OAuth app you registered
export OAUTH_CLIENT_SECRET=...
python3 ~/oauth-lab/app.py
# register http://localhost:8080/callback as the app's callback URL first

Routes: / 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:

  1. Run the happy path — watch state go out, come back, and match in the log.
  2. Break state — call /callback?code=x&state=wrong with a valid session cookie. Expect the abort. That abort is the CSRF defense.
  3. Delete the state check in callback() and repeat exercise 2. The forged flow now proceeds — the vulnerability made concrete.
  4. Strip PKCE (drop code_challenge / code_verifier) and compare.
  5. 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 ✓
  • 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)