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.

Advertisement

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

Client apppublic clientBrowserAuthorize endpointToken endpoint1. verifier = random 43-128 charschallenge = B64URL(SHA256(verifier))2. redirect with challenge, S256, state3. GET /authorize?...code_challenge=...4. user signs in; server storescode -> (challenge, client, redirect)5. 302 redirect_uri?code=...&state=...6. code delivered to app7. POST /token code + code_verifier8. SHA256(verifier) == challenge?9. access token (+ refresh token)An attacker who steals the code at step 5 or 6 lacks the verifier, so step 7 fails with invalid_grant.
The challenge travels through the browser; the verifier goes only on the direct token request. The authorization server links the two through the stored code.
  1. The client creates a random code verifier and derives the code challenge from it. It stores the verifier locally, keyed by the state value of this login attempt.
  2. The client sends the browser to the authorization endpoint with code_challenge and code_challenge_method=S256 added to the usual parameters.
  3. 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.
  4. The server redirects back to the client's redirect URI with the code and the state.
  5. The client posts the code and the original verifier to the token endpoint.
  6. The server hashes the verifier, compares it with the stored challenge and issues tokens only on a match.
Advertisement

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

CauseHow to recognise itFix
Verifier regeneratedPage reload or new tab between redirect and callbackPersist the verifier in session storage keyed by state
Concurrent logins overwrite the verifierFails only when two tabs log inKey verifiers by state, never one global slot
Wrong encodingFails every timeRun the Appendix B vector in a test
redirect_uri mismatchTrailing slash, port or encoding differsSend byte-identical values in both requests
Code reused or expiredRetry logic or a slow callback handlerRedeem once, immediately; never retry the exchange
Wrong client idSeveral environments share a serverCheck 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

  1. Add the RFC 7636 Appendix B vector as a unit test in every client and server that touches PKCE.
  2. On your authorization server, require PKCE with S256 for all public clients, reject plain, and enable it for confidential clients too.
  3. Implement both downgrade branches: reject a verifier when no challenge was stored, and reject a missing verifier when one was.
  4. Enforce single-use codes with short expiry, exact redirect URI matching and binding to the client id; revoke tokens on code reuse.
  5. Store client-side verifiers per login attempt keyed by state, and clear them after the exchange.
  6. For browser apps, decide deliberately between in-browser tokens and a backend-for-frontend, and remove any remaining implicit or password grants.
Key takeaway: PKCE turns the authorization code into something only the client that started the login can redeem: the client sends a SHA-256 challenge through the browser and proves possession of the matching verifier on the direct token request. It is mandatory for public clients and recommended for everyone. Its strength depends on the server: S256 only, single-use short-lived codes, exact redirect matching and the downgrade checks in both directions. It protects the code, not the tokens, so pair it with sound token storage and validation.