A typical PKCE failure looks like this: the authorization code flow works on the web, but after a mobile SDK upgrade every login on a fresh device ends in invalid_grant, and the server log shows the /token request arrived without a code_verifier, or with a value that does not hash to the challenge sent earlier. Devices with a cached session keep working, so the break is easy to miss in testing, and the debugging usually starts with the question of which value is hashed and which is sent where.

PKCE — Proof Key for Code Exchange — is the OAuth extension that lets clients without a server secret prove they originated the authorization request. RFC 7636 introduced it in 2015 for mobile and native apps. RFC 9700 (2025) requires it for public clients and recommends it for confidential ones, and the OAuth 2.1 draft requires it for every client except a confidential client that the server trusts to use the OpenID Connect nonce correctly. The mechanism is small enough to explain in a paragraph and easy enough to implement wrongly in a way that passes happy-path tests but fails the first time a real client retries. ZeroTool’s PKCE Generator builds the pair in your browser, shows you both the /authorize URL and the /token exchange curl command, refuses to compute the challenge when the verifier breaks the spec’s character set, and tells you why a challenge from your logs does not match.

What PKCE actually does

A standard OAuth 2.0 authorization code flow works like this. The user clicks “Log in with Acme” in your client. You redirect them to https://acme.example/authorize with your client_id and a redirect_uri. They log in. Acme redirects back to your redirect_uri carrying an opaque code in the query string. Your client backend exchanges that code for an access token at https://acme.example/token, authenticating itself with a client_secret it shares with Acme.

The weak spot is the redirect. On a mobile platform, anyone can register a handler for myapp://callback. On a SPA, the code lives briefly in window.location and is vulnerable to anything that can read the URL. If an attacker intercepts the code before your client uses it, and they know your client_id, they can complete the token exchange themselves.

PKCE plugs that hole. Before redirecting to /authorize, the client generates a random secret it calls code_verifier, hashes it with SHA-256, and base64url-encodes the hash to produce a code_challenge. It sends the challenge — not the verifier — on the /authorize request. The server records the challenge against the issued code. When the client comes back to /token with the code, it also sends the original code_verifier. The server hashes it again and confirms it matches the recorded challenge. An attacker who intercepted the code never saw the verifier and cannot fake the second request.

The verifier is the secret. The challenge is the public commitment. The two together make code interception attacks fail because the second leg of the exchange requires knowledge that never crossed the wire.

Five flows that need PKCE today

Client typeWhy PKCEToken endpoint authentication
Native mobile app (iOS / Android)Cannot store client_secret securely; original PKCE use case from RFC 7636code_verifier only — public client
Single-page app (React, Vue, SvelteKit hydrated)Same problem in the browser. Anything in JS leaks to extensions and devtoolscode_verifier only — public client
CLI tool with local callback serverLoopback redirect on http://127.0.0.1:PORT is hijackable by any local processcode_verifier only — public client
Desktop app (Electron, native)URL handlers are registrable system-widecode_verifier only
Confidential web app (server-rendered)RFC 9700 §2.1.1 recommends PKCE; the OAuth 2.1 draft (§7.5.1.1) requires it unless the server has reasonable assurance that the client implements the OpenID Connect noncecode_verifier and client_secret

The last row is the surprise for teams moving toward OAuth 2.1. Even a backend that holds a client_secret should send PKCE: the secret authenticates the client, but it does not stop an attacker from injecting a stolen authorization code into that client’s session, and PKCE does. RFC 9700 therefore recommends PKCE for confidential clients, and the OAuth 2.1 draft makes it required unless the OpenID Connect nonce exception applies — and still recommends it when the exception does apply.

Walk through the workbench

Open the PKCE Generator and the page renders a verifier the moment the script runs. The default is 43 characters: 32 random bytes from crypto.getRandomValues, base64url-encoded, which is the recipe in RFC 7636 §4.1 and §7.1 (256 bits). Change Length to any value from 43 to 128 if your provider asks for it; the line above the verifier shows the length and the entropy. Press Generate (or Ctrl/⌘+Enter) for a fresh verifier, and the challenge is recomputed immediately. Paste a verifier from your own app and the tool checks it against the RFC grammar and names the first character that breaks it.

Next to Generate, the method selector offers S256 and plain. S256 is selected by default. RFC 7636 §4.2 says a client that can use S256 MUST use it and allows plain only for clients that cannot, and OAuth 2.1 draft §7.5.2 forbids plain. Not every server enforces this: Feishu’s authorization endpoint accepts plain and treats a request without code_challenge_method as plain. Choose S256 and always send the method explicitly.

The code_challenge card shows the value you actually send on /authorize. Copy it from there. It changes with every new verifier because the SHA-256 of a fresh verifier is a fresh hash.

Three panels below give you more. Check a code_challenge takes the value your app actually sent and tells you whether it matches the verifier, or which known mistake produced it: a trailing =, standard Base64, a hex digest, the verifier itself, or a hash of the decoded random bytes. Authorization request URL takes your endpoint, client_id, redirect_uri and scope and builds the complete /authorize URL with response_type=code, the current challenge, code_challenge_method, a random state, and a random nonce when the scope contains openid. Paste that URL into your browser to run the first leg of the flow, log in, and copy the code from the redirect.

Token request (cURL) generates the matching /token request. Paste the code from the redirect into its code field and run the command. The code_verifier body parameter holds the verifier itself, not the challenge. If the verifier and challenge are correctly paired and the code is fresh, the provider returns tokens. If not, you get invalid_grant, the same error you would see in production with broken PKCE; start with the Check a code_challenge panel.

How the cryptography fits together

The four lines of code that matter are short enough to read aloud.

// 1. Generate a random verifier.
const bytes = new Uint8Array(32);
crypto.getRandomValues(bytes);
const verifier = base64url(bytes);   // 43 chars from 32 bytes

// 2. Hash it for the challenge.
const data = new TextEncoder().encode(verifier);
const hash = await crypto.subtle.digest('SHA-256', data);
const challenge = base64url(new Uint8Array(hash));   // 43 chars from 32 bytes

function base64url(bytes) {
  let bin = '';
  for (const b of bytes) bin += String.fromCharCode(b);
  return btoa(bin).replaceAll('+', '-').replaceAll('/', '_').replaceAll('=', '');
}

The verifier produced this way is always 43 characters because 32 bytes encoded in base64 with no padding is exactly 43 characters. RFC 7636 allows 43–128 characters, so 43 is the minimum and a perfectly valid choice. It is also the tool’s default, because RFC 7636 §7.1 asks for at least 256 bits of entropy and 32 random bytes give exactly that.

The hashing uses SubtleCrypto, which is part of the Web Crypto API and available in every modern browser when the page is served over HTTPS. The tool refuses to run if crypto.subtle is unavailable — typically because someone opened the page over http:// — and the inline status message tells you to switch to HTTPS.

Base64url is the same as base64 except + becomes -, / becomes _, and trailing = padding is stripped. This is the format RFC 7636 specifies. Mis-encoding here is a common silent bug: standard btoa() output contains + and / and ends with =, so it never equals the base64url hash the server computes from the verifier, and the token request fails with a generic error. Paste the value into Check a code_challenge and the tool names the encoding mistake.

Five mistakes that silently break login

1. Sending the challenge to /token instead of the verifier. The /authorize request takes the challenge. The /token request takes the verifier. Switching them is the easiest possible mistake because both strings are 43 characters of base64url. The mnemonic: the challenge is what you commit to publicly, the verifier is what you prove you held privately.

2. Mismatched code_challenge_method. Send S256 on /authorize and then forget to compute SHA-256 client-side, sending the raw verifier as the challenge? The server hashes the verifier you send to /token, compares it to the raw verifier you sent as the challenge, and the comparison fails. Leaving the method out is the same bug in reverse: RFC 7636 §4.3 makes a missing code_challenge_method mean plain, so a correct S256 challenge is compared with the raw verifier. Always send the method with the challenge, and default to S256 everywhere.

3. Reusing the verifier across attempts. Each authorization flow gets a fresh verifier and challenge pair. If your client retries /authorize because the user dismissed the consent screen, generate a new verifier; do not keep the old one around because the server might have associated the original verifier with the previous attempt that you abandoned.

4. Storing the verifier in the wrong place. For SPAs, keep it in sessionStorage, as the browser sample below does, and remove it after the token request. The verifier only has to survive the redirect in the same tab. sessionStorage belongs to that tab and is cleared when the tab closes; localStorage stays after the login and is shared by every tab of the origin, so two logins in parallel overwrite each other’s verifier. Neither protects against script injection: any JavaScript running on your origin can read both. For native apps, use the platform’s secure storage (Keychain on iOS, an Android Keystore key on Android). Do not write the verifier to disk in plaintext.

5. Using plain because S256 is “too complex.” The five lines of code above are the complete S256 implementation, and OAuth libraries ship it. The specifications leave no room for plain: RFC 7636 §4.2 allows it only for clients that cannot compute SHA-256, RFC 9700 §2.1.1 notes that S256 is currently the only method that does not expose the verifier in the authorization request, and OAuth 2.1 draft §7.5.2 forbids it. Servers differ: LINE Login accepts only S256, while Keycloak still offers plain as a client setting and Feishu falls back to plain when the method is missing.

Integrate with the major providers

The code_challenge parameters and request shape are identical across providers because RFC 7636 standardised them. The differences live in registration: whether you tick a “Public Client” or “Allow PKCE” box, and which redirect_uri schemes are accepted.

Auth0 documents the Authorization Code Flow with PKCE for applications that cannot store a client secret, such as native and single-page apps, and its SDKs add the parameters for you. The endpoints are https://YOUR_TENANT.auth0.com/authorize and https://YOUR_TENANT.auth0.com/oauth/token.

Okta’s guide for Authorization Code with PKCE starts by creating a Native Application or Single-Page Application integration, the app types for public clients. With the org authorization server the endpoints are https://YOUR_OKTA_DOMAIN/oauth2/v1/authorize and https://YOUR_OKTA_DOMAIN/oauth2/v1/token.

Keycloak sets PKCE per client with the PKCE method option (Server Administration Guide): blank means PKCE is applied only when the client sends the parameters, S256 or plain makes that method required. Choose S256. Endpoints follow the realm pattern https://YOUR_KEYCLOAK/realms/YOUR_REALM/protocol/openid-connect/{authorize,token}.

Amazon Cognito supports PKCE in authorization code grants. Send the challenge to the authorize endpoint https://YOUR_DOMAIN.auth.REGION.amazoncognito.com/oauth2/authorize and the verifier to /oauth2/token.

Spotify calls Authorization Code with PKCE the recommended flow for mobile apps, single-page apps and any other app where the client secret can’t be safely stored. The parameters are the same as everywhere else.

Three quick implementations

Python with requests:

import base64, hashlib, secrets, requests

verifier = secrets.token_urlsafe(64)[:64]
challenge = base64.urlsafe_b64encode(
    hashlib.sha256(verifier.encode()).digest()
).rstrip(b"=").decode()

# 1. Send the user to /authorize with `challenge`
# 2. Receive `code` on your redirect_uri
# 3. Exchange:
response = requests.post(
    "https://acme.example/token",
    data={
        "grant_type": "authorization_code",
        "code": code,
        "redirect_uri": "https://yourapp.example/callback",
        "client_id": "YOUR_CLIENT_ID",
        "code_verifier": verifier,
    },
)

JavaScript / TypeScript (browser):

const bytes = crypto.getRandomValues(new Uint8Array(32));
const verifier = base64url(bytes);
const hash = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(verifier));
const challenge = base64url(new Uint8Array(hash));

sessionStorage.setItem('pkce_verifier', verifier);

const authUrl = `https://acme.example/authorize?` + new URLSearchParams({
  response_type: 'code',
  client_id: 'YOUR_CLIENT_ID',
  redirect_uri: 'https://yourapp.example/callback',
  code_challenge: challenge,
  code_challenge_method: 'S256',
  state: crypto.randomUUID(),
});
location.href = authUrl;

function base64url(bytes: Uint8Array) {
  let bin = '';
  for (const b of bytes) bin += String.fromCharCode(b);
  return btoa(bin).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
}

Bash for the /token leg, once you have the code:

curl -X POST https://acme.example/token \
  -H "Content-Type: application/x-www-form-urlencoded" \
  -d "grant_type=authorization_code" \
  -d "code=$AUTHORIZATION_CODE" \
  -d "redirect_uri=https://yourapp.example/callback" \
  -d "client_id=YOUR_CLIENT_ID" \
  -d "code_verifier=$CODE_VERIFIER"

Why a browser-only generator earns its place

Most PKCE generators on the open web do the cryptography in JavaScript already — that is the easy part. The differences come from what they bundle around the pair.

tonyxu-io.github.io/pkce-generator is the one everyone bookmarks: minimal UI, correct output for the RFC 7636 example, English only. In a 2026-10-01 test it did not validate input: a 42-character verifier, a verifier with +, / and =, and an empty field all produced a challenge. Ping Identity’s PKCE Code Generator generates locally, but its verifier field is read-only, so you cannot check a verifier from your own app. oauth.com/playground is interactive but builds an entire round-trip against its own test issuer, which is great for learning and overkill for debugging your own provider.

ZeroTool’s tool ties the generator to a pasteable /token curl so you can replay the exchange against your actual provider, checks a pasted verifier against the RFC grammar, explains why a challenge from your logs does not match, and renders the same interface in four languages. The verifier is generated fresh on every page load and never written to localStorage, sessionStorage or the URL — the disabled persistence policy ensures it, and the page loads no analytics or ad scripts. If you reload the tab between debugging sessions, the previous verifier is gone, which matches the real-world behavior you want from an OAuth helper.

Further reading