OAuth & OIDC

How PKCE Works in OAuth 2.0 (and Why You Need It)

PKCE explained step by step — the authorization-code interception attack it prevents, how the code verifier and challenge work, and why it's now mandatory for every OAuth client.

Emilian GheoneaJune 24, 20266 min read

PKCE (pronounced "pixy") is the small addition to OAuth 2.0 that quietly became mandatory for nearly every client. If you have ever wondered what those code_challenge and code_verifier parameters are doing in an OAuth flow, this article explains exactly what attack they stop and why the mechanism is so elegant.

If you need a refresher on the surrounding flow first, start with How OAuth 2.0 Actually Works — this piece assumes you know what the authorization code is.

The attack PKCE prevents

In the Authorization Code flow, the authorization server redirects the user back to your app with a one-time authorization code in the URL. Your app then exchanges that code for tokens.

The problem: on some platforms — especially native mobile apps and single-page apps — that redirect can be intercepted. A malicious app registered for the same custom URL scheme, a leaky referrer, or browser history can expose the code to an attacker. If the attacker grabs the code before your app exchanges it, and there is no client secret protecting the exchange (SPAs and native apps can't safely hold a secret), the attacker can redeem the code for tokens themselves.

PKCE — Proof Key for Code Exchange (RFC 7636) — closes this hole by proving that the app redeeming the code is the same app that started the flow.

The mechanism: a per-request secret

PKCE works by generating a fresh secret for every authorization request and splitting it across the two legs of the flow:

  • code_verifier — a high-entropy random string your app generates and keeps private.
  • code_challenge — the SHA-256 hash of the verifier, sent openly at the start.

The insight: the challenge (a hash) can travel in the interceptable redirect safely, because a hash can't be reversed into the verifier. When your app later exchanges the code, it presents the original verifier. The server hashes it and checks it matches the challenge it saw earlier. An attacker who intercepted the code never saw the verifier, so they can't complete the exchange.

Rendering diagram…

Step by step

1. Generate the verifier and challenge

The verifier is 43–128 characters of unreserved URL-safe randomness. The challenge is its SHA-256 hash, base64url-encoded (the S256 method — always prefer it over the legacy plain method).

function base64url(buffer) {
  return btoa(String.fromCharCode(...new Uint8Array(buffer)))
    .replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
}

// code_verifier: keep this secret, in memory
const verifierBytes = crypto.getRandomValues(new Uint8Array(32));
const codeVerifier = base64url(verifierBytes);

// code_challenge: safe to send in the redirect
const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(codeVerifier));
const codeChallenge = base64url(digest);

2. Start authorization with the challenge

GET /authorize
  ?response_type=code
  &client_id=YOUR_CLIENT_ID
  &redirect_uri=https://yourapp.com/callback
  &code_challenge=E9Melhoa2Ow...        (the hash)
  &code_challenge_method=S256
  &state=...

Stash the code_verifier somewhere the redirect can't leak it — in-memory or session storage, never in the URL.

3. Exchange the code with the verifier

POST /token
  grant_type=authorization_code
  code=THE_AUTH_CODE
  redirect_uri=https://yourapp.com/callback
  code_verifier=dBjftJeZ4CVP...         (the original secret)
  client_id=YOUR_CLIENT_ID

The server recomputes SHA256(code_verifier) and compares it to the challenge it stored. Match → tokens. Mismatch or missing verifier → rejected.

Getting the details wrong

PKCE looks simple enough that it is usually implemented from memory, which is how the same four mistakes keep appearing in code review.

Generating the verifier from something predictable. Math.random() is not a cryptographic random source, and neither is a timestamp, a UUID v1, or a hash of the session id. The whole guarantee rests on an attacker being unable to guess the verifier from anything they can observe. Use crypto.getRandomValues in the browser, crypto.randomBytes in Node, secrets.token_urlsafe in Python. Thirty-two bytes of entropy is the practical floor.

Reusing the verifier across requests. Some implementations generate one verifier at app startup and reuse it for every authorization. That converts a per-request secret into a long-lived one: an attacker who obtains it once — from a crash log, a debug build, a shared device — can redeem every future intercepted code. Generate a fresh verifier per authorization request, and discard it the moment the exchange completes or fails.

Storing the verifier where the redirect can reach it. Putting the verifier in localStorage keyed by nothing in particular, or worse, in the URL alongside the challenge, defeats the point. In-memory is best; sessionStorage is an acceptable compromise for flows that survive a full-page redirect. If you must persist it, key it by the state value so a concurrent flow in another tab cannot overwrite it.

Accepting plain on the server. The plain method sends the verifier itself as the challenge, which offers no protection against an attacker who can read the redirect. It exists only for constrained devices that cannot compute SHA-256, which in 2026 means essentially nothing. An authorization server should advertise only S256 in its metadata and reject code_challenge_method=plain outright. A client should refuse to downgrade even if the server offers it.

There is a fifth failure that is subtler and belongs to the server side: enforcing PKCE only when it is present. If the authorization server accepts a token request without a code_verifier for a code that was issued with a challenge, an attacker who intercepts the code can simply omit the verifier and be granted tokens. The check must be that a code issued with a challenge can only be redeemed with a matching verifier — absence is a rejection, not a bypass. Any server that treats a missing verifier as "this client isn't using PKCE" has implemented an opt-out for attackers.

What PKCE does not do

PKCE has a narrow job, and it is worth being precise about the boundaries, because teams routinely credit it with protections it does not provide.

It does not authenticate the client. A confidential client with a real client secret proves which application it is; PKCE only proves that the party redeeming the code is the same party that started the flow. Those are different claims. A malicious app that starts its own flow has a perfectly valid verifier for its own code — PKCE never claimed otherwise.

It does not protect the tokens after issuance. Once the exchange succeeds, you hold an access token and probably a refresh token, and PKCE has left the building. Everything about storing those safely, rotating them, and revoking them is a separate problem covered in refresh token rotation and session management.

It does not validate the redirect_uri. Exact-match registration of redirect URIs is still the control that stops an attacker from having the code delivered somewhere else entirely. PKCE makes a stolen code useless; strict redirect matching stops the code from being stolen in the first place. Both, always.

And, as covered below, it does not prevent CSRF on the callback.

Why PKCE replaced the implicit flow

OAuth once had an implicit flow that returned tokens directly in the redirect URL, precisely because SPAs couldn't keep a client secret. But putting access tokens in URLs is dangerous — they leak through history, referrers, and logs. The modern guidance (OAuth 2.0 Security BCP and OAuth 2.1) is unambiguous: use the Authorization Code flow with PKCE for everything, including SPAs and native apps, and retire the implicit flow entirely.

Don't forget state

PKCE protects the code exchange; it does not protect against CSRF on the redirect itself. You still need the state parameter — an unguessable value you generate, send in the authorization request, and verify on the callback — to ensure the response corresponds to a request your app actually made. The two mechanisms solve different problems and you need both. The open-redirect and CSRF angles are covered in Common Authentication Vulnerabilities.

PKCE in embedded and iframe contexts

Embedded authentication faces the same interception concerns as SPAs, plus the cross-origin constraints described in Why iframe Authentication Is Difficult. EmbedAuth runs OAuth with state for every provider and PKCE wherever the provider supports it (Google does; Discord does not), so your integration receives an already-verified identity as a signed RS256 JWT — you never have to manage the verifier yourself.

Key takeaways

  • PKCE stops authorization-code interception by proving the redeemer started the flow.
  • The challenge (a hash) travels in the interceptable redirect; the verifier (the secret) is presented only at token exchange.
  • Always use S256, never plain.
  • PKCE is now the standard for all clients, not just public ones — pair it with state for full protection.

Written by

Emilian Gheonea

Senior Blockchain & Full-Stack Software Engineer. I build EmbedAuth — an embeddable authentication platform for SaaS — and write about the auth problems most teams hit too late.