What Actually Breaks When You Skip PKCE

·3 min read

pkceoauth2securityspa

PKCE — Proof Key for Code Exchange, RFC 7636 — is often introduced as "the thing mobile apps need because they can't keep a secret." That framing is outdated and it leads people to skip it in server-side apps where it still matters.

OAuth 2.1 makes PKCE mandatory for all clients using the authorization code flow. Here's what it's actually protecting against.

The attack

The authorization code flow has a gap. After the user authenticates, the authorization server redirects back to your app with a code in the URL:

https://yourapp.com/callback?code=SplxlOBeZQQYbYS6WxSbIA&state=xyz

Your app then exchanges that code for tokens. Between those two steps, the code sits in a URL — and URLs leak. They land in browser history, server access logs, Referer headers, and on mobile, in whatever app claimed the redirect URI.

That last one is the original motivation. On mobile, custom URI schemes aren't exclusive. A malicious app can register yourapp://callback too, and the OS may hand it the redirect. It now holds a valid authorization code.

Without PKCE, that code is all an attacker needs. They exchange it for tokens and are now authenticated as your user.

What PKCE changes

PKCE binds the code to the specific client instance that started the flow.

Before redirecting the user, your app generates a random code_verifier and derives a code_challenge from it:

code_challenge = base64url(SHA256(code_verifier))

The challenge goes in the authorization request. The verifier stays in your app and is never transmitted at that stage.

GET /authorize
  ?response_type=code
  &client_id=your-client-id
  &code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM
  &code_challenge_method=S256
  &redirect_uri=https://yourapp.com/callback

When you exchange the code, you send the verifier. The server hashes it and compares against the challenge it stored. A match proves the exchanging party is the same one that began the flow.

An attacker who intercepts the code doesn't have the verifier, and can't derive it — SHA-256 doesn't run backwards. The stolen code is inert.

"But my app has a client secret"

This is the objection that keeps PKCE out of server-side apps, and it's weaker than it sounds.

A client secret authenticates the application. PKCE authenticates the flow. They defend different things, and the secret doesn't cover the code interception path at all.

Consider: an attacker steals a code from your logs. They can't exchange it without the secret — true. But now consider a redirect URI misconfiguration, an open redirect on your domain, or a mix-up attack where the code from one authorization server is replayed against another. In those cases the code reaches a party that either has the secret or doesn't need it. PKCE still blocks the exchange because the verifier never left the legitimate client.

There's also a simpler argument: PKCE costs you a hash and a random string. The threat model doesn't need to be certain for that trade to be worth making.

Use S256, never plain

The spec permits code_challenge_method=plain, which sends the verifier as the challenge unmodified. That's pointless — anyone who intercepts the authorization request now has the verifier.

plain exists only for clients that genuinely cannot compute SHA-256. That is essentially no client written this decade. Use S256.

If your authorization server accepts plain, check whether it can be disabled. An attacker who can modify the authorization request may be able to downgrade the method, which defeats the whole mechanism.

Requirements that are easy to get wrong

  • Verifier length: 43–128 characters, from the unreserved set [A-Z] [a-z] [0-9] - . _ ~. Shorter than 43 is a spec violation and materially weakens the entropy.
  • Real randomness: use a CSPRNG. Math.random() is not one.
  • One flow, one verifier: generate fresh per authorization request. Reusing a verifier across flows reintroduces exactly the replay problem you're closing.
  • PKCE is not CSRF protection: it doesn't replace the state parameter. You need both — state protects against cross-site request forgery on the callback, PKCE protects the code. Different attacks.

Trying it

You can generate a valid verifier and challenge pair, and check your own values hash correctly, with the PKCE Generator — it uses the Web Crypto API and runs entirely client-side. The State / Nonce Generator covers the other parameters the same flow needs, and the OAuth Flow Tester will run an end-to-end authorization request with the challenge attached.

For how PKCE works against AuthAction specifically, see the PKCE authorization flow guide.


PKCE on by default

AuthAction's SDKs generate the verifier, send the challenge, and complete the exchange for you — there's no configuration to forget.

Free, unlimited users. No credit card required.