ndovuoauth2

FLOW DEEP DIVE · RFC 6749 §4.1

The authorization-code flow

The OAuth2 authorization-code grant on the wire: every message annotated, the front channel vs the back channel, the PKCE variant, refresh tokens, and the ways it goes wrong in production.

October 26, 2026the default grantRun it: the PKCE console
01 · What it is and when it reaches for it

Section 4.1 defines the authorization-code grant as the flow "used to obtain both access tokens and refresh tokens" and "optimized for confidential clients". Its security property, in the spec's own words (section 1.3.1): the access token is transmitted "directly to the client without passing it through the resource owner's user-agent" - and the resource owner's credentials are never shared with the client at all.

Every "Sign in with ..." button is this flow. So is every SPA (via PKCE) and every mobile app (via RFC 8252). When RFC 9700 and OAuth 2.1 tightened the framework, they tightened it around this grant: it is the only one the security BCP still recommends for new work, and the only one that initiates user-delegated access that OAuth 2.1 keeps.

02 · The flow, step by step

Ten hops, four parties. Click a step to isolate it on the diagram; the dashed arrows are the front channel (values riding through the browser), solid arrows are the back channel (direct, over TLS).

Authorization code flow sequence: browser, web app, authorization server, and APIBrowserWeb appAuth serverAPI1. GET /authorize(response_type=code, client_id, state...)2. login + consent screen3. user signs in, approves scopes4. 302 redirect_uri?code=...&state=...5. code + state hit the callback6. POST /token (code + client auth)7. access_token (+ refresh_token)8. Authorization: Bearer ...9. protected resource10. rendered page

Click a step above to isolate it on the diagram.

03 · The wire

The same flow as actual messages, with the parameters that carry the security. Values are illustrative; shapes are exact.

Step 1 - the authorization request (4.1.1)
GET https://accounts.example.com/authorize?
    response_type=code
    &client_id=sushi-tracker
    &redirect_uri=https://sushi.example.com/callback
    &scope=orders:read
    &state=Nb7Ka81Qxzqk
    &code_challenge=RGjvQZD7n3muAsL3k6fIe3ioTomWMm3Gj18dTmxAiRw
    &code_challenge_method=S256
ParameterStatusWhy it exists
response_type=codeREQUIRED (4.1.1)Selects this grant. token selects the retired implicit grant - never for new work.
client_idREQUIRED (4.1.1)Identifies the client. "Not a secret; it is exposed to the resource owner" (2.2).
redirect_uriOptional per 3.1.2 registrationWhere the response lands. Compared by exact string match when fully registered (3.1.2.3; RFC 9700 makes exact matching universal except native loopback ports).
scopeOPTIONAL (3.3)The permissions being requested; the user consents to exactly this.
stateRECOMMENDED in 6749 (4.1.1); REQUIRED practice per RFC 9700 §4.7.1Opaque per-request value; the CSRF binding. Section 10.12: "MUST contain a non-guessable value".
code_challenge + methodREQUIRED for public clients (RFC 9700 §2.1.1); required for all under OAuth 2.1 §4.1.1, with one narrow exceptionPKCE (RFC 7636): the S256 hash of a one-time verifier the client keeps secret. Makes a stolen code worthless.
Steps 4-5 - the callback (4.1.2)
302 Found
Location: https://sushi.example.com/callback?code=SplxlOBeZQQYbYS6WxSbIA&state=Nb7Ka81Qxzqk

The code MUST expire shortly - a 10-minute maximum lifetime is RECOMMENDED - and MUST NOT be used more than once; on reuse the server MUST deny the exchange and SHOULD revoke everything that code already produced (4.1.2). The client's first job at the callback is the state comparison; only then does it spend the code.

Step 6 - the token exchange (4.1.3)
POST /token HTTP/1.1
Host: accounts.example.com
Authorization: Basic c3VzaGktdHJhY2tlcjpZT1VSLUNMSUVOVC1TRUNSRVQ=  (base64 of "sushi-tracker:YOUR-CLIENT-SECRET")

grant_type=authorization_code
code=SplxlOBeZQQYbYS6WxSbIA
redirect_uri=https://sushi.example.com/callback
code_verifier=dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk

The redirect_uri is REQUIRED here if it appeared in the authorization request, and the two values "MUST be identical" (4.1.3) - the check RFC 9700 §4.5.2 observes providers "very often ignore". A confidential client authenticates with its secret (Basic or body); a public client sends code_verifier and itsclient_id instead.

Step 7 - the token response (5.1)
HTTP/1.1 200 OK
Content-Type: application/json
Cache-Control: no-store
Pragma: no-cache

{
  "access_token": "eyJhbGciOiJSUzI1NiIsImtpZCI6IjIyIn0.<payload>.<signature>",
  "token_type": "Bearer",
  "expires_in": 3600,
  "refresh_token": "<refresh-token-value>",
  "scope": "orders:read"
}

expires_in is RECOMMENDED, not guaranteed - RFC 6749 names no required access-token lifetime (the "one hour or less" figure is RFC 6750's recommendation). Cache-Control: no-store is a MUST on every token response. What the token is - opaque string or JWT - is deliberately undefined here.

Not shown

Two things the browser never does: call the API, and see a token. The code crosses the front channel precisely so the valuable credentials do not have to - that is the grant's entire reason for existing.

04 · Three variants, one grant
ClientSecret?Step 6 authenticates withDeep dive
Server-side web appYes - confidentialClient secret over the back channel (Basic or body; asymmetric methods preferred per RFC 9700 §2.5)This page, as drawn
SPA (browser app)No - publicPKCE verifier (MUST per RFC 9700 §2.1.1); the token endpoint needs CORSRFC 10017: browser apps ranks the architectures
Mobile / desktop appNo - publicPKCE verifier; the redirect lands via loopback port or URI schemeRFC 8252: native apps
05 · Refresh tokens

Access tokens are short-lived by design; refresh tokens are how the session outlives them without another user login (grant type refresh_token, section 6). The rules a client actually feels: the requested scope "MUST NOT include any scope not originally granted" (narrowing yes, expansion no); if the server rotates the refresh token, the client MUST discard the old one immediately.

For public clients the security BCP closes the loop that 2012 left open: refresh tokens for public clients "MUST be sender-constrained or use refresh token rotation" (RFC 9700 §2.2.2), and rotation must detect reuse - a replayed rotated token means theft, so the server revokes the active refresh token (§4.14.2). The mechanism from section 10.4 made mandatory. If your SPA holds a refresh token and cannot detect its theft, it does not hold a refresh token; it holds a liability.

06 · How it goes wrong
AttackMovesStops it
Code interceptionMalicious app or network observer captures the code from the front channel and redeems it itself (RFC 7636 §1, "observed in the wild")PKCE: the thief lacks the verifier; exchange fails with invalid_grant
CSRF / forged callbackAttacker splices their own code (or an error) into a callback the victim's browser followsOne-time state bound to the user agent (RFC 9700 §4.7.1); or PKCE itself once AS support is verified (§2.1)
Authorization code injectionAttacker injects a code stolen from their own session into the victim's client, marrying the victim's identity to the attacker's resources (RFC 9700 §4.5.1)PKCE or OIDC nonce validation (4.5.3); server-side redirect_uri enforcement at the exchange
Mix-up between ASesMulti-AS client is tricked into sending a code to the wrong issuer's token endpoint (RFC 9207 §1)The iss authorization-response parameter (RFC 9207 §2), verified with string comparison on every response
Redirect tamperingLoose redirect matching lets codes land on attacker-controlled paths - subdomain takeover included (RFC 9700 §4.1.1)Exact string matching everywhere except native loopback ports (§2.1); no open redirectors (§4.11.1)
Referrer leakageThe callback page's outgoing links and third-party scripts leak the code in the Referer header (RFC 9700 §4.2)No third-party resources on the callback; Referrer-Policy: no-referrer; invalidate state after first use
07 · Primary sources