OAuth2 Authorization Code Flow with PKCE

Type: Software Reference Confidence: 0.92 Sources: 8 Verified: 2026-02-24 Freshness: 2026-02-24

TL;DR

Constraints

Quick Reference

StepActionEndpointKey Parameters
1Generate code_verifierClient-side43-128 chars, cryptographically random
2Derive code_challengeClient-sideBASE64URL(SHA256(code_verifier))
3Authorization requestGET /authorizeresponse_type=code, code_challenge, code_challenge_method=S256, state, redirect_uri
4User authenticatesAuthorization serverUser enters credentials, consents
5Receive authorization codeRedirect to clientcode param in callback URL
6Exchange code for tokensPOST /tokengrant_type=authorization_code, code, code_verifier, redirect_uri
7Server verifies PKCEAuthorization serverComputes BASE64URL(SHA256(code_verifier)), compares to stored challenge
8Receive tokensClientaccess_token, refresh_token (optional), id_token (if OIDC)
9Use access tokenResource serverAuthorization: Bearer {access_token}
10Refresh tokensPOST /tokengrant_type=refresh_token, use refresh token rotation for SPAs

Decision Tree

START
├── Is the client a public client (SPA, mobile, desktop, CLI)?
│   ├── YES → Use Authorization Code + PKCE (this unit)
│   └── NO ↓
├── Is it server-to-server with no user interaction?
│   ├── YES → Use Client Credentials flow
│   └── NO ↓
├── Is it a device without a browser (smart TV, IoT)?
│   ├── YES → Use Device Authorization flow (RFC 8628)
│   └── NO ↓
├── Is it a server-side web app (confidential client)?
│   ├── YES → Use Authorization Code + PKCE + client_secret (OAuth 2.1 recommends PKCE for all)
│   └── NO ↓
└── DEFAULT → Use Authorization Code + PKCE (safest default for any client type)

Step-by-Step Guide

1. Generate a cryptographic code_verifier

Create a random string of 43-128 characters. Generate 32 random bytes and base64url-encode them (yielding 43 characters). [src1]

// Generate code_verifier (43 chars from 32 random bytes)
const array = new Uint8Array(32);
crypto.getRandomValues(array);
const codeVerifier = btoa(String.fromCharCode(...array))
  .replace(/\+/g, '-')
  .replace(/\//g, '_')
  .replace(/=+$/, '');

Verify: codeVerifier.length → should be 43, all characters match [A-Za-z0-9\-._~]

2. Derive the code_challenge using S256

Hash the code_verifier with SHA-256 and base64url-encode the result. [src1]

// Derive code_challenge = BASE64URL(SHA256(code_verifier))
async function generateCodeChallenge(verifier) {
  const encoder = new TextEncoder();
  const data = encoder.encode(verifier);
  const digest = await crypto.subtle.digest('SHA-256', data);
  return btoa(String.fromCharCode(...new Uint8Array(digest)))
    .replace(/\+/g, '-')
    .replace(/\//g, '_')
    .replace(/=+$/, '');
}
const codeChallenge = await generateCodeChallenge(codeVerifier);

Verify: codeChallenge is a 43-character base64url string, different from codeVerifier

3. Build the authorization URL and redirect

Construct the authorization request with PKCE parameters, state, and redirect URI. [src2]

// Build authorization URL
const state = crypto.randomUUID(); // CSRF protection
sessionStorage.setItem('oauth_state', state);
sessionStorage.setItem('code_verifier', codeVerifier);

const params = new URLSearchParams({
  response_type: 'code',
  client_id: 'YOUR_CLIENT_ID',
  redirect_uri: 'https://app.example.com/callback',
  scope: 'openid profile email',
  state: state,
  code_challenge: codeChallenge,
  code_challenge_method: 'S256'
});

window.location.href = `https://auth.example.com/authorize?${params}`;

Verify: URL contains code_challenge and code_challenge_method=S256 parameters

4. Handle the callback and extract the authorization code

When the authorization server redirects back, validate the state parameter and extract the code. [src2]

// In callback handler (e.g., /callback route)
const urlParams = new URLSearchParams(window.location.search);
const code = urlParams.get('code');
const returnedState = urlParams.get('state');

// Validate state to prevent CSRF
const savedState = sessionStorage.getItem('oauth_state');
if (returnedState !== savedState) {
  throw new Error('State mismatch -- possible CSRF attack');
}

Verify: code is present and returnedState === savedState

5. Exchange the authorization code for tokens

Send the code and the original code_verifier to the token endpoint. The server computes SHA256(code_verifier) and compares it to the stored code_challenge. [src1]

// Exchange code for tokens
const codeVerifier = sessionStorage.getItem('code_verifier');

const tokenResponse = await fetch('https://auth.example.com/oauth/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://app.example.com/callback',
    code_verifier: codeVerifier
  })
});

const tokens = await tokenResponse.json();

Verify: Response contains access_token. HTTP 200 status. No invalid_grant error.

6. Store tokens securely and clean up

Store tokens appropriately for your platform. For SPAs, use in-memory storage or httpOnly cookies -- never localStorage. [src6]

// Store tokens in memory (SPA best practice)
const tokenStore = {
  accessToken: tokens.access_token,
  refreshToken: tokens.refresh_token,
  expiresAt: Date.now() + (tokens.expires_in * 1000)
};

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

Verify: sessionStorage no longer contains code_verifier or oauth_state

Code Examples

JavaScript/TypeScript: SPA PKCE Helper

// Input:  Authorization server URLs + client_id
// Output: Complete PKCE flow utilities

class PKCEClient {
  constructor(authUrl, tokenUrl, clientId, redirectUri) {
    this.authUrl = authUrl;
    this.tokenUrl = tokenUrl;
    this.clientId = clientId;
    this.redirectUri = redirectUri;
  }

  async generateVerifier() {
    const bytes = crypto.getRandomValues(new Uint8Array(32));
    return this.base64url(bytes);
  }

  async generateChallenge(verifier) {
    const digest = await crypto.subtle.digest(
      'SHA-256', new TextEncoder().encode(verifier)
    );
    return this.base64url(new Uint8Array(digest));
  }

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

Python: Server-Side PKCE Flow

# Input:  auth_url, token_url, client_id
# Output: PKCE authorization + token exchange

import hashlib, base64, os, secrets, urllib.parse
import httpx  # pip install httpx>=0.27

def generate_pkce_pair():
    """Generate code_verifier and code_challenge (S256)."""
    verifier = base64.urlsafe_b64encode(os.urandom(32)).rstrip(b'=').decode()
    challenge = base64.urlsafe_b64encode(
        hashlib.sha256(verifier.encode()).digest()
    ).rstrip(b'=').decode()
    return verifier, challenge

def build_auth_url(auth_url, client_id, redirect_uri, scope, challenge):
    """Build authorization URL with PKCE parameters."""
    params = urllib.parse.urlencode({
        'response_type': 'code',
        'client_id': client_id,
        'redirect_uri': redirect_uri,
        'scope': scope,
        'state': secrets.token_urlsafe(32),
        'code_challenge': challenge,
        'code_challenge_method': 'S256',
    })
    return f"{auth_url}?{params}"

async def exchange_code(token_url, client_id, code, verifier, redirect_uri):
    """Exchange authorization code for tokens using code_verifier."""
    async with httpx.AsyncClient() as client:
        resp = await client.post(token_url, data={
            'grant_type': 'authorization_code',
            'client_id': client_id,
            'code': code,
            'code_verifier': verifier,
            'redirect_uri': redirect_uri,
        })
        resp.raise_for_status()
        return resp.json()

Node.js/Express: Backend Callback Handler

// Input:  Express request with ?code=...&state=...
// Output: Token response from authorization server

const crypto = require('node:crypto');

function generatePKCE() {
  const verifier = crypto.randomBytes(32)
    .toString('base64url'); // Node 16+
  const challenge = crypto.createHash('sha256')
    .update(verifier)
    .digest('base64url');
  return { verifier, challenge };
}

// In Express route handler:
app.get('/callback', async (req, res) => {
  const { code, state } = req.query;
  if (state !== req.session.oauthState) {
    return res.status(403).send('State mismatch');
  }
  const resp = await fetch(TOKEN_URL, {
    method: 'POST',
    headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
    body: new URLSearchParams({
      grant_type: 'authorization_code',
      client_id: CLIENT_ID,
      code,
      code_verifier: req.session.codeVerifier,
      redirect_uri: REDIRECT_URI,
    }),
  });
  const tokens = await resp.json();
  req.session.accessToken = tokens.access_token;
  res.redirect('/dashboard');
});

Anti-Patterns

Wrong: Using plain code challenge method

// BAD -- plain method sends code_verifier as code_challenge
// An attacker who intercepts the authorization request gets the verifier
const params = new URLSearchParams({
  code_challenge: codeVerifier,         // verifier sent in the clear
  code_challenge_method: 'plain'        // no hashing
});

Correct: Using S256 code challenge method

// GOOD -- S256 hashes the verifier so intercepting the challenge is useless
const challenge = await generateCodeChallenge(codeVerifier);
const params = new URLSearchParams({
  code_challenge: challenge,            // SHA-256 hash, not the verifier
  code_challenge_method: 'S256'         // always S256
});

Wrong: Using Math.random() for code_verifier

// BAD -- Math.random() is not cryptographically secure
// Attackers can predict the sequence
const verifier = Array.from({length: 43}, () =>
  'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'
  [Math.floor(Math.random() * 62)]
).join('');

Correct: Using crypto.getRandomValues()

// GOOD -- cryptographically secure random bytes
const bytes = crypto.getRandomValues(new Uint8Array(32));
const verifier = btoa(String.fromCharCode(...bytes))
  .replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');

Wrong: Storing tokens in localStorage

// BAD -- localStorage is accessible to any JS on the page (XSS attack vector)
localStorage.setItem('access_token', tokens.access_token);
localStorage.setItem('refresh_token', tokens.refresh_token);

Correct: Storing tokens in memory or httpOnly cookies

// GOOD -- in-memory storage, cleared on page close
// For persistence, use httpOnly secure cookies set by backend
let tokenStore = { accessToken: null, refreshToken: null };
tokenStore.accessToken = tokens.access_token;
// Or: backend sets httpOnly cookie with SameSite=Strict

Wrong: Omitting state parameter

// BAD -- no state parameter means no CSRF protection
const params = new URLSearchParams({
  response_type: 'code',
  client_id: CLIENT_ID,
  redirect_uri: REDIRECT_URI,
  code_challenge: challenge,
  code_challenge_method: 'S256'
  // missing: state parameter
});

Correct: Including cryptographic state parameter

// GOOD -- state parameter prevents CSRF attacks
const state = crypto.randomUUID();
sessionStorage.setItem('oauth_state', state);
const params = new URLSearchParams({
  response_type: 'code',
  client_id: CLIENT_ID,
  redirect_uri: REDIRECT_URI,
  code_challenge: challenge,
  code_challenge_method: 'S256',
  state: state  // verify this on callback
});

Common Pitfalls

Diagnostic Commands

# Test code_verifier generation (Node.js)
node -e "console.log(require('crypto').randomBytes(32).toString('base64url'))"

# Verify S256 challenge computation
echo -n "YOUR_CODE_VERIFIER" | openssl dgst -sha256 -binary | openssl base64 -A | tr '+/' '-_' | tr -d '='

# Test token endpoint manually with curl
curl -X POST https://auth.example.com/oauth/token \
  -H "Content-Type: application/x-www-form-urlencoded" \
  -d "grant_type=authorization_code" \
  -d "client_id=YOUR_CLIENT_ID" \
  -d "code=AUTHORIZATION_CODE" \
  -d "code_verifier=YOUR_CODE_VERIFIER" \
  -d "redirect_uri=https://app.example.com/callback"

# Decode a JWT access token to inspect claims
echo "ACCESS_TOKEN" | cut -d. -f2 | base64 -d 2>/dev/null | python3 -m json.tool

# Check if authorization server supports PKCE (OpenID Discovery)
curl -s https://auth.example.com/.well-known/openid-configuration | \
  python3 -c "import sys,json; d=json.load(sys.stdin); print('PKCE methods:', d.get('code_challenge_methods_supported', 'not advertised'))"

Version History & Compatibility

VersionStatusBreaking ChangesMigration Notes
OAuth 2.1 (draft-14, 2024)Draft / De facto standardPKCE mandatory for ALL authorization code flows; Implicit flow removedAdd code_challenge + code_verifier to all existing auth code flows
RFC 9700 (2024)BCP (Best Current Practice)Recommends PKCE for all clients including confidentialAdd PKCE even if you have a client_secret
RFC 7636 (2015)StandardOriginal PKCE specificationBaseline implementation; supports S256 and plain methods
OAuth 2.0 (RFC 6749, 2012)StandardPKCE not part of core specPKCE was an extension; Implicit flow was the recommended SPA approach

When to Use / When Not to Use

Use WhenDon't Use WhenUse Instead
Building a SPA (React, Vue, Angular)Server-to-server API calls (no user)Client Credentials flow (RFC 6749 sec 4.4)
Building a mobile app (iOS, Android)Device with no browser/keyboardDevice Authorization flow (RFC 8628)
Building a desktop app (Electron)You only need user identity, not API accessOpenID Connect (but OIDC uses PKCE under the hood)
Building a server-side web app (OAuth 2.1 recommends it)Issuing your own tokens (you are the auth server)Build token endpoint with proper PKCE verification
Any new OAuth2 implementation (future-proofing for 2.1)Simple API key authentication sufficesAPI keys with rate limiting

Important Caveats

Related Units