privileged.id

OAuth 2.0

Authorization code flow with PKCE

The one OAuth flow every user-facing client should use, request by request, with the RFC 7636 test vector and the rules from RFC 9700 and RFC 8252.

Reviewed 8 min read 7 primary sources

The authorization code grant is the OAuth flow for any application acting on behalf of a person. The user's browser carries a short-lived, single-use authorization code from the authorization server back to the client, and the client exchanges that code for tokens on a direct back-channel request. PKCE (Proof Key for Code Exchange, RFC 7636) binds the code to the client instance that started the flow, so a stolen or injected code is useless.

PKCE was designed for native apps, but RFC 9700 §2.1.1 applies it to every client: public clients MUST use PKCE, and for confidential clients it is RECOMMENDED. Authorization servers MUST support it. The OAuth 2.1 Internet-Draft makes it the default form of the grant.

The flow at a glance

Authorization code flow with PKCE, step by step User agent browser Client your app Authorization server Resource server (API) 1. Make verifier, challenge, state 2. 302 to /authorize 3. GET /authorize + code_challenge 4. User signs in and consents 5. 302 to redirect_uri: code, state 6. GET /cb?code&state 7. code + verifier 8. Check SHA-256(verifier) 9. Tokens 10. API call with access token
Steps 2 to 6 travel through the browser (front channel). Steps 7 and 9 are a direct HTTPS call from the client to the token endpoint (back channel). The verifier never passes through the browser.

Step by step, with HTTP

The examples use the PKCE values from RFC 7636 Appendix B, recomputed for this page, and the client and host names used throughout the OAuth RFCs. Line breaks inside URLs and bodies are for display only.

1. The client creates a verifier, a challenge and a state value

For every authorization request, the client generates a fresh code verifier: a high-entropy random string of 43 to 128 characters drawn from the unreserved set A-Z a-z 0-9 - . _ ~ (RFC 7636 §4.1). The RFC recommends 32 random octets, base64url-encoded, which gives a 43-character string with 256 bits of entropy. It then derives the challenge:

code_verifier  = dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk
code_challenge = BASE64URL-ENCODE(SHA256(ASCII(code_verifier)))
               = E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM

The client stores the verifier, along with a random state value, in the user's session (for a server-side app) or in the app's own storage (for a native or browser app). Both must be tied to this browser session and this transaction.

2. The client sends the browser to the authorization endpoint

HTTP/1.1 302 Found
Location: https://as.example.com/authorize?response_type=code
  &client_id=s6BhdRkqt3
  &redirect_uri=https%3A%2F%2Fclient.example.com%2Fcb
  &scope=calendar.read
  &state=af0ifjsldkj
  &code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM
  &code_challenge_method=S256

Only the hash goes into the URL. Anyone who reads the authorization request (browser history, a proxy log, a malicious extension) learns the challenge but cannot compute the verifier from it.

3. The user authenticates and consents

The authorization server authenticates the user however it likes (password plus phishing-resistant MFA, an existing session, a passkey) and asks them to approve the requested scope. It records the challenge and method against the code it is about to issue (RFC 7636 §4.4 MUST).

4. The authorization server redirects back with a code

HTTP/1.1 302 Found
Location: https://client.example.com/cb?code=SplxlOBeZQQYbYS6WxSbIA
  &state=af0ifjsldkj
  &iss=https%3A%2F%2Fas.example.com

RFC 6749 §4.1.2 says the code MUST expire shortly after issue (a maximum lifetime of 10 minutes is RECOMMENDED) and that the client MUST NOT use it more than once. If a code is presented twice, the server MUST deny the request and SHOULD revoke tokens already issued from it. The iss parameter comes from RFC 9207 and lets a client that talks to several authorization servers confirm which one answered; see mix-up attacks.

5. The client checks state and redeems the code

The client compares state with the value it stored, rejects the response on any mismatch, and invalidates the stored value. Then it calls the token endpoint directly, sending the verifier:

POST /token HTTP/1.1
Host: as.example.com
Content-Type: application/x-www-form-urlencoded

grant_type=authorization_code
&code=SplxlOBeZQQYbYS6WxSbIA
&redirect_uri=https%3A%2F%2Fclient.example.com%2Fcb
&client_id=s6BhdRkqt3
&code_verifier=dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk

This example is a public client, so it sends client_id and no secret. A confidential client also authenticates here (an Authorization: Basic header, a private_key_jwt assertion, or mutual TLS; see client authentication methods). Under RFC 6749 the redirect_uri is required in the token request if it was sent in the authorization request, and the values must be identical; the OAuth 2.1 draft drops this parameter because PKCE already prevents code injection.

6. The authorization server verifies and issues tokens

The server recomputes BASE64URL-ENCODE(SHA256(ASCII(code_verifier))), compares it with the stored challenge, and on any mismatch returns invalid_grant (RFC 7636 §4.6 MUST). On success:

HTTP/1.1 200 OK
Content-Type: application/json
Cache-Control: no-store
Pragma: no-cache

{
  "access_token": "2YotnFZFEjr1zCsicMWpAA",
  "token_type": "Bearer",
  "expires_in": 3600,
  "refresh_token": "tGzv3JOkF0XG5Qx2TlKWIA",
  "scope": "calendar.read"
}

RFC 6749 §5.1 requires Cache-Control: no-store on any response containing tokens. If the granted scope differs from what was requested, the scope member is REQUIRED. For a public client, the refresh token must be either rotated on every use or sender-constrained (RFC 9700 §2.2.2 MUST); see tokens.

PKCE in detail: S256, plain and downgrades

RFC 7636 defines two transformations. With S256 the challenge is the SHA-256 hash of the verifier; with plain the challenge is the verifier itself. The rules:

  • A client capable of S256 MUST use it; S256 is mandatory to implement on the server. plain is allowed only for clients that cannot do S256 and know the server supports plain (§4.2).
  • Clients MUST NOT downgrade to plain after trying S256 (§7.2). An error in response to S256 means either a faulty server or an active attacker.
  • If code_challenge_method is omitted, it defaults to plain (§4.3). Always send S256 explicitly.
  • RFC 9700 §2.1.1: clients SHOULD use a method that does not expose the verifier in the authorization request, and "currently, S256 is the only such method". The OAuth 2.1 draft removes plain entirely.

RFC 9700 adds three server-side requirements that close gaps attackers have used:

  1. If the authorization request carried a valid code_challenge, the server MUST enforce correct use of code_verifier at the token endpoint.
  2. To stop the PKCE downgrade attack, the server MUST reject a token request that contains a code_verifier when the authorization request had no code_challenge (§4.8.2). Otherwise an attacker strips the challenge from their own request and injects the resulting unbound code into a victim's session.
  3. The server MUST provide a way to detect PKCE support; publishing code_challenge_methods_supported in RFC 8414 metadata is RECOMMENDED.

state, PKCE and nonce: what each one protects

Three values travel with the authorization request and are easy to confuse. They overlap but are not interchangeable.

ValueDefined inChecked byProtects againstDoes not protect against
stateRFC 6749 §4.1.1 (RECOMMENDED)Client, at the redirect URICSRF on the redirect endpoint: an attacker feeding their own code into the victim's session. Also carries application state.A stolen code being redeemed by the attacker. And if it leaks (for example through a Referer header), its CSRF protection is lost.
PKCE code_verifierRFC 7636Authorization server, at the token endpointRedemption of a stolen code by anyone else; code injection; and, as a side effect, CSRF (RFC 9700 §4.7.1).An attacker who can tamper with the victim's authorization request itself (RFC 9700 §4.5.4).
nonceOpenID Connect CoreClient, inside the ID tokenID token replay and, if checked on the token-endpoint ID token before any token is used, code injection for confidential OIDC clients.Stolen codes of public clients: the attacker can redeem them directly (RFC 9700 §4.5.3.2).

RFC 9700 §2.1 puts it this way: clients MUST prevent CSRF. Clients that have confirmed the authorization server supports PKCE MAY rely on PKCE for this; in OpenID Connect flows the nonce provides it; otherwise a one-time CSRF token in state, securely bound to the user agent, MUST be used. Whatever you use, the PKCE challenge or OIDC nonce MUST be transaction-specific and bound to the client and user agent that started the transaction (§2.1.1). A constant challenge baked into an app protects nothing.

Exact redirect URI matching

Most real-world code theft has come from loose redirect URI validation: wildcard patterns, prefix matching, or ignoring the path. RFC 9700 settles it:

When comparing client redirection URIs against pre-registered URIs, authorization servers MUST utilize exact string matching except for port numbers in localhost redirection URIs of native apps.

"Exact" means simple string comparison as defined in RFC 3986 §6.2.1: no normalization, no case folding, no trailing-slash tolerance (§4.1.3). Related rules in the same document: clients and authorization servers MUST NOT expose open redirectors; redirect URIs using plain http MUST NOT be allowed except for native-app loopback redirects; and if the redirect URI is wrong, RFC 6749 §4.1.2.1 says the server MUST NOT automatically redirect the browser to it.

Native apps (RFC 8252)

RFC 8252 (BCP 212) is the best current practice for mobile and desktop apps:

  • Native apps MUST use an external user-agent, normally the system browser or an in-app browser tab provided by the OS, for authorization requests (§5), and MUST NOT use embedded user-agents such as a web view the app controls (§8.12). An embedded view lets the app read the user's credentials and session cookies, and trains users to type passwords into unverifiable screens.
  • Public native clients MUST implement PKCE, and servers MUST support it for them (§6).
  • Three redirect options exist (§7):
    • Claimed https scheme redirects, such as https://app.example.com/oauth2redirect/example-provider, where the OS verifies the app owns the domain. Apps SHOULD use these where possible.
    • Private-use URI schemes, which MUST be based on a domain the app controls in reverse order, for example com.example.app:/oauth2redirect/example-provider. A scheme like myapp: does not meet the requirement, and servers SHOULD reject schemes without a period.
    • Loopback redirects such as http://127.0.0.1:51004/oauth2redirect/example-provider for desktop apps. The server MUST allow any port at request time. Using localhost instead of the IP literal is NOT RECOMMENDED (§8.3).

Pushed authorization requests (RFC 9126)

With PAR, the client POSTs the authorization parameters directly to the authorization server, authenticating as it would at the token endpoint, and gets back a short-lived, single-use request_uri. The browser then carries only that reference:

POST /as/par HTTP/1.1
Host: as.example.com
Content-Type: application/x-www-form-urlencoded

response_type=code&client_id=s6BhdRkqt3&state=af0ifjsldkj
&redirect_uri=https%3A%2F%2Fclient.example.com%2Fcb&scope=calendar.read
&code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM&code_challenge_method=S256
&client_assertion_type=urn%3Aietf%3Aparams%3Aoauth%3Aclient-assertion-type%3Ajwt-bearer
&client_assertion=eyJhbGciOiJSUzI1NiJ9...

HTTP/1.1 201 Created
Content-Type: application/json
Cache-Control: no-cache, no-store

{ "request_uri": "urn:ietf:params:oauth:request_uri:6esc_11ACC5bwc014ltc14eY22c",
  "expires_in": 60 }

GET /authorize?client_id=s6BhdRkqt3
  &request_uri=urn%3Aietf%3Aparams%3Aoauth%3Arequest_uri%3A6esc_11ACC5bwc014ltc14eY22c HTTP/1.1
Host: as.example.com

The benefits: the request cannot be tampered with in the browser, sensitive parameters stay out of URLs and logs, and the server can reject a bad request before the user is involved. RFC 9700 §4.1.3 notes that when the origin and integrity of the request are verified this way, with client authentication, the server MAY trust the redirect URI without further checks.

Checklist

  • A new random verifier (32 octets, base64url) per authorization request, never reused or hard-coded.
  • code_challenge_method=S256 sent explicitly; never fall back to plain.
  • state generated per request, bound to the browser session, checked and then discarded.
  • For OpenID Connect, a per-request nonce, checked in the ID token.
  • Redirect URIs registered in full; the server compares by exact string.
  • Server rejects code_verifier without a prior code_challenge, and enforces it when one was sent.
  • Codes short-lived (≤10 minutes recommended) and single-use; reuse triggers revocation.
  • Native apps use the system browser, a claimed https redirect where possible, and never a web view.
  • If the client talks to more than one authorization server, check the iss response parameter (RFC 9207).
  • Try the flow interactively in the OAuth flow explorer.

Sources

  1. RFC 7636: Proof Key for Code Exchange by OAuth Public Clients, IETF. IETF Trust Legal Provisions (brief quotations with attribution).
  2. RFC 6749: The OAuth 2.0 Authorization Framework, IETF. IETF Trust Legal Provisions (brief quotations with attribution).
  3. RFC 9700: Best Current Practice for OAuth 2.0 Security, IETF. IETF Trust Legal Provisions (brief quotations with attribution).
  4. RFC 8252: OAuth 2.0 for Native Apps, IETF. IETF Trust Legal Provisions (brief quotations with attribution).
  5. RFC 9126: OAuth 2.0 Pushed Authorization Requests, IETF. IETF Trust Legal Provisions (brief quotations with attribution).
  6. RFC 9207: OAuth 2.0 Authorization Server Issuer Identification, IETF. IETF Trust Legal Provisions (brief quotations with attribution).
  7. The OAuth 2.1 Authorization Framework, draft-ietf-oauth-v2-1-16 (Internet-Draft, work in progress), IETF. IETF Trust Legal Provisions (brief quotations with attribution).