Skip to main content

Documentation Index

Fetch the complete documentation index at: https://mintlify.com/workos/workos-node/llms.txt

Use this file to discover all available pages before exploring further.

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 };
};