← Recipes

阅读中文版本

Add PKCE to an OAuth Public Client Flow

Generate a per-request code verifier, send its challenge in the authorization request, and redeem the code proving possession of the verifier.

~25 minsecurityoauthauthentication

Task

Harden an OAuth authorization-code flow for public clients with PKCE so intercepted codes are useless.

When to use this

A mobile app uses the plain authorization-code flow. Authorization codes can be intercepted by malicious apps through custom URI schemes, and a stolen code becomes a session.

Prerequisites

  • An authorization server that supports PKCE, RFC 7636 or OIDC
  • A public client such as a mobile app or SPA
  • A crypto source for the code verifier

Steps

1. Generate a high-entropy verifier per attempt.

const codeVerifier = crypto.randomBytes(32).toString("base64url");
const codeChallenge = crypto
  .createHash("sha256")
  .update(codeVerifier)
  .digest("base64url");

Expected output: A 43-character verifier and its SHA-256 challenge, single-use and never reused across attempts.

2. Send the challenge with the authorization request.

const url = new URL("https://auth.example.com/authorize");
url.searchParams.set("code_challenge", codeChallenge);
url.searchParams.set("code_challenge_method", "S256");
url.searchParams.set("response_type", "code");
url.searchParams.set("client_id", CLIENT_ID);
url.searchParams.set("redirect_uri", REDIRECT_URI);

Expected output: The authorization server binds the issued code to this specific challenge value.

3. Redeem the code while proving the verifier.

const res = await fetch("https://auth.example.com/token", {
  method: "POST",
  body: new URLSearchParams({
    grant_type: "authorization_code",
    code: authCode,
    code_verifier: codeVerifier,
    client_id: CLIENT_ID,
    redirect_uri: REDIRECT_URI,
  }),
});

Expected output: The server hashes the verifier and compares to the stored challenge; an intercepted code without the verifier fails here.

4. Reject the plain challenge method.

if (params.code_challenge_method !== "S256") {
  throw new Error("plain challenge method not allowed");
}

Expected output: S256-only policy prevents downgrade to the plain method, where the challenge itself reveals the verifier.

5. Test the interception scenario.

node -e 'const stolen = await redeem(authCode, wrongVerifier); console.log(stolen.status)'

Expected output: Redeeming with any verifier other than the original returns 400 invalid_grant, proving a stolen code is inert.

Troubleshooting

Problem

Server rejects S256

Solution

The authorization server predates PKCE support or needs it enabled per client; check its metadata for code_challenge_methods_supported before coding around it.

Problem

State parameter missing alongside PKCE

Solution

PKCE protects the code, state protects CSRF and carries correlation; use both, since they answer different attacks.

Problem

Verifier stored insecurely between steps

Solution

The verifier must survive only until token redemption; keep it in memory tied to the request, not in durable storage where a later reader can reuse it.

Limitations

  • PKCE protects the code exchange; it does not protect a compromised authorization server or malicious redirect registration.
  • Confidential clients should additionally use client authentication, where PKCE remains good defense in depth.

A stolen code that opens nothing

The authorization-code flow has a structural weakness for public clients: the code travels through redirect mechanisms that other apps can intercept. PKCE welds the code to something the attacker never captured: a per-request random verifier whose hash traveled with the request and whose value is proven at redemption. The intercepted code is then a receipt for a transaction the thief cannot complete.

// Per attempt
const codeVerifier = crypto.randomBytes(32).toString("base64url");
const codeChallenge = crypto.createHash("sha256")
  .update(codeVerifier).digest("base64url");

// authorize?...code_challenge=...&code_challenge_method=S256
// token: code + code_verifier (the proof)

S256 or nothing

The plain challenge method sends the verifier itself as the challenge, which means an interceptor watching the authorization request learns the value needed at redemption. It exists for environments without hashing, which no longer describes yours. Insist on S256 in the request and reject anything else in your policy, so downgrade is structurally impossible.

State and PKCE answer different questions

PKCE proves the token requester is the same party that initiated authorization. State proves the response belongs to a request this client issued and carries return routing. Dropping state because PKCE exists reopens CSRF and breaks deep-link correlation; the two are complementary protections, not alternatives.