PKCE in OpenID Connect
An authorization code travels back to the client through the user's browser, and that trip is the weak point of the Authorization Code Flow. A redirect can be observed, logged, or captured by another application registered for the same URI scheme. Whoever ends up holding the code can present it at the token endpoint and receive tokens, because the code alone used to be enough.
Proof Key for Code Exchange closes that gap. The client invents a fresh secret before the flow starts, sends only a hash of it in the authorization request, and must produce the original when redeeming the code. A stolen code is then worthless: the thief has the code but not the secret it was bound to.
TL;DR
PKCE adds three parameters to a flow you already run, and one piece of client state per login attempt: keyed, not singular, because concurrent attempts each keep their own.
- Before redirecting, the client generates a
code_verifier: 43 to 128 unreserved characters, from a cryptographic random source. - The authorization request carries
code_challenge(the base64url-encoded SHA-256 of the verifier) andcode_challenge_method=S256. - The token request carries the original
code_verifier. The server recomputes the hash and compares.
RFC 9700, the OAuth 2.0 Security Best Current Practice, makes PKCE a MUST for public clients and a RECOMMENDED for confidential ones, and requires every authorization server to support it. The mechanism itself is specified in RFC 7636.
The attack PKCE stops
Two related attacks share one root cause: the authorization code is a bearer value in transit.
Code interception. The code arrives as a query parameter on a redirect. On a mobile device, another installed application can claim the same custom URI scheme and receive the redirect instead of the legitimate client. In a browser, the code lands in the address bar, the history, the server log of whatever handled the redirect, and any Referer header sent onward. A public client has no client secret to fall back on, so before PKCE the code was the only thing standing between an attacker and a token.
Code injection. The attacker does not need the victim's browser at all. Having obtained a code that was issued for the victim, the attacker starts their own flow with the legitimate client and swaps their fresh code for the stolen one on the way back. The client redeems it under its own credentials, and the attacker's session at that client is now bound to the victim's identity and resources. RFC 9700, section 4.5 walks through the six steps.
The mirror image, planting a code of the attacker's own into the victim's session, is a different attack: that is the CSRF family of section 4.7, and in its PKCE-specific form the downgrade attack described further down this page.
PKCE answers both, because it stops treating the code as sufficient on its own. The code becomes usable only together with a secret that never left the client that started the flow.
How it works
The code verifier
The client creates one code_verifier per authorization request, not per session and not per installation. RFC 7636, section 4.1 fixes the alphabet and the length:
code-verifier = 43*128unreserved
unreserved = ALPHA / DIGIT / "-" / "." / "_" / "~"The recommended construction is 32 octets from a cryptographic random number generator, base64url-encoded, which lands exactly on the 43-character minimum. That is a floor, not a target: the value must be unguessable, and a counter, a timestamp, or a session identifier padded to 43 characters satisfies the ABNF while defeating the purpose.
The code challenge
The challenge is derived from the verifier by one of two transformations:
code_challenge_method | code_challenge |
|---|---|
S256 | BASE64URL-ENCODE(SHA256(ASCII(code_verifier))) |
plain | the verifier itself |
Using the example from RFC 7636, appendix B, a verifier of
dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXkproduces the challenge
E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cMNote that base64url here carries no = padding, and the comparison at the token endpoint is case-sensitive.
The two requests
The authorization request adds the challenge and names the method:
GET /connect/authorize
?response_type=code
&client_id=s6BhdRkqt3
&redirect_uri=https%3A%2F%2Fclient.example.org%2Fcb
&scope=openid%20profile
&state=af0ifjsldkj
&code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM
&code_challenge_method=S256The server stores both values against the code it issues. The token request then presents the verifier:
POST /connect/token
Content-Type: application/x-www-form-urlencoded
grant_type=authorization_code
&code=SplxlOBeZQQYbYS6WxSbIA
&redirect_uri=https%3A%2F%2Fclient.example.org%2Fcb
&client_id=s6BhdRkqt3
&code_verifier=dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXkThe server recomputes the challenge from the presented verifier using the method it recorded, and issues tokens only on an exact match.
Why S256 and not plain
code_challenge_method is optional in the wire format and defaults to plain when it is absent (RFC 7636, section 4.3). That default is the detail on this page that costs the most when it is missed: a client that sends a challenge and forgets the method has silently asked for the weakest variant, and the flow still succeeds.
Under plain the challenge is the verifier, so anyone who reads the authorization request learns the secret and can redeem an intercepted code. The transformation exists for compatibility with deployments that predate PKCE and whose request path is already protected, and for constrained environments that cannot compute SHA-256; RFC 7636, section 7.2 states that it SHOULD NOT be used in new implementations. Clients capable of S256 MUST use it, and MUST NOT fall back to plain after trying S256: a server that rejects S256 is either broken or being impersonated, and retrying in the clear hands the attacker exactly what the retry was meant to recover from.
Which clients need it
The original framing was that PKCE protects public clients: native applications and single-page applications, which cannot keep a client secret. That framing is out of date. RFC 9700, section 2.1.1 sets the current rule:
- Public clients MUST use PKCE.
- For confidential clients PKCE is RECOMMENDED, because it protects against code injection and, as a side effect, prevents CSRF even against an attacker strong enough to defeat
state. - A confidential OpenID Connect client MAY use the nonce parameter and the matching ID token claim instead, with the additional precautions the same document sets out.
The specification adds the point explicitly: although PKCE was designed to protect native apps, the advice applies to every kind of OAuth client, web applications included.
One requirement is easy to miss, and it is usually quoted with its second half removed. The challenge must be transaction-specific and securely bound to the client and the user agent in which the transaction was started. A fresh random value satisfies only the first half.
The verifier also has to be stored where nothing but the browser that began this flow can present it, which in practice means the client's own per-browser session keyed by state, and never a slot that a second login attempt overwrites: two tabs mean two flows in the air, and one slot means the first callback dies with invalid_grant having reused nothing. Authorization servers, for their part, are encouraged to detect clients that send constant challenges.
PKCE compared with the implicit flow
Both were answers to the same question, which is how a browser-based application obtains tokens without a backend. They answer it differently, and only one of them survived.
| Implicit Flow | Authorization Code Flow with PKCE | |
|---|---|---|
| What the redirect carries | the access token itself | an authorization code |
| Where the value lands | URL fragment and browser history; the fragment never reaches a server, and Referer must omit it | query string, so the redirect handler's log and any Referer sent onward as well - but the code is useless without the verifier |
| Refresh tokens | not issued | issued normally |
| Current status | removed from OAuth 2.1 and advised against by RFC 9700 | the recommended flow for public clients |
The implicit flow put the payload itself in the address bar and had no way to bind it to the client that asked for it. PKCE keeps the same redirect shape and makes what travels through it worthless on its own. That is why the migration path from implicit is PKCE and not the other way round, and why "should we still use implicit to get access tokens" no longer has a version that ends in yes. Pure authentication with response_type=id_token and form_post is the narrower case the BCP does not reach, since it puts no access token in the response; the code flow is still the better answer there.
For the full sequence of flows and how each one closed the previous one's hole, see From Implicit to Authorization Code with PKCE & BFF. Where the client does have a backend, the Backend-For-Frontend pattern keeps tokens out of the browser entirely, and PKCE still applies to the flow that backend runs.
What the authorization server has to enforce
PKCE is not a client-side feature. Half of it lives on the server, and RFC 9700 states all three of the server's obligations as MUSTs.
- Support it at all. Authorization servers MUST support PKCE, and MUST provide a way for clients to detect that support. The recommended way is the
code_challenge_methods_supportedelement of the authorization server metadata. - Enforce the verifier once a challenge was sent. If the authorization request carried a valid
code_challenge, the token endpoint MUST require the matchingcode_verifier. - Refuse the downgrade. RFC 9700, section 4.8 describes the PKCE downgrade attack: the attacker strips
code_challengefrom an authorization request on their own device, obtains a code bound to no challenge, and injects it into the victim's session. The client duly sends its owncode_verifier, and a server that simply ignores an unexpected verifier issues tokens for the attacker's account. The countermeasure is exact: a token request containingcode_verifieris accepted only if the authorization request containedcode_challenge.
PKCE in Abblix OIDC Server
Abblix OIDC Server implements the server half of the mechanism, and each of the three rules above is checked in code. The protocol layer is certified by the OpenID Foundation across all login and logout profiles, which is the external check on claims of this kind; the paragraphs below describe behaviour that certification does not cover, so they name what a reader can go and reproduce.
At the authorization endpoint, a statically configured client requires PKCE unless it opts out: a missing code_challenge fails any response_type that yields a code, while a pure implicit request is exempt because there is no code for a challenge to protect. That exemption is not a way in: the implicit flow is opt-in, so without it the token and id_token response types are refused server-wide whatever a client is registered for, and a client is limited in any case to the response types it registered. That default is stricter than RFC 9700, which makes PKCE a MUST only for public clients, and it is on by default.
A client created through Dynamic Client Registration inherits the same default: the registration request may set pkce_required explicitly, and a request that says nothing leaves the client on the server's own value. Earlier versions shipped the opposite default on that path, so a client registered dynamically did not require PKCE unless the request asked for it.
plain is refused unless the client is explicitly configured to allow it, and under a profile that pins the method, such as FAPI 2.0, anything other than S256 is refused before the per-client check, so the profile cannot be loosened one client at a time.
The trap from the top of this page has a definite answer here: a request that carries a challenge but no code_challenge_method is not quietly treated as plain. It passes the authorization endpoint and then fails the exchange, because the token endpoint requires the method that the code was bound with. Read discovery with that in mind: code_challenge_methods_supported advertises what the server can do, including plain, while whether a given client may use it is per-client policy that metadata does not express.
With reuse detection enabled, a code_challenge this client has already used is refused. RFC 9700 puts the MUST on the client, which is the party that makes each challenge transaction-specific. The server it only asks to make a reasonable effort at detecting constant values, and this is that effort made explicit.
The token endpoint recomputes the challenge from the presented verifier and compares ordinally, since base64url and the plain verifier are both case-sensitive. A code issued with a challenge and redeemed without a verifier is rejected, and so is the mirror image: a verifier presented for a code that was issued without a challenge is treated as a downgrade attempt rather than silently ignored.
Beyond the registered methods, the library also computes S512. It is a non-standard extension, not present in the IANA registry, and interoperable only where both ends agree on it; S256 remains the method to use.
Mistakes that survive testing
Each of these produces a flow that works, which is why they reach production. Against a strict server the first and third fail loudly at the token exchange; the rest are client-side and no server can see them.
- Omitting
code_challenge_method. The parameter defaults toplain, so against a permissive server the login succeeds and the protection is gone, while a strict one fails with an error namingplain- a word the client never sent, which puzzles everyone for an afternoon. Send it explicitly, always. - Deriving the verifier from something. A hash of the session id, a UUID from a non-cryptographic source, a value the client can be made to repeat: all pass the length check and none of them are unguessable.
- Reusing one verifier for a whole session. The specification requires a fresh value per authorization request, and a reused challenge undoes the binding between this login attempt and this code.
- Treating PKCE as a substitute for
state. It can replacestatefor CSRF purposes, but only once the client has confirmed the server supports PKCE, and it replaces nothing else. A client that can talk to more than one authorization server is required by RFC 9700, section 2.1 to defend against mix-up, using theissresponse parameter of RFC 9207 or theissclaim of the ID token. PKCE supplies none of that, so droppingstateon the strength of it can leave two protections missing instead of one. - Skipping PKCE because the client is confidential. The client secret proves which client is redeeming the code. It does not prove that this is the code that client asked for, which is exactly what code injection exploits.