PKCE and the authorization code flow

What it is

PKCE (Proof Key for Code Exchange, RFC 7636, pronounced "pixy") binds an authorization code to the client instance that requested it. The client invents a high-entropy random string called the code verifier, sends its SHA-256 hash (the code challenge) with the authorization request, and presents the original verifier when redeeming the code. The authorization server hashes the verifier and compares. An attacker who steals the code cannot redeem it, because the code is now useless without a secret that never left the client.

The name is misleading. PKCE is not a key exchange and produces no shared key. It is a proof of possession over a one-time nonce, and calling it that in an interview is a small but real signal.

It is commonly confused with state, which is a different control solving a different problem: state binds the callback to the user's browser session and prevents CSRF on the redirect endpoint. PKCE binds the code to the client. You want both, and OpenID Connect adds nonce to bind the ID token to the authorization request, which is a third distinct thing.

The problem it solves

The authorization code flow returns the code to the client through a browser redirect. On a mobile or desktop platform, that redirect goes to a custom URI scheme or a loopback port, and neither is exclusive: a malicious app can register the same custom scheme, or race for the same port. That is the authorization code interception attack described in RFC 7636's motivation. The attacker observes the redirect, grabs the code, and redeems it.

For a confidential web client the code exchange also requires a client secret, so interception alone is not enough. For a public client (single-page app, mobile, CLI, anything shipped to a user's device) there is no secret that can be kept, so before PKCE the code was the whole credential. That is why the implicit grant existed, and why the implicit grant was worse: it put the access token itself in a URL fragment, where it landed in browser history, referrer headers and logs.

PKCE lets public clients use the authorization code flow safely, which is why implicit is dead.

The second reason, and the one that surprises people, is that PKCE now applies to confidential clients too. The OAuth 2.0 Security Best Current Practice (RFC 9700) and OAuth 2.1 require PKCE for all clients, because a client secret authenticates the client application but does not bind the code to the specific authorization request. Without that binding, an attacker who can inject a code into a legitimate client's flow (an authorization code injection attack) can have the legitimate client, holding a valid secret, redeem the attacker's code and then operate on the attacker's account, or the reverse depending on the variant. PKCE closes it because the injected code does not match the verifier the victim's client generated.

Mechanics

Generating the pair

import os, hashlib, base64

# Verifier: 43-128 characters from the unreserved set. 32 random bytes
# base64url-encoded gives 43 characters and 256 bits of entropy, which is
# the recommended construction in RFC 7636 section 4.1.
verifier = base64.urlsafe_b64encode(os.urandom(32)).rstrip(b"=").decode()

# Challenge: S256 method. The "plain" method (challenge == verifier) exists
# only for clients that genuinely cannot compute SHA-256, and a server that
# supports S256 MUST reject plain from a client capable of S256.
challenge = base64.urlsafe_b64encode(
    hashlib.sha256(verifier.encode("ascii")).digest()
).rstrip(b"=").decode()

Two details that get failed in code review: the padding = must be stripped (base64url without padding), and the hash is computed over the ASCII bytes of the verifier string, not over the raw bytes that produced it. Hashing the wrong thing produces a challenge that never validates and an error message that does not tell you why.

The full exchange, parameter by parameter

# 1. Authorization request. Browser navigates here.
GET /authorize
    ?response_type=code                 # authorization code flow
    &client_id=s6BhdRkqt3                # public identifier, not a secret
    &redirect_uri=https://app.example.com/cb   # MUST be pre-registered, exact match
    &scope=openid%20profile%20orders.read      # what you are asking for
    &state=af0ifjsldkj                   # CSRF: opaque, bound to the browser session
    &nonce=n-0S6_WzA2Mj                  # OIDC: binds the ID token to this request
    &code_challenge=E9Melhoa2Ow...       # base64url(SHA256(verifier))
    &code_challenge_method=S256          # never "plain" if you can hash
  Host: idp.example.com

The authorization server authenticates the user, obtains consent, stores (code, client_id, redirect_uri, code_challenge, code_challenge_method) against the issued code, and redirects:

# 2. Redirect back. The code is in the QUERY string, not the fragment.
HTTP/1.1 302 Found
Location: https://app.example.com/cb?code=SplxlOBeZQQYbYS6WxSbIA&state=af0ifjsldkj
          &iss=https://idp.example.com     # RFC 9207: defends against mix-up attacks

The client must compare the returned state against the value it stored for this browser session and abort if it does not match. This is the CSRF check, and it is not optional just because PKCE is present.

# 3. Token request. Back channel, POST, no browser involved.
POST /token HTTP/1.1
Host: idp.example.com
Content-Type: application/x-www-form-urlencoded

grant_type=authorization_code
&code=SplxlOBeZQQYbYS6WxSbIA
&redirect_uri=https://app.example.com/cb    # MUST match the authorize request
&client_id=s6BhdRkqt3
&code_verifier=dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk   # the proof

The authorization server recomputes BASE64URL(SHA256(code_verifier)) and compares against the stored code_challenge. Mismatch, missing verifier, or a code already redeemed all produce invalid_grant. Authorization codes are single-use and short-lived (the specification recommends a maximum of 10 minutes, and one minute is a common production value); redeeming one twice must invalidate any tokens already issued from it.

# 4. Response.
{
  "access_token": "2YotnFZFEjr1zCsicMWpAA",
  "token_type": "Bearer",
  "expires_in": 900,
  "refresh_token": "tGzv3JOkF0XG5Qx2TlKWIA",
  "id_token": "eyJhbGciOiJSUzI1NiIs..."      # OIDC only
}

What each control actually defends

ControlAttack it stopsWhat it does not stop
PKCECode interception, code injectionA stolen access token; a malicious client
stateCSRF on the redirect endpointCode interception
nonce (OIDC)ID token replay/injectionAnything about the access token
iss in response (RFC 9207)Mix-up attacks with multiple IdPsEverything else
Exact redirect URI matchingOpen-redirect code exfiltrationInterception at the OS level
DPoP / mTLS-bound tokensUse of a stolen access tokenTheft itself

That last row is the "above and beyond" answer. PKCE protects the code exchange. Once you hold a bearer access token, anyone who steals it can use it. Sender constraining, via DPoP (RFC 9449) or mTLS-bound tokens (RFC 8705), binds the token to a key the client holds, so a stolen token is unusable. Offering that distinction unprompted is one of the cleanest depth signals available in an OAuth conversation.

A worked example

A React single-page app and a native iOS app share an API.

The SPA is a public client: everything it ships is visible in the browser, so it has no secret. It uses authorization code plus PKCE, with the code exchange made from the browser. Access tokens live in memory (never localStorage, which is readable by any XSS), refresh tokens are either absent (relying on a silent re-authorization via a hidden iframe or the IdP session cookie) or issued with rotation. Token lifetime is 15 minutes.

The iOS app is also a public client. It uses ASWebAuthenticationSession rather than an embedded webview, which is the RFC 8252 requirement: an embedded webview lets the app read the user's IdP credentials, defeating the point of federated login. The redirect goes to a claimed HTTPS universal link rather than a custom scheme, which prevents another app from registering the same target. PKCE protects the code in transit through the OS.

Both use the same authorization server and the same S256 method. Now suppose an attacker installs a malicious app on the user's phone that registers a competing URI scheme and successfully receives the redirect:

  1. The attacker has code=SplxlOBeZQQYbYS6WxSbIA.
  2. They POST to /token with that code and their own guess at a verifier.
  3. The server computes SHA-256 of their guess and compares to the stored challenge, which was derived from a 256-bit random value generated inside the legitimate app and never transmitted.
  4. invalid_grant. The code is burned. The legitimate app's own redemption also fails, the user retries, and the attacker gains nothing.

The residual risk is worth naming: PKCE did not stop the interception, it made it useless. If the attacker can intercept the redirect, they can also mount a phishing flow of their own; PKCE is one control in a stack that also includes claimed HTTPS redirects, exact redirect matching and platform-level app attestation.

Production evidence

RFC 8252, "OAuth 2.0 for Native Apps" (an IETF BCP) requires PKCE for native applications and requires the use of an external user agent rather than an embedded webview. Google's OAuth documentation for installed applications implements exactly this, and Google's identity platform requires PKCE for the mobile and desktop flows.

RFC 9700, "Best Current Practice for OAuth 2.0 Security" generalises the requirement to all clients, including confidential ones, and recommends against the implicit grant and the resource owner password credentials grant entirely. The OAuth 2.1 draft folds these into the core specification.

Every major identity provider has followed: Auth0, Okta, Microsoft Entra ID and AWS Cognito all document authorization code plus PKCE as the recommended flow for SPAs and native apps, and several now require it or enable it by default for newly created public clients. That convergence is what makes this a safe, non-controversial position to hold in an interview.

The debate

The alternative for a browser application is the backend-for-frontend pattern: the SPA never sees a token at all. A server-side component performs the full confidential-client flow, holds the tokens, and issues the browser an HttpOnly, Secure, SameSite session cookie. The browser talks only to the BFF, which attaches the access token to upstream calls.

This is genuinely more secure, and it is what I would choose for a first-party web application. Tokens in a browser are exposed to XSS no matter how carefully you store them: in-memory storage survives a page reload badly, and anything persistent is readable by injected script. A cookie-based session with HttpOnly is not readable by script at all. The BFF also gives you a natural place to do token refresh, revocation and audience narrowing.

The cost is an extra service to run, session state to manage, and the loss of the pure-static-hosting deployment model that makes SPAs cheap. For a third-party integration, a CLI, a mobile app or anything where you cannot interpose a server you control, PKCE in the client is the right and only answer.

My position: authorization code plus PKCE is the baseline for every client type and there is no longer a defensible reason to omit it. For a first-party browser app, add a backend-for-frontend so tokens never reach JavaScript. For high-value APIs, add sender-constrained tokens with DPoP so a stolen token is not a usable token. Each layer addresses a different attacker capability, which is the framing that turns a checklist answer into a reasoned one.

PKCE is the wrong thing to focus on when the actual weakness is elsewhere: overly broad scopes, no token revocation path, a 24-hour access token lifetime, or authorization decisions delegated to scopes rather than made in your own resource server. A perfect PKCE implementation with a one-day bearer token and scope=admin has not bought you much.

Follow-up Q&A

"Why PKCE for a confidential client that already has a secret?" Because the secret authenticates the client application, not the specific authorization request. In an authorization code injection attack, the attacker gets their own code injected into a victim's legitimate client, which then redeems it using its valid secret. The secret does not detect the substitution; PKCE does, because the injected code's challenge does not match the verifier the victim's client generated. RFC 9700 makes this a requirement for all clients for exactly this reason.

"Does PKCE replace state?" No. They defend different things. PKCE binds the code to the client; state binds the callback to the user's browser session and stops an attacker from delivering their own code to your callback endpoint to have you associate their account with your user's session. Some analyses argue PKCE incidentally mitigates certain CSRF variants; the specifications still require state (or an equivalent binding such as an ID token nonce), and implementing both is free.

"Design token revocation with stateless JWTs." Accept that you cannot revoke a JWT that has already been issued, and shorten the window instead: access tokens of 5 to 15 minutes, with revocation applied at the refresh boundary. Add a refresh token rotation scheme: each refresh issues a new refresh token and invalidates the old one, and if an already-used refresh token is ever presented, revoke the entire token family, because a replay means the token was stolen. For immediate revocation of an access token, keep a small deny list keyed by the token's jti with a TTL equal to the token lifetime, which bounds its size, or maintain a per-user tokens_valid_after timestamp checked by the resource server. Both reintroduce state, which is the honest cost of revoking a stateless credential.

"What is alg: none and algorithm confusion?" Two JWT validation failures. alg: none is a JWT header claiming the token is unsigned; a library that honours it accepts a forged token. Algorithm confusion is submitting a token signed with HMAC-SHA256 using the server's public RSA key as the HMAC secret, against a verifier that picks its algorithm from the token's own header. Both are fixed the same way: the verifier decides the acceptable algorithms from configuration, not from the token, and maintains an explicit allowlist.

"How do you validate a JWT correctly?" Fetch the signing keys from the issuer's JWKS endpoint and cache them, selecting the key by kid. Verify the signature against an allowlisted algorithm. Then check iss equals the expected issuer exactly, aud contains your resource identifier, exp and nbf against the current time with a small clock skew allowance (60 seconds), and for OIDC ID tokens, nonce against the value you sent. Then, and only then, use the claims. Handle key rotation by refetching JWKS on an unknown kid, with rate limiting so a token flood with random kid values cannot be used as a denial of service.

Common misconceptions

The most common is that PKCE is only for mobile and SPAs. It is required for all client types under current best practice, and the reason is code injection rather than code interception.

The second is that PKCE protects the access token. It protects the code exchange only. A leaked access token is fully usable until it expires; that is what DPoP and mTLS-bound tokens are for.

The third is that a scope is a permission. A scope is what the client asked for and the user consented to; it is not an authorization decision about a specific resource. orders.read does not tell your API which orders. Authorization belongs in your resource server, evaluated against the resource and the subject, and treating scopes as an access control list is how IDOR and broken object-level authorization bugs (the number one item in the OWASP API Security Top 10) get shipped.

Interview delivery note

Say this: "Authorization code with PKCE for every client type. The client generates a random verifier, sends its SHA-256 hash as the challenge, and presents the verifier at the token endpoint, so an intercepted or injected code is useless. For public clients it replaces the missing client secret; for confidential clients it binds the code to the specific request, which the secret does not do, and that is why RFC 9700 and OAuth 2.1 require it universally. State is still required, because it defends CSRF rather than interception."

The depth signal is the confidential-client justification, and then extending past PKCE to sender-constrained tokens: "PKCE protects the code exchange. If I also care about a stolen access token, that is DPoP or mTLS binding, which is a different control." Most candidates stop at "PKCE for mobile apps".

Further reading

  • RFC 7636, "Proof Key for Code Exchange by OAuth Public Clients", especially section 1 (the interception attack) and 4.1 (verifier construction).
  • RFC 9700, "Best Current Practice for OAuth 2.0 Security", and the OAuth 2.1 draft, for why PKCE became universal.
  • RFC 8252, "OAuth 2.0 for Native Apps", for the external-user-agent requirement.
  • RFC 9449 (DPoP) and RFC 8705 (mTLS-bound tokens), for sender-constrained tokens.