Skip to content

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.

Updated May 25, 2026

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:

  1. The user must authenticate to Facebook in a browser session Facebook controls — not in Notebook’s UI. Otherwise Notebook sees the password.
  2. The access token must never appear in the browser’s URL bar, where it would land in browser history, server logs, and any Referer header. 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 secret client_secret, and an allowlisted redirect_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.

0 / 9
UserClientAuth SrvResource

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.

  1. 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 derives code_challenge = BASE64URL(SHA256(code_verifier)) — the S256 method. It sends only the challenge (plus code_challenge_method=S256) on the authorization request, and stashes the verifier in its own session.

  2. Authorization Server binds the code to the challenge

    The server stores the challenge against the authorization code it’s about to issue.

  3. Client proves possession on the token exchange

    On the token request, instead of (or in addition to) client_secret, the client sends the original code_verifier. The server hashes it the same way and compares to the stored challenge.

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

OAuthCallback.java
@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

Anti-pattern
Implicit grant (response_type=token)
Smell

The redirect from the Authorization Server lands at the client with #access_token=… directly in the URL fragment — no code, no token exchange.

Why it happens

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.

Fix instead

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_uri allowlist 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 no state. 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_token is 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.