Skip to content

Conversation

@nicknisi
Copy link
Member

@nicknisi nicknisi commented Jan 5, 2026

Description

Enable PKCE authentication for both public and confidential clients.

Changes

  • API key now optional: Initialize with just clientId for PKCE mode: new WorkOS({ clientId: 'client_...' })
  • New helper method: getAuthorizationUrlWithPKCE() - generates PKCE internally, returns { url, state, codeVerifier }
  • Enhanced exchange: authenticateWithCode() auto-detects client mode based on available credentials
  • Manual PKCE option: workos.pkce.generate() + getAuthorizationUrl() for advanced use cases
  • Non-breaking: Existing getAuthorizationUrl() unchanged, still returns URL string

PKCE with Confidential Clients (OAuth 2.1 Best Practice)

Server-side apps can use PKCE alongside the client secret for defense in depth:

const workos = new WorkOS('sk_...'); // With API key

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_...',
});

The auto-detection logic:

API Key codeVerifier Behavior
Send both client_secret AND code_verifier (confidential + PKCE)
Send client_secret only (traditional confidential client)
Send code_verifier only (public client)
Error

Removed: @workos-inc/node/client export

The separate /client entry point has been removed. Instead of:

// Old approach - standalone functions
import { userManagement } from '@workos-inc/node/client';
const url = userManagement.getAuthorizationUrl({ ... });

Use the standard SDK without an API key:

// New approach - consistent with rest of SDK
import { WorkOS } from '@workos-inc/node';
const workos = new WorkOS({ clientId: 'client_...' });
const url = workos.userManagement.getAuthorizationUrl({ ... });

This provides a single, consistent API surface rather than two parallel approaches.

Public Client Usage

import { WorkOS } from '@workos-inc/node';

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

// Step 1: Get authorization URL with auto-generated PKCE
const { url, state, codeVerifier } = await workos.userManagement.getAuthorizationUrlWithPKCE({
  redirectUri: 'myapp://callback',
  provider: 'authkit',
});

// Store codeVerifier securely, then redirect user to url

// Step 2: Exchange code for tokens
const { accessToken, refreshToken, user } = await workos.userManagement.authenticateWithCode({
  code: authCode,
  codeVerifier,
});

Methods Available Without API Key

Method Description
userManagement.getAuthorizationUrlWithPKCE() Build OAuth URL with auto-generated PKCE
userManagement.getAuthorizationUrl() Build OAuth URL (with manual PKCE params)
userManagement.authenticateWithCode() Exchange code + verifier for tokens
userManagement.authenticateWithCodeAndVerifier() Exchange code + verifier for tokens (explicit)
userManagement.authenticateWithRefreshToken() Refresh tokens
userManagement.getLogoutUrl() Build logout redirect URL
userManagement.getJwksUrl() Get JWKS URL for local JWT validation
workos.pkce.generate() Generate PKCE code verifier and challenge

Enable public clients (Electron, mobile, CLI) to authenticate using PKCE
without requiring an API key.

- Make API key optional when clientId is provided
- Add PKCE class with RFC 7636 compliant utilities (generateCodeVerifier,
  generateCodeChallenge, generate)
- Add ApiKeyRequiredException for methods requiring API key
- Support constructor overloading: new WorkOS({ clientId }) for PKCE mode
- Export PKCE utilities from all entry points
Guards all API calls by default, throwing ApiKeyRequiredException
when no API key is provided. PKCE-safe methods bypass with
skipApiKeyCheck option.
- Remove ./client package export from package.json
- Inline getAuthorizationUrl, getLogoutUrl, getJwksUrl into services
- Delete src/client/ directory
- Add LogoutURLOptions interface to user-management
Add authenticateWithRefreshTokenPKCE() for public clients (browser/mobile)
to refresh tokens without requiring a client secret.

- New interface: AuthenticateWithRefreshTokenPKCEOptions
- New serializer that omits client_secret from request
- Method uses skipApiKeyCheck to work without API key
- Completes PKCE flow: auth → token exchange → refresh indefinitely
- Remove separate authenticateWithRefreshTokenPKCE method
- authenticateWithRefreshToken now auto-detects public client mode
- Rename "PKCE mode" to "public client mode" in tests/comments
- PKCE is just the security mechanism, not the mode itself
- Renamed SerializedAuthenticateWithPKCEBase → SerializedAuthenticatePublicClientBase
- Renamed *-pkce-* files to *-public-client-* for accuracy
- Improved ApiKeyRequiredException error message with actionable guidance
- Document authenticateWithCodeAndVerifier() for PKCE flow
- Document getAuthorizationUrl() PKCE parameters
- Add Public Client Mode section to README
Public clients cannot securely store a cookiePassword, so session
sealing is not appropriate. Throw a clear error directing users to
store tokens directly (secure storage, keychain, etc).
Remove verbose comments that explained obvious code. Keeps JSDoc
and comments that explain non-obvious dependencies.
- Eliminate modulo bias in PKCE code verifier generation using rejection
  sampling (threshold 198 for uniform distribution across 66 chars)
- Rethrow non-JWT errors in isValidJwt() instead of swallowing all exceptions
  (only treat jose ERR_JWT_* and ERR_JWS_* errors as invalid JWT)
- Update tests to mock jose-like errors with proper code property
- Add test verifying network errors are properly propagated
Replace custom rejection sampling with base64url encoding of random bytes.
This matches RFC 7636 Appendix B and is the standard approach used by
most OAuth libraries. Base64url has no bias since 64 divides evenly into 256.
- Add requireApiKey() to getProfileAndToken() to fail fast in public
  client mode instead of passing undefined to the API
- Remove try-catch around unsealData() in session.ts - known seal errors
  already return {} (handled by accessToken check), unknown errors should
  propagate instead of being masked as "invalid session"
Add a convenience method that generates PKCE internally and returns
the codeVerifier for app-controlled storage. This enables a simplified
2-step auth flow for public clients (CLI, Electron, React Native).

- Add getAuthorizationUrlWithPKCE() async method
- Add PKCEAuthorizationURLResult interface
- Add authenticateWithCode() with auto-detect for codeVerifier
- Keep existing getAuthorizationUrl() unchanged (non-breaking)
- Replace snapshot tests with toContain assertions
Add PKCE (Proof Key for Code Exchange) support to SSO module for
public clients like Electron apps, mobile apps, and CLI tools that
cannot securely store a client secret.

Changes:
- Add codeChallenge/codeChallengeMethod to SSOAuthorizationURLOptions
- Add getAuthorizationUrlWithPKCE() helper that auto-generates PKCE
- Add codeVerifier option to getProfileAndToken() for token exchange
- Auto-detect public vs confidential client mode based on codeVerifier
- Add empty codeVerifier validation to prevent silent bypass of PKCE flow
- Add PKCE field coupling types (codeChallenge + codeChallengeMethod must be set together)
- Standardize error types to TypeError for argument validation
- Add tests for empty/whitespace codeVerifier edge cases
@nicknisi nicknisi marked this pull request as ready for review January 6, 2026 19:32
@nicknisi nicknisi requested a review from a team as a code owner January 6, 2026 19:32
@nicknisi nicknisi requested review from faroceann and removed request for a team January 6, 2026 19:32
@nicknisi nicknisi changed the title [v8] feat: add PKCE support for public clients [v8] feat: add PKCE support for public clients; remove /client entry point Jan 6, 2026
@nicknisi
Copy link
Member Author

nicknisi commented Jan 6, 2026

@greptileai

@greptile-apps
Copy link
Contributor

greptile-apps bot commented Jan 6, 2026

Greptile Summary

This PR enables public client authentication using PKCE (Proof Key for Code Exchange) for applications that cannot securely store secrets, while removing the separate /client entry point to consolidate the API surface.

Key Changes

  • PKCE Implementation: New PKCE class in src/pkce/pkce.ts implements RFC 7636 compliant code verifier generation and SHA-256 challenge creation using Web Crypto API
  • Optional API Key: WorkOS constructor now accepts { clientId } for public client mode, making API key optional for specific authentication flows
  • Auto-Detection: authenticateWithCode() and getProfileAndToken() automatically detect client mode based on presence of codeVerifier parameter
  • New Helper Methods: getAuthorizationUrlWithPKCE() in both UserManagement and SSO modules generates PKCE parameters internally and returns { url, state, codeVerifier }
  • Consolidated API: Removed @workos-inc/node/client export - all functionality now available through main SDK initialized without API key
  • Proper Error Handling: New ApiKeyRequiredException provides clear guidance when API key is required for specific operations

Security Considerations

  • PKCE implementation correctly uses cryptographically secure random values via crypto.getRandomValues()
  • Code verifier length properly validated (43-128 characters per RFC 7636)
  • Base64URL encoding correctly strips padding and uses URL-safe characters
  • Empty/whitespace codeVerifier validation prevents common mistakes
  • Session sealing correctly blocked for public clients (requires API key)
  • No sensitive tokens logged in console output

Backward Compatibility

  • Existing getAuthorizationUrl() unchanged - still returns URL string
  • Confidential client flows (with API key) continue to work identically
  • Breaking change: /client entry point removed, but migration path is straightforward (use main SDK without API key)
  • All existing API key-based workflows remain fully functional

Confidence Score: 5/5

  • This PR is safe to merge with comprehensive test coverage and secure PKCE implementation
  • The implementation follows OAuth 2.0 RFC 7636 specification correctly, includes extensive test coverage for both public and confidential client modes, properly validates inputs, uses secure crypto primitives, and maintains backward compatibility for existing flows. No security vulnerabilities detected.
  • No files require special attention

Important Files Changed

Filename Overview
src/pkce/pkce.ts New PKCE implementation using Web Crypto API with proper RFC 7636 compliance and validation
src/workos.ts Modified to support optional API key initialization, public client mode with clientId-only, and new PKCE property
src/user-management/user-management.ts Added PKCE support with new getAuthorizationUrlWithPKCE method, enhanced authenticateWithCode to auto-detect client mode, proper validation
src/sso/sso.ts Added getAuthorizationUrlWithPKCE and enhanced getProfileAndToken with PKCE support and proper client mode detection
src/common/exceptions/api-key-required.exception.ts New exception class for methods requiring API key with helpful error message guiding users to correct approach
package.json Removed /client export from package exports, cleaned up unnecessary entry point

Sequence Diagram

sequenceDiagram
    participant Client as Public Client App
    participant SDK as WorkOS SDK
    participant Crypto as Web Crypto API
    participant API as WorkOS API
    participant User as User/Browser

    Note over Client,API: PKCE Authorization Flow (Public Client Mode)
    
    Client->>SDK: new WorkOS({ clientId: 'client_...' })
    Note over SDK: No API key required
    
    Client->>SDK: getAuthorizationUrlWithPKCE(options)
    SDK->>Crypto: generateCodeVerifier()
    Crypto-->>SDK: codeVerifier (43-128 chars)
    SDK->>Crypto: SHA-256(codeVerifier)
    Crypto-->>SDK: codeChallenge (base64url)
    SDK-->>Client: { url, state, codeVerifier }
    
    Client->>Client: Store codeVerifier securely
    Client->>User: Redirect to authorization URL
    User->>API: Authenticate
    API-->>User: Redirect with code
    User-->>Client: Return with code & state
    
    Client->>SDK: authenticateWithCode({ code, codeVerifier })
    Note over SDK: Detects public client mode<br/>(codeVerifier present)
    SDK->>API: POST /authenticate<br/>{ code, code_verifier, client_id }
    Note over SDK,API: No client_secret sent
    API->>API: Verify code_verifier matches<br/>code_challenge from PKCE
    API-->>SDK: { accessToken, refreshToken, user }
    SDK-->>Client: Authentication response
    
    Note over Client,API: Token Refresh Flow
    
    Client->>SDK: authenticateWithRefreshToken({ refreshToken })
    Note over SDK: Detects public client mode<br/>(no API key)
    SDK->>API: POST /authenticate<br/>{ refresh_token, client_id }
    Note over SDK,API: No client_secret sent
    API-->>SDK: { accessToken, refreshToken }
    SDK-->>Client: New tokens
Loading

Copy link
Contributor

@greptile-apps greptile-apps bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

37 files reviewed, no comments

Edit Code Review Agent Settings | Greptile

OAuth 2.1 recommends PKCE for ALL clients, not just public ones.
This change allows confidential clients (with API key) to also use
PKCE for additional security and CSRF protection.

Changes:
- authenticateWithCode() now sends both client_secret AND code_verifier
  when both API key and codeVerifier are present
- Same behavior for SSO getProfileAndToken()
- Added tests for "confidential client with PKCE" scenario
- Updated README with example for server-side PKCE usage

The auto-detection logic now:
- Has API key + has codeVerifier = send both (confidential + PKCE)
- Has API key + no codeVerifier = send client_secret only (traditional)
- No API key + has codeVerifier = send codeVerifier only (public client)
- No API key + no codeVerifier = error
*
* @example
* // PKCE/public client (no API key)
* const workos = new WorkOS({ clientId: 'client_...' });
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i wonder if we could keep the API key mode around for backwards compat, but prefer/encourage the object mode going forward. i'm imagining that even confidential clients would initialize with:

const workos = new WorkOS({
  client_id: 'client_1234',
  client_secret: 'sssshhh'
});

It feels strange to me to initialize differently based on how you want to authenticate.

I would also really love it if the authkit APIs could use the clientId specified here for all authenticate calls instead of awkwardly requiring the user to specify them as arguments. (in authenticateWithPassword/getJwksUrl/etc.). (we don't necessarily need to tackle that right now though).

throw new NoApiKeyProvidedException();
}
if (!this.key) {
this.key = getEnv('WORKOS_API_KEY');
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i kind of think it would be lovely if you could specify your WORKOS_CLIENT_ID via env var too.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(i really wish client_id was required but i also see the value of having it be optional to preserve backwards compatibility).

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

also wondering if we could only attach attach the stuff that makes sense for how this was initialized (e.g. if you don't specify a client_secret, we don't attach all the CRUD objects that you currently will get a run-time error (no key) for when calling). i think this would make the typescript experience pretty awesome.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i kind of think it would be lovely if you could specify your WORKOS_CLIENT_ID via env var too.

This is already supported.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

also wondering if we could only attach attach the stuff that makes sense for how this was initialized (e.g. if you don't specify a client_secret, we don't attach all the CRUD objects that you currently will get a run-time error (no key) for when calling). i think this would make the typescript experience pretty awesome.

I don't disagree here and shied away from it because it'd be a pretty big breaking change. Constructors can't be overloaded in TS I don't think, so the best way to apply this would probably be with a factory function. That's something we could look at adding later, but for now users will get a runtime error if using a method that requires an API Key when none is provided.

README.md Outdated
const { url, codeVerifier } = await workos.userManagement.getAuthorizationUrlWithPKCE({
provider: 'authkit',
redirectUri: 'https://example.com/callback',
clientId: 'client_...',
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is the redundant clientId stuff that i wish we could omit given we already specified this when creating the client.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

clientId is now optional in all method parameters. If not provided, it falls back to the value from the constructor (new WorkOS('sk_...', { clientId: 'client_123' })).

Changes:

  • Added resolveClientId() helper that throws a clear error if clientId isn't available from either source
  • Updated all 10 affected methods (8 authenticate methods + 2 authorization URL methods)
  • Added WithResolvedClientId utility type for type-safe serializers (no ! assertions)
  • Added tests for fallback behavior

Usage:

const workos = new WorkOS('sk_...', { clientId: 'client_123' });

// clientId now optional - falls back to constructor value
await workos.userManagement.authenticateWithCode({ code });

README.md Outdated
const workos = new WorkOS({ clientId: 'client_...' }); // No API key needed

// Generate auth URL with automatic PKCE
const { url, codeVerifier } = await workos.userManagement.getAuthorizationUrlWithPKCE({
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should we mention that the codeVerifier should be stored on-device (cookie, phone storage, etc.) for later? (it's somewhat clear since you need it for the authenticateWithCode call, but maybe not obvious to someone who has never done this before?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added the following to the README:

Important

Store codeVerifier securely on-device between generating the auth URL and handling the callback. For mobile apps, use platform secure storage (iOS Keychain, Android Keystore). For CLI apps, consider OS credential storage. The verifier must survive app restarts during the auth flow.

Support Headers instance, string[][] tuples, and plain objects
as valid header inputs per the RequestInit type contract.
Allows `new WorkOS({ apiKey: 'sk_...', clientId: '...' })` as an
alternative to `new WorkOS('sk_...', { clientId: '...' })`.
Allow clientId to be omitted from method calls when it was provided
during WorkOS initialization. This reduces redundancy for developers
who configure clientId once at startup.

- Add resolveClientId() helper that falls back to constructor value
- Update 10 methods to use extract-resolve-spread pattern
- Add WithResolvedClientId<T> utility type for type-safe serializers
- Add tests for fallback behavior and missing clientId error
Warns developers to store codeVerifier securely on-device between
auth URL generation and callback handling. Mentions platform-specific
options (iOS Keychain, Android Keystore, OS credential storage).
Copy link
Contributor

@cmatheson cmatheson left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎉

@nicknisi nicknisi merged commit dddca9a into version-8 Jan 7, 2026
8 checks passed
@nicknisi nicknisi deleted the nicknisi/pkce-support branch January 7, 2026 21:59
nicknisi added a commit that referenced this pull request Jan 8, 2026
…point (#1435)

Enable PKCE authentication for both public and confidential clients.

- **API key now optional**: Initialize with just `clientId` for PKCE
mode: `new WorkOS({ clientId: 'client_...' })`
- **New helper method**: `getAuthorizationUrlWithPKCE()` - generates
PKCE internally, returns `{ url, state, codeVerifier }`
- **Enhanced exchange**: `authenticateWithCode()` auto-detects client
mode based on available credentials
- **Manual PKCE option**: `workos.pkce.generate()` +
`getAuthorizationUrl()` for advanced use cases
- **Non-breaking**: Existing `getAuthorizationUrl()` unchanged, still
returns URL string

Server-side apps can use PKCE alongside the client secret for defense in
depth:

```ts
const workos = new WorkOS('sk_...'); // With API key

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_...',
});
```

The auto-detection logic:
| API Key | codeVerifier | Behavior |
|---------|--------------|----------|
| ✅ | ✅ | Send both `client_secret` AND `code_verifier` (confidential +
PKCE) |
| ✅ | ❌ | Send `client_secret` only (traditional confidential client) |
| ❌ | ✅ | Send `code_verifier` only (public client) |
| ❌ | ❌ | Error |

The separate `/client` entry point has been removed. Instead of:

```ts
// Old approach - standalone functions
import { userManagement } from '@workos-inc/node/client';
const url = userManagement.getAuthorizationUrl({ ... });
```

Use the standard SDK without an API key:

```ts
// New approach - consistent with rest of SDK
import { WorkOS } from '@workos-inc/node';
const workos = new WorkOS({ clientId: 'client_...' });
const url = workos.userManagement.getAuthorizationUrl({ ... });
```

This provides a single, consistent API surface rather than two parallel
approaches.

```ts
import { WorkOS } from '@workos-inc/node';

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

// Step 1: Get authorization URL with auto-generated PKCE
const { url, state, codeVerifier } = await workos.userManagement.getAuthorizationUrlWithPKCE({
  redirectUri: 'myapp://callback',
  provider: 'authkit',
});

// Store codeVerifier securely, then redirect user to url

// Step 2: Exchange code for tokens
const { accessToken, refreshToken, user } = await workos.userManagement.authenticateWithCode({
  code: authCode,
  codeVerifier,
});
```

| Method | Description |
|--------|-------------|
| `userManagement.getAuthorizationUrlWithPKCE()` | Build OAuth URL with
auto-generated PKCE |
| `userManagement.getAuthorizationUrl()` | Build OAuth URL (with manual
PKCE params) |
| `userManagement.authenticateWithCode()` | Exchange code + verifier for
tokens |
| `userManagement.authenticateWithCodeAndVerifier()` | Exchange code +
verifier for tokens (explicit) |
| `userManagement.authenticateWithRefreshToken()` | Refresh tokens |
| `userManagement.getLogoutUrl()` | Build logout redirect URL |
| `userManagement.getJwksUrl()` | Get JWKS URL for local JWT validation
|
| `workos.pkce.generate()` | Generate PKCE code verifier and challenge |
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

3 participants