- RFC 6749, section 4.1: Authorization Code Grantthe normative text for every message above; section 5.1 governs the token response
- RFC 7636: Proof Key for Code Exchangethe public-client variant of step 6 - demonstrated live in the PKCE console
- RFC 9700: Best Current Practice for OAuth 2.0 Securitythe modern floor: PKCE rules (§2.1.1), redirect matching (§2.1), code injection (§4.5), refresh rotation (§4.14)
- RFC 9207: Authorization Server Issuer Identificationthe
issparameter and the mix-up attack it closes - RFC 10017: OAuth 2.0 for Browser-Based ApplicationsParecki, De Ryck, Waite (IETF, August 2026) - part of BCP 212, with RFC 8252; where the SPA variant's architecture decisions live now
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.
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).
Click a step above to isolate it on the diagram.
The same flow as actual messages, with the parameters that carry the security. Values are illustrative; shapes are exact.
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| Parameter | Status | Why it exists |
|---|---|---|
response_type=code | REQUIRED (4.1.1) | Selects this grant. token selects the retired implicit grant - never for new work. |
client_id | REQUIRED (4.1.1) | Identifies the client. "Not a secret; it is exposed to the resource owner" (2.2). |
redirect_uri | Optional per 3.1.2 registration | Where 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). |
scope | OPTIONAL (3.3) | The permissions being requested; the user consents to exactly this. |
state | RECOMMENDED in 6749 (4.1.1); REQUIRED practice per RFC 9700 §4.7.1 | Opaque per-request value; the CSRF binding. Section 10.12: "MUST contain a non-guessable value". |
code_challenge + method | REQUIRED for public clients (RFC 9700 §2.1.1); required for all under OAuth 2.1 §4.1.1, with one narrow exception | PKCE (RFC 7636): the S256 hash of a one-time verifier the client keeps secret. Makes a stolen code worthless. |
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.
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.
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.
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.
| Client | Secret? | Step 6 authenticates with | Deep dive |
|---|---|---|---|
| Server-side web app | Yes - confidential | Client secret over the back channel (Basic or body; asymmetric methods preferred per RFC 9700 §2.5) | This page, as drawn |
| SPA (browser app) | No - public | PKCE verifier (MUST per RFC 9700 §2.1.1); the token endpoint needs CORS | RFC 10017: browser apps ranks the architectures |
| Mobile / desktop app | No - public | PKCE verifier; the redirect lands via loopback port or URI scheme | RFC 8252: native apps |
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.
| Attack | Moves | Stops it |
|---|---|---|
| Code interception | Malicious 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 callback | Attacker splices their own code (or an error) into a callback the victim's browser follows | One-time state bound to the user agent (RFC 9700 §4.7.1); or PKCE itself once AS support is verified (§2.1) |
| Authorization code injection | Attacker 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 ASes | Multi-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 tampering | Loose 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 leakage | The 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 |