โ† All flows

PKCE Flow

Authorization Code flow for apps that cannot keep a secret.

Who's involved
User, app, auth server
Best for
Single-page and mobile apps
Difficulty
Moderate

1Overview

How it works

PKCE (Proof Key for Code Exchange, pronounced "pixy") extends the Authorization Code flow for public clients: apps that cannot safely store a client secret, such as single-page, mobile and desktop apps. Anyone can pull a secret out of such an app's code, so a fixed secret would protect nothing.

Instead, the app makes a fresh secret for every sign-in:

  1. It creates a random code_verifier.
  2. It sends a hash of it, the code_challenge, when authorization starts.
  3. It shows the original verifier when it exchanges the code for tokens.

This proves that the same app that started the request is finishing it. OAuth 2.1 recommends PKCE for all clients, even those with a secret.

2Use cases

When would you use this?

A single-page app

A React app in the browser lets a user sign in with Google. There is no server to hide a secret on.

A mobile app

A fitness app on your phone asks to read your data from another service.

A desktop app

A note-taking app on your laptop signs you in through your browser.

Not the right fit? Have a server that can keep a secret? Authorization Code works, and PKCE is still worth adding as an extra layer.

3Interactive

Try it step by step

See it step by step

๐Ÿ“ฑ
App
๐Ÿ”
Login server
๐Ÿ“ฆ
API
Make secret

Step 1 of 6

The app makes a secret and a fingerprint

It invents a random secret (the code_verifier) and works out a fingerprint of it (the code_challenge). The fingerprint is safe to share; the secret is not.

PKCE or Authorization Code?

Authorization CodePKCE
Client typeConfidential (can keep secrets)Public (cannot keep secrets)
Typical useServer-side web appsSingle-page, mobile and desktop apps
Stolen code is useless becauseThe attacker lacks the client_secretThe attacker lacks the code_verifier

4Practical advice

Do this, avoid that

Good practices

  • Always use the S256 method โ€” It uses SHA-256. The plain method is only for legacy systems
  • Generate verifiers with a secure random source โ€” Use crypto.getRandomValues(), never Math.random()
  • Keep the verifier in session storage โ€” Not localStorage, and clear it after the token exchange
  • Rotate refresh tokens โ€” For single-page apps, each refresh token should work only once
  • Consider a backend-for-frontend (BFF) โ€” A small server that holds tokens for your app and gives the browser httpOnly cookies
  • Still check the state parameter โ€” PKCE does not replace CSRF protection

Common mistakes

  • Storing tokens in localStorage โ€” Any script on the page can read it (an XSS attack); use memory or httpOnly cookies
  • Using weak random numbers โ€” Math.random() is not cryptographically secure
  • Reusing a code_verifier โ€” Create a new one for every authorization request
  • Using the plain challenge method โ€” Use S256 (SHA-256) so the verifier stays hidden
  • Forgetting to validate redirect_uri โ€” The authorization server must check that it matches the registered value

5Code

Code example

Show the code
PKCE Flow example
javascript
// PKCE Flow Implementation for Single-Page Applications
// This flow is perfect for apps that cannot securely store a client_secret

// Step 1: Generate code verifier and challenge
function generateCodeVerifier() {
  const array = new Uint8Array(32);
  crypto.getRandomValues(array);
  return base64URLEncode(array);
}

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

async function generateCodeChallenge(verifier) {
  const encoder = new TextEncoder();
  const data = encoder.encode(verifier);
  const hash = await crypto.subtle.digest('SHA-256', data);
  return base64URLEncode(hash);
}

// Step 2: Initiate OAuth flow with PKCE
async function initiateOAuthFlowWithPKCE() {
  // Generate and store verifier
  const codeVerifier = generateCodeVerifier();
  sessionStorage.setItem('code_verifier', codeVerifier);

  // Generate challenge
  const codeChallenge = await generateCodeChallenge(codeVerifier);

  // Build authorization URL
  const authUrl = 'https://auth-server.com/oauth/authorize';
  const params = new URLSearchParams({
    response_type: 'code',
    client_id: 'your-public-client-id', // No secret needed!
    redirect_uri: 'https://yourapp.com/callback',
    scope: 'read write',
    state: generateRandomState(),
    code_challenge: codeChallenge,
    code_challenge_method: 'S256' // SHA-256
  });

  // Redirect user
  window.location.href = `${authUrl}?${params}`;
}

// Step 3: Handle callback and exchange code for tokens
async function handleOAuthCallback(authCode, state) {
  // Verify state
  if (state !== sessionStorage.getItem('oauth_state')) {
    throw new Error('State mismatch - possible CSRF attack!');
  }

  // Retrieve stored verifier
  const codeVerifier = sessionStorage.getItem('code_verifier');
  if (!codeVerifier) {
    throw new Error('Code verifier not found');
  }

  // Exchange code for tokens
  const tokenUrl = 'https://auth-server.com/oauth/token';
  const response = await fetch(tokenUrl, {
    method: 'POST',
    headers: {
      'Content-Type': 'application/x-www-form-urlencoded',
    },
    body: new URLSearchParams({
      grant_type: 'authorization_code',
      code: authCode,
      redirect_uri: 'https://yourapp.com/callback',
      client_id: 'your-public-client-id',
      code_verifier: codeVerifier // The secret revealed!
    })
  });

  if (!response.ok) {
    throw new Error(`Token exchange failed: ${response.status}`);
  }

  const tokens = await response.json();
  // tokens = { access_token, refresh_token, expires_in, token_type }

  // Clean up
  sessionStorage.removeItem('code_verifier');
  sessionStorage.removeItem('oauth_state');

  return tokens;
}

// Step 4: Secure token storage (use httpOnly cookies on your backend proxy)
// For pure SPA, use memory storage with refresh token rotation
class TokenStore {
  constructor() {
    this.accessToken = null;
    this.refreshToken = null;
  }

  setTokens(accessToken, refreshToken) {
    this.accessToken = accessToken;
    this.refreshToken = refreshToken;
    // DO NOT store in localStorage (XSS vulnerable)
  }

  async getAccessToken() {
    // Check if token is expired, refresh if needed
    if (this.isTokenExpired()) {
      await this.refreshAccessToken();
    }
    return this.accessToken;
  }

  async refreshAccessToken() {
    const response = await fetch('https://auth-server.com/oauth/token', {
      method: 'POST',
      headers: {
        'Content-Type': 'application/x-www-form-urlencoded',
      },
      body: new URLSearchParams({
        grant_type: 'refresh_token',
        refresh_token: this.refreshToken,
        client_id: 'your-public-client-id'
      })
    });

    const tokens = await response.json();
    this.setTokens(tokens.access_token, tokens.refresh_token);
  }
}

// Helper functions
function generateRandomState() {
  const array = new Uint8Array(16);
  crypto.getRandomValues(array);
  const state = base64URLEncode(array);
  sessionStorage.setItem('oauth_state', state);
  return state;
}