Proof Key for Code Exchange (PKCE, pronounced 'pixie') is a small addition to the OAuth 2.0 authorization code flow that closes a large hole. It was standardised in RFC 7636 for mobile apps, which cannot keep a client secret and whose redirect URIs could be intercepted by other apps. Today the OAuth security best current practice, RFC 9700, says public clients MUST use PKCE and recommends it for confidential clients too, and the OAuth 2.1 draft folds it into the authorization code flow itself.
This article is about the mechanics: exactly what the client generates, what goes in each request, what the authorization server must store and check, and how the pieces fail. It uses the RFC's own test values so you can verify your implementation byte for byte. For the architectural overview of where PKCE sits among clients, gateways and resource servers, see the OAuth2 PKCE architecture.
The attack PKCE exists to stop
In the plain authorization code flow, the authorization server redirects the browser back to the client with a short-lived code, and the client exchanges that code for tokens at the token endpoint. A confidential client authenticates that exchange with its secret, so a stolen code is useless without the secret. A public client, such as a mobile app, a desktop app or a single-page app, has no secret that stays secret: anything shipped to a device can be extracted.
That leaves the code itself as the only credential, and codes leak. On mobile, several apps could register the same custom URI scheme and the operating system might deliver the redirect to the wrong one. Codes appear in browser history, proxy and server logs, and the Referer header of a page that loads third-party content. An attacker holding the code could redeem it as easily as the real app. A related attack, code injection, has the attacker push a code obtained for their own account into the victim's session.
PKCE binds the code to a secret the legitimate client generated moments before and never sent through the browser. Only the hash of that secret travels in the front channel; the secret itself goes only in the direct back-channel request to the token endpoint.
The flow, step by step
- The client creates a random code verifier and derives the code challenge from it. It stores the verifier locally, keyed by the
statevalue of this login attempt. - The client sends the browser to the authorization endpoint with
code_challengeandcode_challenge_method=S256added to the usual parameters. - The user authenticates and consents. The authorization server issues a code and stores, alongside it, the challenge, the method, the client id and the redirect URI.
- The server redirects back to the client's redirect URI with the code and the state.
- The client posts the code and the original verifier to the token endpoint.
- The server hashes the verifier, compares it with the stored challenge and issues tokens only on a match.
Generating the verifier and challenge
RFC 7636 defines the verifier as a high-entropy random string of 43 to 128 characters drawn from the unreserved URL characters: letters, digits, hyphen, period, underscore and tilde. The recommended way to make one is to take 32 random bytes from a cryptographically secure generator and base64url-encode them without padding, which yields exactly 43 characters.
The S256 challenge is BASE64URL(SHA256(ASCII(code_verifier))), again without padding. The RFC also defines a plain method where the challenge equals the verifier, intended only for clients that cannot compute SHA-256. It offers no protection if the authorization request is observed, and RFC 9700 says clients should use methods that do not expose the verifier, which today means S256. Servers should refuse plain.
# Python: generate a verifier and its S256 challenge (RFC 7636 section 4.1 and 4.2)
import base64, hashlib, secrets
def b64url(data: bytes) -> str:
return base64.urlsafe_b64encode(data).rstrip(b"=").decode("ascii")
def new_pkce_pair() -> tuple[str, str]:
verifier = b64url(secrets.token_bytes(32)) # 43 chars, all from the unreserved set
challenge = b64url(hashlib.sha256(verifier.encode("ascii")).digest())
return verifier, challenge
# RFC 7636 Appendix B test vector
assert b64url(hashlib.sha256(b"dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk").digest()) \
== "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM"// Browser: Web Crypto, no libraries
function b64url(bytes) {
return btoa(String.fromCharCode(...new Uint8Array(bytes)))
.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
}
export async function newPkcePair() {
const verifier = b64url(crypto.getRandomValues(new Uint8Array(32)));
const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier));
return { verifier, challenge: b64url(digest) };
}The assertion is the test vector from RFC 7636 Appendix B. Put it in your unit tests; it catches the three most common bugs, which are hex-encoding the digest instead of base64url, using standard base64 with + and / characters, and leaving the = padding on.
The requests on the wire
Here is the complete exchange with the RFC's verifier and challenge. Line breaks are added for readability.
GET /authorize?response_type=code
&client_id=mobile-app
&redirect_uri=https%3A%2F%2Fapp.example.com%2Fcallback
&scope=openid%20orders.read
&state=af0ifjsldkj
&code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM
&code_challenge_method=S256
Host: auth.example.com
HTTP/1.1 302 Found
Location: https://app.example.com/callback?code=SplxlOBeZQQYbYS6WxSbIA&state=af0ifjsldkj&iss=https%3A%2F%2Fauth.example.com
POST /token
Host: auth.example.com
Content-Type: application/x-www-form-urlencoded
grant_type=authorization_code&code=SplxlOBeZQQYbYS6WxSbIA
&redirect_uri=https%3A%2F%2Fapp.example.com%2Fcallback&client_id=mobile-app
&code_verifier=dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk
HTTP/1.1 200 OK
Content-Type: application/json
Cache-Control: no-store
{"access_token":"eyJhbGciOi...","token_type":"Bearer","expires_in":600,"refresh_token":"8xLOxBtZp8"}Three details are easy to miss. The token request repeats the exact redirect_uri used in the authorization request, and the server must compare it exactly. The response includes Cache-Control: no-store so no intermediary keeps tokens. And the iss parameter on the redirect comes from RFC 9207 (authorization server issuer identification); a client that talks to more than one authorization server should check it to defend against mix-up attacks, where one server's code is sent to another.
If an authorization server that requires PKCE receives an authorization request without a challenge, RFC 7636 says it returns an invalid_request error. If the verifier does not match at the token endpoint, it returns invalid_grant.
What the authorization server must enforce
PKCE is only as strong as the server-side check, and that check has more branches than 'hash and compare'.
import hashlib, hmac, re, time
VERIFIER_RE = re.compile(r"^[A-Za-z0-9\-._~]{43,128}$")
def redeem_code(store, form) -> dict:
rec = store.take(form["code"]) # atomic get-and-delete: single use
if rec is None or rec.expires_at < time.time():
raise OAuthError("invalid_grant") # unknown, used or expired code
if rec.client_id != form.get("client_id") or rec.redirect_uri != form.get("redirect_uri"):
raise OAuthError("invalid_grant")
verifier = form.get("code_verifier")
if rec.code_challenge is None:
if verifier is not None: # RFC 9700 downgrade defence
raise OAuthError("invalid_grant")
if rec.client_is_public: # policy: PKCE mandatory for public clients
raise OAuthError("invalid_grant")
else:
if verifier is None or not VERIFIER_RE.match(verifier):
raise OAuthError("invalid_grant")
if rec.code_challenge_method != "S256": # this server issues codes for S256 only
raise OAuthError("invalid_grant")
computed = b64url(hashlib.sha256(verifier.encode("ascii")).digest())
if not hmac.compare_digest(computed, rec.code_challenge):
raise OAuthError("invalid_grant")
return issue_tokens(rec.user_id, rec.client_id, rec.scope)The branch most often missing is the downgrade defence. Suppose a server supports PKCE but does not require it. An attacker obtains a code that was issued without a challenge, for example by starting a login without PKCE parameters, and redeems it while sending some arbitrary verifier, hoping the server only checks verifiers when a challenge exists. RFC 9700 requires the server to accept a token request containing a code_verifier only if a code_challenge was present in the authorization request. The opposite case matters too: if a challenge was stored, a token request without a verifier must fail.
Codes must also be single use, short lived (RFC 6749 recommends a maximum of ten minutes, and a minute or less is common), and bound to the client id and redirect URI. RFC 6749 also says that if a code is redeemed twice, the server must deny the request and should revoke tokens already issued from it, since reuse means someone else has the code. Use a constant-time comparison for the challenge, and log failed verifications with the client id so attacks are visible.
What PKCE does not protect against
- Redirect URI abuse. If the server accepts loose redirect matching (wildcards, prefix matches, open redirectors on the client's domain), an attacker can receive the code and run the whole flow, including their own PKCE pair. RFC 9700 requires exact string matching of redirect URIs, with a narrow exception for loopback ports in native apps.
- Script injection in the client. In a single-page app, cross-site scripting can read tokens after the exchange or start its own flow. PKCE protects the code in transit, not the tokens at rest.
- Phishing and consent abuse. A malicious app with its own legitimate client id runs PKCE perfectly.
- Token leakage after issue. Bearer tokens remain bearer tokens. Short lifetimes, audience restriction and sender-constrained tokens such as DPoP address that layer; see JWT validation for what resource servers must check.
On cross-site request forgery: RFC 9700 notes that PKCE also protects the redirect endpoint against CSRF, because an injected code fails without the matching verifier, but clients must first confirm the authorization server supports PKCE before relying on it for that. Keeping a random state value costs nothing and also gives you a key for storing the per-login verifier, so most clients send both. OpenID Connect clients add a nonce bound to the ID token.
Native apps, SPAs and backends
Native apps follow RFC 8252: use the system browser or an in-app browser tab, never an embedded web view where the app could read the user's password. Redirect with claimed HTTPS links (Android App Links, iOS Universal Links) where possible, a loopback address such as http://127.0.0.1:{port}/callback for desktop apps, or a private-use URI scheme based on a domain you own. PKCE is what makes the latter two safe, since another process could also listen on a loopback port or claim a scheme.
Single-page apps can run PKCE entirely in the browser, which is far better than the deprecated implicit grant that returned tokens in the URL fragment (RFC 9700 says clients should not use it). The remaining problem is where tokens live. Many teams now prefer a backend-for-frontend: a small server-side component acts as a confidential client, runs the code flow with PKCE, keeps tokens on the server and gives the browser an HttpOnly, SameSite session cookie. That removes tokens from script reach at the cost of session management and CSRF defences; see session management and CSRF defence.
Confidential server-side clients should use PKCE as well. It costs one hash and defends against code injection, which a client secret alone does not stop.
Worked example: an intercepted code
A desktop app starts a login. It generates the verifier dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk, stores it in memory under state af0ifjsldkj, opens the system browser with the challenge E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM, and listens on a loopback port. A malicious process on the same machine also binds a listener and wins the race for the redirect, receiving code SplxlOBeZQQYbYS6WxSbIA.
The attacker posts the code to the token endpoint. Without a verifier the server finds a stored challenge with no verifier and returns invalid_grant. With a guessed verifier the hash does not match. And if the attacker instead steals a code issued without any challenge and redeems it with a made-up verifier, the RFC 9700 downgrade rule rejects it. The server above consumes a code on any redemption attempt, so the real app's later redemption also fails, and the user simply retries. That is the correct outcome: a denial of one login, not a stolen account.
Debugging invalid_grant
| Cause | How to recognise it | Fix |
|---|---|---|
| Verifier regenerated | Page reload or new tab between redirect and callback | Persist the verifier in session storage keyed by state |
| Concurrent logins overwrite the verifier | Fails only when two tabs log in | Key verifiers by state, never one global slot |
| Wrong encoding | Fails every time | Run the Appendix B vector in a test |
| redirect_uri mismatch | Trailing slash, port or encoding differs | Send byte-identical values in both requests |
| Code reused or expired | Retry logic or a slow callback handler | Redeem once, immediately; never retry the exchange |
| Wrong client id | Several environments share a server | Check the client id on both requests |
Servers should log a precise internal reason (challenge mismatch, code reused, redirect mismatch) while returning only invalid_grant to the client, so support can diagnose failures without giving attackers an oracle.
What to do next
- Add the RFC 7636 Appendix B vector as a unit test in every client and server that touches PKCE.
- On your authorization server, require PKCE with S256 for all public clients, reject
plain, and enable it for confidential clients too. - Implement both downgrade branches: reject a verifier when no challenge was stored, and reject a missing verifier when one was.
- Enforce single-use codes with short expiry, exact redirect URI matching and binding to the client id; revoke tokens on code reuse.
- Store client-side verifiers per login attempt keyed by state, and clear them after the exchange.
- For browser apps, decide deliberately between in-browser tokens and a backend-for-frontend, and remove any remaining implicit or password grants.