PKCE (Proof Key for Code Exchange)

Authentication Security Notes Jan 6, 2025 JAVASCRIPT

Definition

Here’s a scary scenario: you’re using a mobile banking app, and somewhere in the background, a malicious app is watching your network traffic. You log in through OAuth, the authorization server sends back a code, and… the malicious app intercepts it. Now they have everything they need to steal your tokens and access your bank account. This attack was so effective against mobile apps that OAuth needed a patch. That patch is PKCE - Proof Key for Code Exchange (pronounced “pixy”).

PKCE adds a cryptographic handshake to OAuth’s authorization code flow. Before you start logging in, your app generates a random secret called a “code verifier” - a long, unpredictable string. It then creates a “code challenge” by hashing that verifier with SHA-256. When your app asks for authorization, it sends along the code challenge. When it later exchanges the authorization code for tokens, it sends the original verifier. The authorization server checks: does this verifier hash to the challenge I received earlier? If yes, proceed. If no, someone intercepted the code and is trying to use it.

The beauty of PKCE is its simplicity and the impossibility of cheating. An attacker who intercepts the authorization code doesn’t have the code verifier - it was never transmitted, just the hash. And you can’t reverse a SHA-256 hash to get the original value. So even with the authorization code in hand, the attacker can’t complete the token exchange. It’s like mailing someone half of a two-part key: useless without the other half, which never left your possession. PKCE is now mandatory in OAuth 2.1, recognizing that this attack vector was too dangerous to leave optional.

Example

PKCE protects OAuth flows wherever authorization codes travel through untrusted environments:

Mobile Banking Apps: When you open your Chase or Bank of America app and tap “Log in with Google,” the app generates a PKCE code verifier and stores it securely. It sends only the hash (challenge) to Google’s authorization endpoint. After you authenticate, Google redirects back with an authorization code. Even if a malicious app intercepts this redirect (a real attack vector on Android), it can’t exchange the code without the verifier that never left the legitimate banking app.

Single-Page Applications: Spotify’s web player uses PKCE when authenticating you. Since JavaScript runs in the browser where anyone can view the source code, there’s no place to hide a client secret. PKCE solves this: the code verifier lives only in your browser’s session storage, sent to Spotify’s server only during token exchange. The challenge sent initially proves nothing without the verifier to complete the handshake.

Desktop Applications: Slack’s desktop app uses PKCE when you sign in. It generates the verifier, opens your browser for Google/Okta authentication, and waits for the callback. The operating system routes the callback back to Slack, which then exchanges the code using its secret verifier. Even if another application registered the same callback URL (a documented attack), it wouldn’t have the verifier.

CLI Tools: The GitHub CLI uses PKCE during gh auth login. It generates a verifier, opens your browser to github.com/login/device, you authenticate there, and when you return to the terminal, the CLI uses its verifier to complete the exchange. No secrets stored in plain text config files.

Smart TV Apps: When Netflix on your TV asks you to authenticate, it uses PKCE with device flow. The TV displays a code, you visit netflix.com/activate on your phone, and the TV polls for completion. PKCE ensures that even if someone sees the displayed code, they can’t hijack your authentication because they don’t have the verifier the TV generated.

Analogy

The Sealed Envelope System: Imagine you’re sending a valuable package through an unreliable courier. Before shipping, you write a secret phrase on a piece of paper and seal it in an envelope that you keep. You tell the recipient: “I’m sending a package. When it arrives, I’ll call you with the secret phrase. Only release it to whoever knows the phrase.” Even if the courier steals the package, they can’t claim it - they don’t know the phrase that never left your possession.

The Split Key Safe Deposit Box: Banks sometimes use two-key systems where both the customer and the bank must insert their keys simultaneously. PKCE is similar: the authorization code is like the bank’s key (it traveled through the network), but it only works when combined with the code verifier (your key that never left your device). Having one key alone opens nothing.

The Matching Ticket Stub: At a high-end restaurant’s valet service, they tear your ticket in half - you keep one piece with a unique pattern, they keep the other. When you return, both halves must match the tear pattern exactly. A thief who photographed the valet’s half still can’t claim your car because they don’t have your half with its unique tear pattern that was never exposed.

The Combination Lock with Two Parts: Imagine a lock where you set the first half of the combination, then whisper only a hash of it to the locksmith. Later, you return and dial the full combination. The locksmith checks: does the hash of what you just entered match what you whispered before? If someone overheard just the hash, they still can’t open the lock - they’d need to reverse-engineer your original numbers from the hash, which is mathematically infeasible.

Diagram

sequenceDiagram
    participant App as Client App
    participant Auth as Authorization Server

    Note over App: Generate PKCE pair
    App->>App: 1. Create code_verifier
(random 43-128 chars) App->>App: 2. Create code_challenge
= SHA256(code_verifier) App->>App: 3. Store code_verifier securely App->>Auth: 4. Authorization request
(code_challenge + method=S256) Note over Auth: Store code_challenge
with auth session Auth->>App: 5. Authorization code Note over App: Even if code is intercepted... App->>Auth: 6. Token request
(code + code_verifier) Auth->>Auth: 7. Verify: SHA256(code_verifier)
== stored code_challenge? alt Verification succeeds Auth->>App: 8. Access token + Refresh token else Verification fails (attacker) Auth->>App: 8. Error: invalid_grant Note over Auth: Attacker cannot guess
code_verifier from hash end

Code Example


// PKCE implementation
const crypto = require('crypto');

// Generate code verifier (random string)
function generateCodeVerifier() {
  return crypto.randomBytes(32).toString('base64url');
}

// Generate code challenge (SHA256 hash of verifier)
function generateCodeChallenge(verifier) {
  return crypto
    .createHash('sha256')
    .update(verifier)
    .digest('base64url');
}

// Step 1: Start authorization flow with PKCE
const codeVerifier = generateCodeVerifier();
const codeChallenge = generateCodeChallenge(codeVerifier);

// Store verifier securely (session storage for SPAs)
sessionStorage.setItem('pkce_verifier', codeVerifier);

// Redirect to authorization server with challenge
const authUrl = new URL('https://oauth.provider.com/authorize');
authUrl.searchParams.append('response_type', 'code');
authUrl.searchParams.append('client_id', 'your_client_id');
authUrl.searchParams.append('redirect_uri', 'https://yourapp.com/callback');
authUrl.searchParams.append('code_challenge', codeChallenge);
authUrl.searchParams.append('code_challenge_method', 'S256');
authUrl.searchParams.append('scope', 'read:user');
authUrl.searchParams.append('state', generateRandomState());

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

// Step 2: Exchange authorization code with verifier
const tokenResponse = await fetch('https://oauth.provider.com/token', {
  method: 'POST',
  headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
  body: new URLSearchParams({
    grant_type: 'authorization_code',
    code: authorizationCode,
    client_id: 'your_client_id',
    redirect_uri: 'https://yourapp.com/callback',
    code_verifier: sessionStorage.getItem('pkce_verifier')
  })
});

// Server verifies: SHA256(code_verifier) === code_challenge

Security Notes

SECURITY NOTES

CRITICAL: PKCE prevents authorization code interception. Mandatory for public clients.

PKCE Flow:

  • Code verifier: Client generates random string (43-128 characters)
  • Code challenge: Hash code verifier (SHA256 recommended)
  • Authorize request: Include code challenge in authorize request
  • Authorization code: Server returns code
  • Token request: Client sends code, code_verifier, gets token

Security Benefits:

  • Authorization code theft: Code alone insufficient without verifier
  • MITM protection: Attacker cannot use stolen code
  • Public clients: Especially important for mobile/desktop apps
  • SPA protection: Single-page apps vulnerable without PKCE

Implementation:

  • Code verifier length: 43-128 characters recommended
  • Code challenge method: SHA256 (S256) or plain text (not recommended)
  • Random generation: Use cryptographically random verifier
  • Browser friendly: Works with redirect-based flow

Validation:

  • Match verification: Server verifies SHA256(verifier) == challenge
  • Code binding: Verifier must match code originally issued
  • One-time use: Code and verifier used only once
  • Time window: Complete flow within reasonable time window

Standards & RFCs