TestingAPIs is still being built. If something breaks, email support@testingapis.com.

Authorization code flow with PKCE

Every request in the flow, with a code verifier you generate yourself.

The authorization code flow is how almost every app signs users in with an OAuth provider. The user is sent to the provider, approves, and comes back with a one-time code that the app exchanges for tokens. PKCE, short for Proof Key for Code Exchange and defined in RFC 7636, adds one check to that exchange. It was designed for mobile apps, and current guidance, including RFC 9700, recommends it for every client.

This lesson runs the whole flow against the OAuth 2.0 test server, one request at a time, then breaks it in every way a server must notice. The previous lesson explains the roles and grants if you need them first.

What PKCE protects against

The code travels back to the app through the browser, in a redirect URL. On a phone, that URL may open a custom scheme like myapp://callback, and nothing stops a malicious app from registering the same scheme and receiving the code. Codes can also leak through logs and browser history. Without PKCE, a public client has no secret, so whoever holds the code can exchange it.

PKCE ties the code to the app that started the flow. Before redirecting, the app makes up a random secret, the code verifier, and sends only its hash, the code challenge. When exchanging the code, it sends the verifier itself. The server hashes it and compares. A thief who intercepted the code never saw the verifier, and can't work it out from the hash.

Making a verifier and challenge

The verifier is 43 to 128 characters from letters, digits and -._~. Thirty-two random bytes in Base64url give 43 characters, which is the usual choice. The challenge is the SHA-256 hash of the verifier, in Base64url without padding. You can check your code against the worked example in RFC 7636, whose verifier and challenge are these:

Terminal
$ VERIFIER="dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"
$ echo -n "$VERIFIER" | openssl dgst -sha256 -binary | base64 | tr '+/' '-_' | tr -d '='
E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM

Base64url is Base64 with + and / replaced by - and _, and the trailing = removed. Getting that conversion wrong is the most common PKCE bug. Leave the = on and every exchange fails with invalid_grant, since a SHA-256 hash in Base64 always ends with one. Forget to swap + and / and about three exchanges in four fail, whenever the hash happens to contain one of them, which makes the bug look intermittent.

The flow, request by request

This script does everything a real app does, except show a login page: the test server approves every request automatically, as user apibee.

JavaScript
// pkce.mjs: the authorization code flow with PKCE, one request at a time
import { createHash, randomBytes } from "node:crypto";

const SERVER = "https://api.testingapis.com/api/auth/oauth";
const CLIENT_ID = "apibee-public";
const REDIRECT_URI = "http://localhost:5555/callback";

// 1. A random code verifier, and its SHA-256 hash as the challenge
const verifier = randomBytes(32).toString("base64url");
const challenge = createHash("sha256").update(verifier).digest("base64url");
const state = randomBytes(16).toString("hex");
console.log({ verifier, challenge, state });

// 2. Send the user to /authorize. This server approves automatically and redirects at once.
const authorizeUrl = `${SERVER}/authorize?${new URLSearchParams({
  response_type: "code",
  client_id: CLIENT_ID,
  redirect_uri: REDIRECT_URI,
  scope: "read profile",
  state,
  code_challenge: challenge,
  code_challenge_method: "S256",
})}`;
const authorize = await fetch(authorizeUrl, { redirect: "manual" });
const callback = new URL(authorize.headers.get("location"));
console.log(authorize.status, callback.href);

// 3. The callback: check state before anything else
if (callback.searchParams.get("state") !== state) throw new Error("state mismatch: possible CSRF");
const code = callback.searchParams.get("code");

// 4. Exchange the code, proving it's the same client with the verifier
const token = await fetch(`${SERVER}/token`, {
  method: "POST",
  headers: { "Content-Type": "application/x-www-form-urlencoded" },
  body: new URLSearchParams({
    grant_type: "authorization_code",
    client_id: CLIENT_ID,
    code,
    redirect_uri: REDIRECT_URI,
    code_verifier: verifier,
  }),
});
const tokens = await token.json();
console.log(token.status, tokens);

// 5. Use the access token
const me = await fetch(`${SERVER}/userinfo`, { headers: { Authorization: `Bearer ${tokens.access_token}` } });
console.log(me.status, await me.json());
This example uses endpoints for signed-in members.Sign inCreate a free account
Output
{
  verifier: 'RFIKThzdwqx6HimCsbygkCLmOmwqFvIEZzvcLnw01Fg',
  challenge: 'cFWFn7k6Rdm-iISqEz5gASFMgh99lMyAE3WaAE5uYhI',
  state: 'feffc5408bdf2d7577530ce32654ab75'
}
302 http://localhost:5555/callback?code=code_bbdf770c6c7260f496c72c26a26b62b253c160db&state=feffc5408bdf2d7577530ce32654ab75
200 {
  access_token: 'oat_f422033aa8d64859b804590fb30dfc26c605688c',
  token_type: 'Bearer',
  expires_in: 3600,
  refresh_token: 'ort_1fd45f953b1e4d2291da4023bc258e6dfcd50704',
  scope: 'read profile'
}
200 {
  sub: 'apibee',
  name: 'TestingAPIs Tester',
  preferred_username: 'apibee',
  email: null,
  email_verified: null
}
  1. /authorize gets the client ID, where to send the user back, the scopes, a state value, and the challenge with its method.
  2. The server answers 302 to the redirect URI, with the code and the same state in the query string. Codes here are single use and expire after 10 minutes.
  3. The app checks state matches what it sent. That stops an attacker from feeding the app a code from their own account, a login CSRF attack. Then it exchanges the code with the verifier and the same redirect URI.
  4. The token response has an access token, a refresh token and the granted scope.

Without a redirect

To keep testing easy, this server only redirects to localhost, 127.0.0.1and Postman's callback addresses. Leave out redirect_uri and it returns the code as JSON instead, which means you can run the flow with curl alone:

Terminal
$ curl "https://api.testingapis.com/api/auth/oauth/authorize?response_type=code&client_id=apibee-public&scope=read%20profile&state=xyz123&code_challenge=i0MY0PKrHxCpOAeDhxCj64h_MmJJhW5dYKWyAhfyT2A&code_challenge_method=S256"

{"code":"code_3f7fdbef5b5df0d22c491925aadc04c6dd3bec76","state":"xyz123","redirect_uri":null,"expires_in":600,"message":"No redirect_uri given — exchange this code at POST /api/auth/oauth/token."}
This example uses endpoints for signed-in members.Sign inCreate a free account

What must fail

A PKCE implementation is only as good as its refusals. Each row was tried against this server with a fresh code:

What was sentResponse
The right code with a different verifier400 invalid_grant, PKCE verification failed: code_verifier does not match code_challenge.
A code that was already exchanged400 invalid_grant, Authorization code is invalid, expired or already used.
A different redirect_uri at the token step400 invalid_grant, redirect_uri does not match the one used at /authorize.
A public client at /authorize with no code_challenge400 invalid_request, Public clients must use PKCE (code_challenge).
An unregistered redirect_uri, https://evil.example/cb400 invalid_request, redirect_uri is not registered for this client. No code is issued
response_type=token400 unsupported_response_type
An unknown client_id400 invalid_client

One detail caught the tests out at first: a failed exchange uses up the code. After the wrong-verifier attempt, the same code with the rightverifier was refused as already used, and the mismatched-redirect attempt burned its code too. That's a sound security choice, since it gives an attacker one guess per code, but it means each negative test needs its own fresh code. A suite that reuses one code across tests will fail in confusing ways.

The redirect row is the first thing to test on any real provider. If a server sends a code to any address it's given, an attacker can start a flow with their own redirect URI, get a victim to approve it, and collect the code. The refusal has to happen before a code exists, as it does here.

One thing this server allows that many providers don't

The plain method. RFC 7636 allows code_challenge_method=plain, where the challenge is the verifier itself, for clients that can't compute SHA-256. This server accepts it, and it's the default if the method is left out. Since a plain challenge travels through the browser just like the code, it offers far less protection. Many providers refuse plain entirely. Find out whether yours does, and whether leaving the method out quietly gives you plain.

Exercise

Run the script, then change it so step 4 sends the challenge instead of the verifier, a mistake that turns up in real client code. What does the server say? Next, generate a verifier of 42 characters and one of 129, both outside the allowed length, and see whether the server enforces the limits at /authorize, at /token, or not at all.

Practise and go further