EncryptCodecencryptcodec
Blog/Security
SecurityMarch 12, 2026 · 7 min read

OAuth 2.0 PKCE — Why Every SPA and Mobile App Needs It

If your SPA or mobile app is still using the Implicit Flow — or even the Authorization Code Flow without PKCE — you have an authorization code interception vulnerability waiting to be exploited. PKCE (Proof Key for Code Exchange) closes that hole. As of OAuth 2.1, it's required for all public clients.

Why Implicit Flow Was Never Really Safe

The Implicit Flow was designed for public clients (apps that can't store a client secret) — browsers and mobile apps. Instead of exchanging a code for a token, it returned the access token directly in the URL fragment.

The problem: tokens in URLs are dangerous.

  • They end up in browser history
  • They leak through Referer headers
  • They're visible in server logs
  • They can be intercepted by malicious scripts

The Authorization Code Flow was supposed to be safer, but for public clients it introduced a different problem: authorization code interception.

The Authorization Code Interception Attack

Without PKCE, here's what an attacker can do on mobile:

  1. A malicious app registers the same custom URL scheme as your app (myapp://callback)
  2. The OS delivers the authorization code to the malicious app instead of yours
  3. The attacker exchanges the code for tokens — your tokens

On SPAs, a similar attack can happen via cross-site scripting or a compromised redirect URI.

PKCE solves this by making the code useless without proof that you initiated the request.

How PKCE Works

PKCE adds a cryptographic challenge to the Authorization Code Flow:

Step 1 — Generate a code verifier A high-entropy random string, 43–128 characters, using URL-safe characters.

Step 2 — Derive a code challenge Hash the verifier with SHA-256 and Base64URL-encode it:

code_challenge = BASE64URL(SHA256(code_verifier))

Step 3 — Send the challenge with the auth request

GET /authorize?
  response_type=code
  &client_id=your_client_id
  &redirect_uri=https://yourapp.com/callback
  &code_challenge=<BASE64URL_SHA256_HASH>
  &code_challenge_method=S256

Step 4 — Receive the authorization code The server stores the challenge alongside the code.

Step 5 — Exchange code + verifier for tokens

POST /token
  code=<authorization_code>
  &code_verifier=<original_random_string>
  &grant_type=authorization_code
  ...

The server hashes the verifier and compares it to the stored challenge. If they don't match, the request is rejected. An intercepted code is useless without the verifier, which never left your app.

OAuth 2.0 PKCE Flow

1
2
3
4
5
6

Implementing PKCE

If you're using Auth0 SDK, AppAuth, or oidc-client-ts, PKCE is already handled. Here's what's actually happening under the hood:

// Generate a cryptographically random code verifier
function generateCodeVerifier() {
const array = new Uint8Array(32);
crypto.getRandomValues(array);
return base64URLEncode(array);
}

// Derive the code challenge via SHA-256
async function generateCodeChallenge(verifier) {
const encoder = new TextEncoder();
const data = encoder.encode(verifier);
const digest = await crypto.subtle.digest("SHA-256", data);
return base64URLEncode(new Uint8Array(digest));
}

function base64URLEncode(buffer) {
return btoa(String.fromCharCode(...buffer))
  .replace(/+/g, "-")
  .replace(///g, "_")
  .replace(/=/g, "");
}

// Usage
const verifier = generateCodeVerifier();
const challenge = await generateCodeChallenge(verifier);

// Store verifier in sessionStorage (not localStorage)
sessionStorage.setItem("pkce_verifier", verifier);

// Build auth URL
const authUrl = new URL("https://auth.example.com/authorize");
authUrl.searchParams.set("response_type", "code");
authUrl.searchParams.set("client_id", "your_client_id");
authUrl.searchParams.set("redirect_uri", "https://yourapp.com/callback");
authUrl.searchParams.set("code_challenge", challenge);
authUrl.searchParams.set("code_challenge_method", "S256");
authUrl.searchParams.set("scope", "openid profile");

window.location.href = authUrl.toString();

Common Mistakes to Avoid

Using plain instead of S256 for the challenge method code_challenge_method=plain sends the verifier as the challenge directly — no hashing. This provides almost no security benefit. Always use S256.

Storing the verifier in localStorage localStorage is accessible to any JavaScript on the page. For SPAs, use sessionStorage or in-memory state. For mobile, use the Keychain (iOS) or EncryptedSharedPreferences (Android).

Reusing code verifiers Generate a fresh verifier for every authorization request. Reuse defeats the purpose.

Skipping the state parameter PKCE protects against code interception. The state parameter protects against CSRF. You need both.

Assuming PKCE replaces a client secret For confidential clients (server-side apps), you still use a client secret. PKCE is for public clients where a secret can't be stored safely — it's an additional layer, not a replacement.

Should Confidential Clients Use PKCE Too?

Yes — and not just because OAuth 2.1 says so. If a code interception attack is possible against public clients, it's possible against confidential ones too if the client secret is ever leaked or the server is compromised. Enable it everywhere your authorization server supports it. The cost is effectively zero.

App TypeFlow to Use
SPA (React, Angular, Next.js)Authorization Code + PKCE
Mobile app (iOS, Android)Authorization Code + PKCE
Server-side web appAuthorization Code + client secret (+ PKCE recommended)
Machine-to-machine / CLIClient Credentials
New appsImplicit Flow (deprecated — never)

PKCE is a straightforward addition to the Authorization Code Flow that eliminates a whole class of token theft attacks. The implementation is a few lines of crypto — SHA-256 and Base64URL encoding. If you're using a modern OAuth library (Auth0 SDK, AppAuth, oidc-client-ts, Authlib), PKCE support is already built in and often enabled by default.

If you're building any public client today — SPA, mobile, CLI — turn on PKCE. There's no good reason not to.

Share this post

Generate a secure secret or code verifier

Free, browser-based — no signup required.

Frequently Asked Questions

Related posts