PKCE Implementation Guide

Edited

Secure authentication for browser-based and mobile applications using OAuth 2.0 with PKCE.

Table of Contents

  1. Overview

  2. How It Works

  3. Implementation

  4. Using Your Tokens

  5. Security Best Practices

  6. Common Pitfalls

  7. Troubleshooting

Overview

PKCE (Proof Key for Code Exchange) is a security extension that protects OAuth 2.0 authorization codes from interception. It's required for all public clients — applications that can't securely store secrets, including SPAs, mobile apps, and native desktop apps.

Key Concepts

Term

Description

Code Verifier

A random string (43-128 chars) your app generates and keeps secret

Code Challenge

SHA-256 hash of the verifier, sent with the authorization request

Authorization Code

Temporary code returned after user login, exchanged for tokens

What You'll Receive

After successful authentication, you get three tokens:

Token

Purpose

Typical Lifetime

Access Token

Call APIs and UserInfo endpoint

1 hour

ID Token

JWT containing user identity claims

1 hour

Refresh Token

Get new tokens without re-login

Days/weeks

How It Works

┌─────────────┐                                    ┌──────────────┐
│    Your     │                                    │Authorization │
│    App      │                                    │   Server     │
└──────┬──────┘                                    └──────┬───────┘
       │                                                  │
       │ 1. Generate code_verifier + code_challenge       │
       │                                                  │
       │──── Authorization Request ──────────────────────>│
       │     (code_challenge, state, scopes)              │
       │                                                  │
       │                                  User logs in    │
       │                                                  │
       │<─── Redirect with authorization code ────────────│
       │                                                  │
       │──── Token Request ──────────────────────────────>│
       │     (code + code_verifier)                       │
       │                                                  │
       │     Server verifies:                             │
       │     SHA256(verifier) == challenge?               │
       │                                                  │
       │<─── Access Token + ID Token + Refresh Token ─────│
       │                                                  │

Why PKCE? Even if an attacker intercepts the authorization code, they can't exchange it for tokens without the original code verifier that only your app knows.

Implementation

Step 1: Generate PKCE Parameters

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

// Create SHA-256 code challenge
async function generateCodeChallenge(verifier) {
  const data = new TextEncoder().encode(verifier);
  const hash = await crypto.subtle.digest('SHA-256', data);
  return base64UrlEncode(new Uint8Array(hash));
}

// Generate random state for CSRF protection
function generateState() {
  const array = new Uint8Array(16);
  crypto.getRandomValues(array);
  return base64UrlEncode(array);
}

// Base64URL encoding (URL-safe, no padding)
function base64UrlEncode(buffer) {
  return btoa(String.fromCharCode(...buffer))
    .replace(/\+/g, '-')
    .replace(/\//g, '_')
    .replace(/=/g, '');
}

Step 2: Start Authorization

async function login() {
  const codeVerifier = generateCodeVerifier();
  const codeChallenge = await generateCodeChallenge(codeVerifier);
  const state = generateState();
  
  // Store for callback verification
  sessionStorage.setItem('code_verifier', codeVerifier);
  sessionStorage.setItem('oauth_state', state);
  
  const authUrl = new URL('https://your-issuer.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('scope', 'openid profile email');
  authUrl.searchParams.set('state', state);
  authUrl.searchParams.set('code_challenge', codeChallenge);
  authUrl.searchParams.set('code_challenge_method', 'S256');
  
  window.location.href = authUrl.toString();
}

Available Scopes

Scope

Claims Returned

openid

sub (required)

profile

name, given_name, family_name, picture

email

email, email_verified

phone

phone_number, phone_number_verified

Step 3: Handle Callback

async function handleCallback() {
  const params = new URLSearchParams(window.location.search);
  
  // Check for errors
  if (params.get('error')) {
    throw new Error(params.get('error_description') || params.get('error'));
  }
  
  // Validate state (CSRF protection)
  if (params.get('state') !== sessionStorage.getItem('oauth_state')) {
    throw new Error('State mismatch - possible CSRF attack');
  }
  
  // Exchange code for tokens
  const tokens = await exchangeCode(
    params.get('code'),
    sessionStorage.getItem('code_verifier')
  );
  
  // Clean up
  sessionStorage.removeItem('code_verifier');
  sessionStorage.removeItem('oauth_state');
  
  return tokens;
}

Step 4: Exchange Code for Tokens

async function exchangeCode(code, codeVerifier) {
  const response = await fetch('https://your-issuer.com/token', {
    method: 'POST',
    headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
    body: new URLSearchParams({
      grant_type: 'authorization_code',
      client_id: 'your-client-id',
      code: code,
      redirect_uri: 'https://yourapp.com/callback',
      code_verifier: codeVerifier,
    }),
  });
  
  if (!response.ok) {
    const error = await response.json();
    throw new Error(error.error_description || error.error);
  }
  
  return response.json();
}

Token Response

{
  "access_token": "eyJhbGciOiJSUzI1NiIs...",
  "id_token": "eyJhbGciOiJSUzI1NiIs...",
  "refresh_token": "v1.refresh.abc123...",
  "token_type": "Bearer",
  "expires_in": 3600
}

Using Your Tokens

Get User Profile

async function getUserInfo(accessToken) {
  const response = await fetch('https://your-issuer.com/userinfo', {
    headers: { 'Authorization': `Bearer ${accessToken}` }
  });
  return response.json();
}

// Response example:
// {
//   "sub": "user_abc123",
//   "email": "user@example.com",
//   "email_verified": true,
//   "name": "Alice Johnson"
// }

Decode ID Token (Quick Access)

function decodeIdToken(idToken) {
  const payload = idToken.split('.')[1];
  return JSON.parse(atob(payload.replace(/-/g, '+').replace(/_/g, '/')));
}

const user = decodeIdToken(tokens.id_token);
console.log('Welcome,', user.name);

Logout

function logout(idToken) {
  // Clear local storage
  localStorage.removeItem('tokens');
  
  // Redirect to end session
  const logoutUrl = new URL('https://your-issuer.com/logout');
  logoutUrl.searchParams.set('id_token_hint', idToken);
  logoutUrl.searchParams.set('post_logout_redirect_uri', window.location.origin);
  
  window.location.href = logoutUrl.toString();
}

Security Best Practices

Token Storage

Storage

Security

Recommendation

Memory (variable)

Best

High-security apps

sessionStorage

Good

SPAs (cleared on tab close)

localStorage

Moderate

Use with caution (XSS vulnerable)

httpOnly Cookie

Best

Requires backend support

Checklist

  • Always use HTTPS in production

  • Validate state parameter on every callback

  • Use sessionStorage for code verifier (not localStorage)

  • Generate new code verifier for each login attempt

  • Never log tokens or include in error messages

  • Implement token refresh before expiration

Common Pitfalls

1. Wrong Base64 Encoding

// ❌ Wrong - standard base64
const challenge = btoa(hash);

// ✅ Correct - base64url (URL-safe, no padding)
const challenge = btoa(String.fromCharCode(...new Uint8Array(hash)))
  .replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '');

2. Weak Random Generation

// ❌ Wrong - predictable
const verifier = Math.random().toString(36);

// ✅ Correct - cryptographically secure
const array = new Uint8Array(32);
crypto.getRandomValues(array);

3. Skipping State Verification

// ❌ Wrong - vulnerable to CSRF
const code = params.get('code');
exchangeCode(code, verifier);

// ✅ Correct - verify state first
if (params.get('state') !== savedState) {
  throw new Error('State mismatch');
}
exchangeCode(code, verifier);

4. Mismatched Code Verifier

// ❌ Wrong - different verifiers
const challenge = generateChallenge(verifier1);
exchangeCode(code, verifier2); // Won't work!

// ✅ Correct - store and reuse same verifier
sessionStorage.setItem('code_verifier', verifier);
// ... after callback ...
const storedVerifier = sessionStorage.getItem('code_verifier');

Troubleshooting

Error

Cause

Solution

invalid_grant

Code verifier mismatch or code expired

Verify same verifier used; codes expire in ~2 min

code_challenge_method not supported

Server config issue

Ensure S256 is specified

invalid_request

Missing parameter

Check all required params in auth URL

State mismatch

Multiple tabs or session cleared

Use sessionStorage; start fresh flow

Validate Your Implementation

async function testPKCE() {
  // RFC 7636 test vector
  const testVerifier = 'dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk';
  const expectedChallenge = 'E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM';
  
  const challenge = await generateCodeChallenge(testVerifier);
  console.assert(challenge === expectedChallenge, 'PKCE implementation is correct');
}

Quick Reference

// Complete minimal implementation
const auth = {
  async login() {
    const v = generateCodeVerifier(), s = generateState();
    sessionStorage.setItem('cv', v);
    sessionStorage.setItem('st', s);
    location.href = `https://your-issuer.com/authorize?` +
      `response_type=code&client_id=YOUR_ID&redirect_uri=${encodeURIComponent(location.origin + '/callback')}` +
      `&scope=openid%20profile%20email&state=${s}&code_challenge=${await generateCodeChallenge(v)}&code_challenge_method=S256`;
  },
  
  async callback() {
    const p = new URLSearchParams(location.search);
    if (p.get('state') !== sessionStorage.getItem('st')) throw new Error('State mismatch');
    const r = await fetch('https://your-issuer.com/token', {
      method: 'POST', headers: {'Content-Type': 'application/x-www-form-urlencoded'},
      body: new URLSearchParams({grant_type: 'authorization_code', client_id: 'YOUR_ID',
        code: p.get('code'), redirect_uri: location.origin + '/callback',
        code_verifier: sessionStorage.getItem('cv')})
    });
    sessionStorage.removeItem('cv'); sessionStorage.removeItem('st');
    return r.json();
  }
};

Was this article helpful?

Sorry about that! Care to tell us more?

Thanks for the feedback!

There was an issue submitting your feedback
Please check your connection and try again.