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
| Control | Attack it stops | What it does not stop |
|---|---|---|
| PKCE | Code interception, code injection | A stolen access token; a malicious client |
state | CSRF on the redirect endpoint | Code interception |
nonce (OIDC) | ID token replay/injection | Anything about the access token |
iss in response (RFC 9207) | Mix-up attacks with multiple IdPs | Everything else |
| Exact redirect URI matching | Open-redirect code exfiltration | Interception at the OS level |
| DPoP / mTLS-bound tokens | Use of a stolen access token | Theft 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:
- The attacker has
code=SplxlOBeZQQYbYS6WxSbIA. - They POST to
/tokenwith that code and their own guess at a verifier. - 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.
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.