Guide
OAuth 2.0 Authorization Code Flow with PKCE, Step by Step
How the OAuth 2.0 authorization code grant works end to end: the request/response shapes, PKCE, refresh token rotation, error responses, and where RFC 10017 now says to actually store the tokens.
OAuth 2.0 solves one specific problem: letting an application act on your behalf against some other service without ever seeing your password for that service. When a third-party app asks to “connect your Google Calendar,” OAuth is what lets you grant it a narrow, revocable permission instead of handing over your actual Google credentials.
RFC 6749, the core spec, builds every flow out of four roles.1 The resource owner is usually you, the person who can grant access. The client is the application asking for it, whether that’s a web app, a mobile app, or a CLI tool. The authorization server authenticates you and issues tokens (accounts.google.com, say). The resource server is the API that actually holds what the client wants (the Calendar API itself). Keep these four apart in your head and the rest of the spec stops being alphabet soup.
Of the several flows OAuth defines, almost everything built today uses one: the authorization code grant, paired with an extension called PKCE. This guide walks through both, then covers the parts most tutorials skip: what happens when a request fails, how a client gets a new access token without making the user log in again, and where current guidance says the resulting tokens should actually live.
The authorization code flow
RFC 6749 Section 4.1 lays this out as six steps, labeled (A) through (F).1 The client never touches your password. It only ever sees a short-lived code, and later, a token.
sequenceDiagram participant U as User's browser participant C as Client participant A as Authorization server participant R as Resource server C->>U: (A) Redirect to authorization endpoint U->>A: GET /authorize?response_type=code&client_id=... A->>U: (B) Login + consent screen A->>U: (C) Redirect back with ?code=...&state=... U->>C: Browser delivers the code C->>A: (D) POST /token, grant_type=authorization_code, code=... A->>C: (E) access_token (+ refresh_token) C->>R: (F) GET /resource, Authorization: Bearer <token>
Step (A)’s authorization request carries a handful of parameters that matter in practice: response_type=code (required, naming the flow), client_id, redirect_uri, scope, and state, an opaque value the client generates and checks on the way back to confirm the response actually answers a request it made.1 Step (D)’s token request carries grant_type=authorization_code, the code itself, and the same redirect_uri sent in step (A). RFC 6749 doesn’t leave room to interpret that last part: the two redirect URIs “MUST be identical.”1
Why PKCE gets added on top
The flow above has a gap. A mobile app often can’t keep a secret: its client_id is public, and the redirect in step (C) frequently lands on a custom URI scheme that more than one app on the device can register to receive. RFC 7636 names the resulting risk directly: “OAuth 2.0 public clients utilizing the Authorization Code Grant are susceptible to the authorization code interception attack,” where a malicious app registered for the same scheme grabs the code before the real one does.2
PKCE, short for Proof Key for Code Exchange, closes that gap by making the client prove, at token-exchange time, that it’s the same client that started the flow. Before the redirect in step (A), the client generates a code_verifier, a random string between 43 and 128 characters drawn from unreserved URL characters, and derives a code_challenge from it.2 The S256 method, base64url-encoding the SHA-256 hash of the verifier, is the one every implementation is required to support. The authorization request carries the challenge, not the verifier; only the token request in step (D) carries the raw verifier, and the authorization server recomputes its hash and checks it against the challenge it was handed back in step (A), rejecting the exchange on any mismatch.2 An attacker who intercepts the redirect in step (C) has the code, but not the verifier, which never left the original client.
RFC 7636’s own worked example shows the shape of the two values. Rather than copy it blind, I recomputed it directly with Node’s crypto module to confirm it actually holds:23
code_verifier: dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk
code_challenge: E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM
What the actual requests look like
Step (A), the browser redirect, is just a GET request built from query parameters:
GET https://auth.example.com/authorize
?response_type=code
&client_id=abc123
&redirect_uri=https://app.example.com/callback
&scope=calendar.read
&state=xyz789
&code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM
&code_challenge_method=S256
Step (D), the token exchange, happens server-to-server and the user’s browser never sees it:
curl -X POST https://auth.example.com/token \
-d grant_type=authorization_code \
-d code=THE_CODE_FROM_STEP_C \
-d redirect_uri=https://app.example.com/callback \
-d client_id=abc123 \
-d code_verifier=dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk
A successful response is JSON: access_token, a token_type of Bearer, an expires_in in seconds, and usually a refresh_token. That last field is what lets the client get a new access token later without sending the user back through steps (A) through (C).
Getting a new access token without a new login
Access tokens are deliberately short-lived, so a client that got a refresh_token back in step (E) can trade it for a fresh access token on its own. RFC 6749 Section 6 defines this as its own grant type, and the request is almost suspiciously simple:1
curl -X POST https://auth.example.com/token \
-H "Authorization: Basic czZCaGRSa3F0MzpnWDFmQmF0M2JW" \
-d grant_type=refresh_token \
-d refresh_token=tGzv3JOkF0XG5Qx2TlKWIA
That’s RFC 6749’s own example request, reproduced verbatim.1 The Authorization header is only there because this particular client is confidential and authenticates itself; a public client (a mobile app, say) skips it and sends client_id as a body parameter instead.
Here’s what the base spec doesn’t fully cover, and what RFC 9700 adds: a leaked refresh token is a much bigger problem than a leaked access token, because it’s longer-lived and grants everything the original token did.4 For public clients specifically, RFC 9700 requires authorization servers to defend against a stolen refresh token in one of two ways. One is cryptographically binding the token to the specific client instance that requested it. The far more common one is refresh token rotation: every time a refresh token gets used, the server issues a brand new one and invalidates the old one. If a stolen token and the legitimate client both eventually try to use the same now-invalidated refresh token, the server can’t tell which one is the attacker, but it revokes the whole chain either way, which is exactly the point: the theft stops being quietly usable forever and starts being self-reporting.4
If your authorization server returns a new refresh_token in every refresh response, that’s not an API quirk. It’s the rotation mechanism working as designed, and your client needs to store the new one and discard the old one every single time, not just on first login.
What a failed request actually looks like
Every OAuth tutorial shows the success path. Section 5.2 of RFC 6749 is just as precise about failure: an error comes back as HTTP 400, with a JSON body naming one of six fixed error codes.1
| Error code | What it means |
|---|---|
invalid_request |
A required parameter is missing, malformed, or duplicated |
invalid_client |
Client authentication failed |
invalid_grant |
The code or refresh token is invalid, expired, revoked, or was issued to a different client |
unauthorized_client |
This client isn’t allowed to use this grant type |
unsupported_grant_type |
The server doesn’t support the grant_type you sent |
invalid_scope |
The requested scope is invalid or exceeds what was actually granted |
{
"error": "invalid_grant",
"error_description": "Authorization code expired"
}
invalid_grant is the one worth remembering: it’s what you get back for an authorization code used twice, a code that already expired (they’re typically valid for under a minute), or a refresh token a rotation policy already invalidated. If you see it intermittently in production, the first thing to check is whether two requests raced to use the same code or refresh token, not whether your client secret is wrong.
PKCE and exact redirect matching aren’t just for native apps anymore
PKCE was originally built to protect “public” clients: apps that can’t hold a secret, like mobile and single-page apps. RFC 9700, the current OAuth 2.0 security best practice published in 2025, pushes that advice further than its original scope: “Although PKCE was designed as a mechanism to protect native apps, this advice applies to all kinds of OAuth clients, including web applications,” and recommends it even for confidential, server-side clients that do hold a secret.4
Two more pieces of current guidance are worth knowing if you learned OAuth from an older writeup. RFC 9700 says clients “SHOULD NOT use the implicit grant,” the older flow that returned a token directly in a redirect fragment instead of an authorization code; if a tutorial has you doing that, it’s describing something the spec’s own current guidance has moved past.4 And redirect URI matching is exact: authorization servers must “utilize exact string matching,” with the sole carve-out being the port number on localhost redirects used by native apps during local development.4 A trailing slash mismatch is enough for a correctly configured server to refuse the request outright.
That strictness exists because a redirect URI with any wiggle room becomes an open redirector, and RFC 9700 spends real effort on exactly that failure mode: an attacker-controlled page that the authorization server will redirect users to, armed with nothing more than a validly-registered client.4 One version of this relies on an abandoned DNS record: a redirect URI pointing at a subdomain whose CNAME still resolves, but to infrastructure the original owner no longer controls, which an attacker can then claim. Exact string matching doesn’t fix a dangling DNS record, but it does close off the softer version of this problem, where a server accepts https://app.example.com/callback/anything because it only checked the prefix.
Where should the tokens actually live?
This is the question most OAuth writeups never get to, and it was an open problem in the ecosystem for years. RFC 10017, “OAuth 2.0 for Browser-Based Applications,” only became an IETF Best Current Practice in August 2026, which makes it newer than most of the advice still circulating online.5
It ranks three architectures in decreasing order of security. At the bottom is the browser-based OAuth client, where the single-page app itself holds the access and refresh tokens, in memory, a cookie, or localStorage. The document’s objection isn’t really about which browser storage API you pick; it’s structural. “Malicious JavaScript code has the same privileges as the legitimate application code,” so any successful XSS on the page can reach whatever’s holding the token, and even a storage mechanism that fully isolates the token doesn’t stop the attacker from simply running a fresh authorization flow of their own.5 One step up is a token-mediating backend, a small server that handles the OAuth exchange but still hands the resulting tokens to the browser afterward, moving some risk around without removing it.
At the top is the pattern RFC 10017 says is “strongly recommended for business applications, sensitive applications, and applications that handle personal data”: the Backend for Frontend, or BFF.5 Here the server, not the browser, holds the OAuth client role entirely. The browser only ever gets a session cookie, and the document is specific about how that cookie has to be configured: “The BFF MUST enable the HttpOnly flag for its cookies.”5 Because HttpOnly cookies are invisible to JavaScript by design, “there are no tokens available to extract from the browser” even if the page’s JavaScript is fully compromised.5
None of this makes a BFF immune to XSS. A compromised page can still ride the session cookie to make authenticated requests for as long as the attacker’s script runs. What it removes is persistence and reach: there’s no long-lived token sitting in localStorage for the attacker to exfiltrate and replay later from their own machine, and no refresh token to steal and keep using after the user closes the tab. For a backend you already control, routing OAuth through it isn’t an exotic architecture choice; per RFC 10017, it’s now the specific thing a security review is going to ask why you didn’t do.5
References
Footnotes
-
IETF, RFC 6749, “The OAuth 2.0 Authorization Framework” (Hardt, 2012), Section 1.1 for the four roles, Section 4.1 for the authorization code grant, Section 5.2 for the error response format and codes, Section 6 for the refresh token grant and its example request. ↩ ↩2 ↩3 ↩4 ↩5 ↩6 ↩7
-
IETF, RFC 7636, “Proof Key for Code Exchange by OAuth Public Clients” (Sakimura, Bradley and Agarwal, 2015), for the authorization code interception attack, the
code_verifier/code_challengemechanism, and the S256 transformation. ↩ ↩2 ↩3 ↩4 -
The
code_verifier/code_challengepair in this guide is RFC 7636 Appendix B’s own worked example, independently recomputed with Node.js’scryptomodule (sha256digest, base64url-encoded) to confirm it, rather than copied without checking. ↩ -
IETF, RFC 9700, “Best Current Practice for OAuth 2.0 Security” (Lodderstedt, Bradley, Labunets and Fett, 2025), Section 2.1 for redirect URI matching and extending PKCE to confidential and web clients, Section 2.1.2 for deprecating the implicit grant, Section 4.11 for open redirection and the subdomain takeover scenario, Section 4.14 for refresh token protection and rotation. ↩ ↩2 ↩3 ↩4 ↩5 ↩6
-
IETF, RFC 10017, “OAuth 2.0 for Browser-Based Applications” (Parecki, De Ryck and Waite, 2026), BCP 212, published August 2026, Section 6 for the three ranked architectures, Section 6.1 for the Backend for Frontend pattern and its
HttpOnlycookie requirement. ↩ ↩2 ↩3 ↩4 ↩5 ↩6