OAuth 2.0 authorization code flow, step by step
The seven-step dance behind 'Log in with Google'. Why the redirects exist, why the code is swapped for the token server-to-server, and where PKCE plugs the public-client hole.
On this page
The story the flow tells
A user is on your app — call it Notebook — and clicks Connect Facebook. Notebook wants to read their friends list from Facebook’s API. Notebook does not want to ask the user for their Facebook password (and Facebook would not let it). Instead, Facebook hands Notebook a scoped access token: “this token can read this user’s friends, expires in 1 hour, can’t do anything else.”
OAuth 2.0’s authorization code grant is the protocol that produces that token. It looks like a lot of steps because two real constraints are doing the work:
- The user must authenticate to Facebook in a browser session Facebook controls — not in Notebook’s UI. Otherwise Notebook sees the password.
- The access token must never appear in the browser’s URL bar, where it would land in browser history, server logs, and any
Refererheader. So the code travels through the browser, and the code-for-token swap happens server-to-server.
Everything else is just plumbing those two constraints into HTTP redirects.
The participants
- User — the human at a browser, with an account on Facebook.
- Client — Notebook’s web app. Registered ahead of time at Facebook with a
client_id, a secretclient_secret, and an allowlistedredirect_uri. - Authorization Server — Facebook’s OAuth endpoints (
/oauth/authorize,/oauth/token). The thing the user actually authenticates to. - Resource Server — Facebook’s Graph API. Accepts the access token and returns data.
In OIDC (“Log in with Google”), the same flow runs and the Authorization Server additionally returns an id_token carrying who the user is. See 0017 Passwordless authentication: OTP, SSO, and OAuth/OIDC · System Design for the difference between OAuth-as-authorization and OIDC-as-identity.
The flow, step by step
OAuth 2.0 authorization code grant
The user-agent (browser) carries the authorization code in steps 4–5. The code-for-token swap in step 6 happens server-to-server, so the access token never appears in the URL bar. Steps 7–8 use the token to call the API.
Why this shape and not a simpler one
Two design choices look gratuitous on first read. Both exist for a real reason.
Why the code-for-token swap?
A simpler design would put the access token directly in the redirect URL at the authorization-server response. That was the original implicit grant, and it’s deprecated.
The reason: a URL containing the token lands in the browser’s history, the server’s access logs, every proxy along the way, and the Referer header of any link the user clicks afterward. By returning a single-use code instead, the URL leak only exposes a value that’s useless without client_secret, and the code expires in seconds.
The access token only ever exists in two places: Facebook’s auth server (which minted it) and Notebook’s backend (which holds it server-side). The browser never sees it.
Why a state parameter?
When the client builds the redirect to the Authorization Server, it includes state=xyz — a random per-request value it stores in the user’s session. The Authorization Server echoes the same state back on the redirect to the callback. The client then checks that the echoed value matches the one it stored before redeeming the code.
Without this check, an attacker can trick a user’s browser into completing the OAuth flow for the attacker’s Facebook account, linking the attacker’s identity to the victim’s Notebook session. The state parameter is the CSRF defence for the authorization request. It’s not optional.
Where PKCE comes in
The code-for-token swap relies on client_secret. That works when the client is a confidential client — a backend server that can keep a secret. Mobile apps, SPAs, and CLIs cannot: anything shipped to a device can be reverse-engineered.
PKCE (Proof Key for Code Exchange — pronounced “pixy”) plugs this without requiring a deployable secret.
-
Client mints a verifier and a challenge
Before redirecting to the Authorization Server, the client generates
code_verifier(a random 43–128-character string per RFC 7636) and derivescode_challenge = BASE64URL(SHA256(code_verifier))— theS256method. It sends only the challenge (pluscode_challenge_method=S256) on the authorization request, and stashes the verifier in its own session. -
Authorization Server binds the code to the challenge
The server stores the challenge against the authorization code it’s about to issue.
-
Client proves possession on the token exchange
On the token request, instead of (or in addition to)
client_secret, the client sends the originalcode_verifier. The server hashes it the same way and compares to the stored challenge. -
An interceptor of the code can't redeem it
Anyone who steals the authorization code from the redirect URL doesn’t have the verifier — they can’t complete the token exchange.
PKCE was originally added for mobile and SPA clients in RFC 7636. The OAuth 2.1 draft now recommends it for every client, including confidential ones, because the extra defence costs nothing and closes a class of code-injection attacks.
A minimal callback handler
@GetMapping("/oauth/callback")
ResponseEntity<?> callback(
@RequestParam String code,
@RequestParam String state,
HttpSession session) {
// 1. CSRF check: state must match the value we minted before the redirect.
String expected = (String) session.getAttribute("oauth_state");
if (expected == null || !expected.equals(state)) {
return ResponseEntity.status(400).body("state mismatch");
}
session.removeAttribute("oauth_state");
// 2. Server-to-server token exchange. client_secret never leaves the backend.
var body = Map.of(
"grant_type", "authorization_code",
"code", code,
"redirect_uri", callbackUrl,
"client_id", clientId,
"client_secret", clientSecret,
"code_verifier", (String) session.getAttribute("pkce_verifier"));
TokenResponse tokens = http.post(tokenEndpoint, body, TokenResponse.class);
// 3. Persist tokens server-side, keyed by the local user. Never to the browser.
userTokens.save(currentUserId(session), tokens.accessToken(), tokens.refreshToken());
return ResponseEntity.status(302).location(URI.create("/dashboard")).build();
}The CSRF check is the line most often skipped in tutorials. Don’t skip it.
The footgun the implicit grant left behind
The redirect from the Authorization Server lands at the client with #access_token=… directly in the URL fragment — no code, no token exchange.
Implicit was designed in 2012 for SPAs that couldn’t keep a client_secret. It removed the back-channel token exchange because the SPA had no server to perform it.
Use authorization code grant + PKCE. Modern browsers, mobile platforms, and OAuth libraries support it everywhere; the implicit grant was formally removed in OAuth 2.1. Tokens belong off the URL bar, even in fragments — browser extensions, screen recorders, and shoulder surfers all see fragments.
Other places the flow goes wrong
redirect_uriallowlist too loose. If the Authorization Server accepts any URI under a host you don’t fully control (e.g., a user-content subdomain), an attacker can register a callback there and harvest codes. The allowlist must be exact-match.- State parameter missing or predictable. A weak
state(timestamp, counter) is the same as nostate. Use a cryptographically random per-request value. - Access tokens stored in
localStorage. Any XSS on the page exfiltrates them. Either keep tokens server-side (the pattern in this note) or use HttpOnly cookies for the API call. - Treating an access token as proof of identity. An access token says “this client may call these APIs”. It does not say who the user is — that’s what OIDC’s
id_tokenis for. Apps that decode access tokens to find a user ID break the moment the IdP rotates token format. - No refresh-token rotation. Refresh tokens are long-lived. If they’re not rotated on use, a single leak is a permanent compromise. Always rotate, always invalidate the previous one on rotation.
Where this fits in the wider authentication picture: 0017 Passwordless authentication: OTP, SSO, and OAuth/OIDC · System Design surveys passwordless auth more broadly (OTP, SSO, OAuth/OIDC); 0016 Password auth: HTTP Basic vs session cookies vs tokens · System Design covers how the session you create after a successful OAuth flow is then carried on subsequent requests.