Skip to main content
PKCE (Proof Key for Code Exchange) is an OAuth 2.0 security extension that enables public clients to securely authenticate without a client secret. This guide explains how to implement PKCE authentication using the WorkOS Node.js SDK.

What is PKCE?

PKCE implements RFC 7636 for secure authorization code exchange without a client secret. It’s essential for:
  • Electron apps - Desktop applications
  • React Native/mobile apps - iOS and Android applications
  • CLI tools - Command-line interfaces
  • Single-page applications - Browser-based apps
  • Any public client - Applications that cannot securely store secrets
From pkce.ts:7:
/**
 * PKCE (Proof Key for Code Exchange) utilities for OAuth 2.0 public clients.
 *
 * Implements RFC 7636 for secure authorization code exchange without a client secret.
 * Used by Electron apps, React Native/mobile apps, CLI tools, and other public clients.
 */
export class PKCE { ... }

PKCE components

A PKCE flow involves three components:

Code verifier

A cryptographically random string between 43-128 characters:
const workos = new WorkOS({ clientId: 'client_...' });
const verifier = workos.pkce.generateCodeVerifier(43); // Default length
From pkce.ts:20:
/**
 * Generate a cryptographically random code verifier.
 *
 * @param length - Length of verifier (43-128, default 43)
 * @returns RFC 7636 compliant code verifier
 */
generateCodeVerifier(length: number = 43): string {
  if (length < 43 || length > 128) {
    throw new RangeError(
      `Code verifier length must be between 43 and 128, got ${length}`,
    );
  }

  const byteLength = Math.ceil((length * 3) / 4);
  const randomBytes = new Uint8Array(byteLength);
  crypto.getRandomValues(randomBytes);

  return this.base64UrlEncode(randomBytes).slice(0, length);
}

Code challenge

A Base64URL-encoded SHA256 hash of the code verifier:
const challenge = await workos.pkce.generateCodeChallenge(verifier);
From pkce.ts:34:
/**
 * Generate S256 code challenge from a verifier.
 *
 * @param verifier - The code verifier
 * @returns Base64URL-encoded SHA256 hash
 */
async generateCodeChallenge(verifier: string): Promise<string> {
  const encoder = new TextEncoder();
  const data = encoder.encode(verifier);
  const hash = await crypto.subtle.digest('SHA-256', data);
  return this.base64UrlEncode(new Uint8Array(hash));
}

Code challenge method

Always 'S256' (SHA256), which is the only method supported by the SDK.

Generating PKCE parameters

The SDK provides a convenience method to generate all PKCE parameters at once:
const workos = new WorkOS({ clientId: 'client_...' });
const pkce = await workos.pkce.generate();

console.log(pkce);
// {
//   codeVerifier: 'randomly-generated-verifier-string',
//   codeChallenge: 'base64url-encoded-sha256-hash',
//   codeChallengeMethod: 'S256'
// }
From pkce.ts:47:
/**
 * Generate a complete PKCE pair (verifier + challenge).
 *
 * @returns Code verifier, challenge, and method ('S256')
 */
async generate(): Promise<PKCEPair> {
  const codeVerifier = this.generateCodeVerifier();
  const codeChallenge = await this.generateCodeChallenge(codeVerifier);
  return { codeVerifier, codeChallenge, codeChallengeMethod: 'S256' };
}

Complete PKCE flow

Step 1: Initialize the client

Initialize WorkOS without an API key:
import { WorkOS } from '@workos-inc/node';

const workos = new WorkOS({ clientId: 'client_...' });

Step 2: Generate authorization URL

Use getAuthorizationUrlWithPKCE() to automatically generate PKCE parameters:
const { url, state, codeVerifier } = 
  await workos.userManagement.getAuthorizationUrlWithPKCE({
    provider: 'authkit',
    redirectUri: 'myapp://callback',
    clientId: 'client_...',
  });

console.log('Authorization URL:', url);
console.log('State:', state);
console.log('Code Verifier:', codeVerifier);
From user-management.ts:1179:
/**
 * Generate an OAuth 2.0 authorization URL with automatic PKCE.
 *
 * This method generates PKCE parameters internally and returns them along with
 * the authorization URL. Use this for public clients (CLI apps, Electron, mobile)
 * that cannot securely store a client secret.
 *
 * @returns Object containing url, state, and codeVerifier
 */
async getAuthorizationUrlWithPKCE(
  options: Omit<
    UserManagementAuthorizationURLOptions,
    'codeChallenge' | 'codeChallengeMethod' | 'state'
  >,
): Promise<PKCEAuthorizationURLResult> {
  // ... implementation
  
  // Generate PKCE parameters
  const pkce = await this.workos.pkce.generate();
  
  // Generate secure random state
  const state = this.workos.pkce.generateCodeVerifier(43);
  
  // ... build URL with pkce.codeChallenge
  
  return { url, state, codeVerifier: pkce.codeVerifier };
}

Step 3: Store parameters securely

Critical: Store codeVerifier and state securely on-device. These values must survive app restarts during the authentication flow.
For different platforms:
// Use iOS Keychain
import Security

// Store code verifier
let keychain = Keychain(service: "com.myapp.auth")
keychain["code_verifier"] = codeVerifier

Step 4: Redirect to authorization URL

Open the authorization URL in the user’s browser:
// For web apps
window.location.href = url;

// For Electron apps
const { shell } = require('electron');
shell.openExternal(url);

// For CLI apps
import open from 'open';
open(url);

Step 5: Handle callback

Capture the authorization code from the callback URL:
// Example callback URL: myapp://callback?code=AUTH_CODE&state=STATE_VALUE

const urlParams = new URLSearchParams(callbackUrl.split('?')[1]);
const code = urlParams.get('code');
const returnedState = urlParams.get('state');

// Verify state matches
if (returnedState !== storedState) {
  throw new Error('State mismatch - possible CSRF attack');
}

Step 6: Exchange code for tokens

Retrieve the stored codeVerifier and exchange the authorization code:
const { accessToken, refreshToken } = 
  await workos.userManagement.authenticateWithCode({
    code: code,
    codeVerifier: storedCodeVerifier,
    clientId: 'client_...',
  });

// Store tokens securely
// Clear code verifier (one-time use)
From user-management.ts:331:
/**
 * Exchange an authorization code for tokens.
 *
 * Auto-detects public vs confidential client mode:
 * - If codeVerifier is provided: Uses PKCE flow (public client)
 * - If no codeVerifier: Uses client_secret from API key (confidential client)
 * - If both: Uses both client_secret AND codeVerifier (confidential client with PKCE)
 */
async authenticateWithCode(
  payload: AuthenticateWithCodeOptions,
): Promise<AuthenticationResponse> {
  // ... implementation validates codeVerifier and exchanges code
}

Manual PKCE implementation

For advanced use cases, you can manually generate PKCE parameters:
const workos = new WorkOS({ clientId: 'client_...' });

// Step 1: Generate PKCE parameters manually
const pkce = await workos.pkce.generate();

// Step 2: Build authorization URL with PKCE
const url = workos.userManagement.getAuthorizationUrl({
  provider: 'authkit',
  redirectUri: 'myapp://callback',
  clientId: 'client_...',
  codeChallenge: pkce.codeChallenge,
  codeChallengeMethod: pkce.codeChallengeMethod,
  state: 'your-custom-state',
});

// Step 3: Store pkce.codeVerifier securely
// Step 4: Redirect user to url
// Step 5: Exchange code with codeVerifier

PKCE with confidential clients

Server-side applications can also use PKCE alongside the client secret for defense in depth (recommended by OAuth 2.1):
// Initialize with API key
const workos = new WorkOS('sk_...');

// Generate authorization URL with PKCE
const { url, codeVerifier } =
  await workos.userManagement.getAuthorizationUrlWithPKCE({
    provider: 'authkit',
    redirectUri: 'https://example.com/callback',
    clientId: 'client_...',
  });

// Both client_secret AND code_verifier will be sent
const { accessToken } = await workos.userManagement.authenticateWithCode({
  code: authorizationCode,
  codeVerifier,
  clientId: 'client_...',
});

Error handling

Common PKCE errors and how to handle them:

Empty code verifier

try {
  await workos.userManagement.authenticateWithCode({
    code: 'auth_code',
    codeVerifier: '', // Empty string
    clientId: 'client_...',
  });
} catch (error) {
  console.error(error.message);
  // "codeVerifier cannot be an empty string.
  // Generate a valid PKCE pair using workos.pkce.generate()."
}

Invalid verifier length

try {
  workos.pkce.generateCodeVerifier(30); // Too short
} catch (error) {
  console.error(error.message);
  // "Code verifier length must be between 43 and 128, got 30"
}

Missing credentials

try {
  const workos = new WorkOS({ clientId: 'client_...' });
  await workos.userManagement.authenticateWithCode({
    code: 'auth_code',
    // Missing codeVerifier AND no API key
    clientId: 'client_...',
  });
} catch (error) {
  console.error(error.message);
  // "authenticateWithCode requires either a codeVerifier (for public clients)
  // or an API key (for confidential clients)"
}

Best practices

Secure storage

Always store code verifiers in platform-specific secure storage (Keychain, Keystore, etc.).

Validate state

Always validate the state parameter to prevent CSRF attacks.

One-time use

Clear the code verifier after successful token exchange. It’s single-use only.

Use getAuthorizationUrlWithPKCE

Use the convenience method instead of manually generating PKCE parameters.

Complete example

Here’s a complete PKCE flow implementation:
import { WorkOS } from '@workos-inc/node';

const workos = new WorkOS({ clientId: 'client_...' });

// Step 1: Generate authorization URL
const { url, state, codeVerifier } = 
  await workos.userManagement.getAuthorizationUrlWithPKCE({
    provider: 'authkit',
    redirectUri: 'myapp://callback',
    clientId: 'client_...',
  });

// Step 2: Store parameters securely
await secureStorage.set('code_verifier', codeVerifier);
await secureStorage.set('state', state);

// Step 3: Open authorization URL
await openBrowser(url);

// Step 4: Handle callback (this happens later)
const handleCallback = async (callbackUrl: string) => {
  const urlParams = new URLSearchParams(callbackUrl.split('?')[1]);
  const code = urlParams.get('code');
  const returnedState = urlParams.get('state');

  // Verify state
  const storedState = await secureStorage.get('state');
  if (returnedState !== storedState) {
    throw new Error('State mismatch');
  }

  // Exchange code for tokens
  const storedCodeVerifier = await secureStorage.get('code_verifier');
  const { accessToken, refreshToken, user } = 
    await workos.userManagement.authenticateWithCode({
      code,
      codeVerifier: storedCodeVerifier,
      clientId: 'client_...',
    });

  // Store tokens and clear one-time values
  await secureStorage.set('access_token', accessToken);
  await secureStorage.set('refresh_token', refreshToken);
  await secureStorage.delete('code_verifier');
  await secureStorage.delete('state');

  return { user, accessToken };
};

Build docs developers (and LLMs) love