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:
iOS (Swift)
Android (Kotlin)
Electron
CLI
// 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 };
};