Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
03e72f1
docs: spec for per-flow PKCE verifier cookies
nicknisi Apr 22, 2026
4db1238
docs: address codex review feedback on PKCE design spec
nicknisi Apr 22, 2026
85e06ba
docs: address codex second-pass review on PKCE spec
nicknisi Apr 22, 2026
32c75a3
docs: implementation plan for per-flow PKCE verifier cookies
nicknisi Apr 22, 2026
4ae73c3
feat: add per-flow PKCE cookie name derivation
nicknisi Apr 22, 2026
d248daa
feat(pkce): derive cookieName in generateAuthorizationUrl
nicknisi Apr 22, 2026
73ea24c
chore: drop unused PKCE_COOKIE_NAME import; hoist test import
nicknisi Apr 22, 2026
04236ae
feat(types): add cookieName to CreateAuthorizationResult
nicknisi Apr 22, 2026
7712452
feat(service): write PKCE cookie under per-flow derived name
nicknisi Apr 22, 2026
1a8a69c
feat(service): derive PKCE cookie name from URL state in callback
nicknisi Apr 22, 2026
abf45f2
feat(service): add pure URL-generation methods
nicknisi Apr 22, 2026
afc6144
feat(service)!: clearPendingVerifier requires state
nicknisi Apr 22, 2026
e46e4cc
feat: export PKCE helpers from package root
nicknisi Apr 22, 2026
8b59382
docs: document per-flow PKCE cookies and clearPendingVerifier break
nicknisi Apr 22, 2026
c767265
chore: release 0.5.0
nicknisi Apr 22, 2026
57ca491
fix(factory): forward pure URL helpers through lazy proxy
nicknisi Apr 23, 2026
a8abf0a
chore: formatting
nicknisi Apr 23, 2026
dd4e91b
chore: remove superpowers plans
nicknisi Apr 23, 2026
eefa682
chore: formatting:
nicknisi Apr 23, 2026
ae3b78d
revert: drop pure URL-generation methods
nicknisi Apr 23, 2026
5659675
chore: drop PKCE_COOKIE_NAME and GetAuthorizationUrlResult
nicknisi Apr 23, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
75 changes: 65 additions & 10 deletions MIGRATION.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,37 @@
# Migration Guide

## 0.5.0 — per-flow PKCE cookies

PKCE verifier cookies now carry a per-flow suffix
(`wos-auth-verifier-<fnv1a>`) so concurrent sign-ins from multiple
tabs no longer clobber each other. `clearPendingVerifier` now
**requires** `options.state`.

### What consumers need to change

| Before | After |
| ------------------------------------------------------ | ------------------------------------------------------------- |
| `auth.clearPendingVerifier(response)` | `auth.clearPendingVerifier(response, { state })` |
| `auth.clearPendingVerifier(response, { redirectUri })` | `auth.clearPendingVerifier(response, { state, redirectUri })` |

Guard the call on `state` presence:

```ts
if (state) {
await auth.clearPendingVerifier(response, { state });
}
```

Skip the call entirely when `state` is absent (malformed callback) —
the 10-minute PKCE TTL cleans up orphan cookies.

### Removed exports

- `PKCE_COOKIE_NAME` is gone. The wire cookie is now per-flow (`wos-auth-verifier-<fnv1a>`), so a single static name no longer identifies anything. Use `PKCE_COOKIE_PREFIX` if you need the stable prefix, or `getPKCECookieNameForState(state)` to derive the per-flow name.
- `GetAuthorizationUrlResult` type is gone. The fields are inlined into `CreateAuthorizationResult`, which is what `createAuthorization` / `createSignIn` / `createSignUp` return.

---

## 0.3.x → 0.4.0

0.4.0 introduces OAuth state binding via a PKCE verifier cookie and collapses
Expand All @@ -24,7 +56,7 @@ adapter version.
| No verifier cookie on the wire | New `wos-auth-verifier` cookie, `HttpOnly`, `Max-Age=600` |
| `handleCallback` emits a single `Set-Cookie` string | Emits `string[]` — session cookie + verifier delete |
| `state` = plaintext `{internal}.{userState}` | `state` = opaque sealed blob (custom `state` still round-trips) |
| No error-path cleanup helper | New: `clearPendingVerifier(response)` |
| No error-path cleanup helper | New: `clearPendingVerifier(response, { state })` |

---

Expand Down Expand Up @@ -149,19 +181,27 @@ must also implement `setCookie` and `clearCookie`. See `src/core/session/types.t

On paths where sign-in was initiated but `handleCallback` never runs (OAuth
error responses, missing `code`, early bail-outs), the verifier cookie would
linger until `Max-Age` expires. Call `clearPendingVerifier` to emit a delete:
linger until `Max-Age` expires. Call `clearPendingVerifier` with the `state`
from the callback URL to emit a delete for the correct per-flow cookie:

```ts
const { headers } = await auth.clearPendingVerifier(response);
// Apply headers the same way you apply any storage output
if (state) {
const { headers } = await auth.clearPendingVerifier(response, { state });
// Apply headers the same way you apply any storage output
}
```

For headers-only adapters, pass `undefined`:
For headers-only adapters, pass `undefined` as the response:

```ts
const { headers } = await auth.clearPendingVerifier(undefined);
if (state) {
const { headers } = await auth.clearPendingVerifier(undefined, { state });
}
```

Skip the call entirely when `state` is absent from the callback URL
(malformed callback) — the 10-minute PKCE TTL cleans up orphan cookies.

(On callback success, the verifier is cleared automatically.)

---
Expand Down Expand Up @@ -238,15 +278,30 @@ the cookie is read — state mismatch, tampered seal, exchange failure, or
save failure — so response-mutating adapters don't need to call
`clearPendingVerifier` manually. Headers-only adapters that can't observe
the response mutation should still call `clearPendingVerifier` in the catch
block to capture the delete `Set-Cookie` headers.
block to capture the delete `Set-Cookie` headers — pass the `state` from
the callback URL so the correct per-flow cookie is cleared, and skip the
call when `state` is absent:

```ts
try {
await auth.handleCallback(request, response, { code, state });
} catch (err) {
if (state) {
await auth.clearPendingVerifier(response, { state });
}
throw err;
}
```

---

### 7. Verifier cookie on the wire

A `wos-auth-verifier` cookie is set during sign-in and read during callback.
A `wos-auth-verifier-<fnv1a>` cookie is set during sign-in and read during
callback. As of 0.5.0 the cookie name carries a per-flow suffix so concurrent
sign-ins from multiple tabs don't clobber each other.

- **Name**: `wos-auth-verifier`
- **Name**: `wos-auth-verifier-<fnv1a>` (per-flow; suffix derived from the sealed blob)
- **HttpOnly**, **Secure** (unless explicitly `SameSite=None` without HTTPS)
- **SameSite**: `Lax` (survives the cross-site return from WorkOS). `None`
preserved for iframe/embed flows.
Expand All @@ -257,7 +312,7 @@ A `wos-auth-verifier` cookie is set during sign-in and read during callback.
**Checklist**

- [ ] Edge/CDN/firewall allowlists pass the cookie through.
- [ ] Cookie-stripping proxies don't strip `wos-auth-verifier`.
- [ ] Cookie-stripping proxies don't strip `wos-auth-verifier-*`.
- [ ] Multiple AuthKit apps on the same host use distinct `cookieDomain`s
(path-based isolation is not available — the cookie path is always `/`).
- [ ] CSP or cookie-policy banners don't interfere with setting an `HttpOnly`
Expand Down
29 changes: 23 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -194,25 +194,32 @@ authService.signOut(sessionId, { returnTo }) // → { logoutUrl, response?,
authService.refreshSession(session, organizationId?)
authService.switchOrganization(session, organizationId)

// URL Generation — write verifier cookie, return { url, response?, headers? }
// URL Generation — write verifier cookie, return { url, cookieName, response?, headers? }
authService.createAuthorization(response, options)
authService.createSignIn(response, options)
authService.createSignUp(response, options)

// Error-path cleanup for the PKCE verifier cookie
// `state` is required (from the callback URL) — it identifies which per-flow
// verifier cookie to clear. Skip this call when `state` is absent from the
// callback URL (malformed callback); the 10-minute PKCE TTL handles the orphan.
// (response may be `undefined` for headers-only adapters)
authService.clearPendingVerifier(response, { redirectUri? })
authService.clearPendingVerifier(response, { state, redirectUri? })
```

### PKCE verifier cookie (`wos-auth-verifier`)
### PKCE verifier cookie (`wos-auth-verifier-<fnv1a>`)

This library binds every OAuth sign-in to a PKCE code verifier, so a leaked
`state` value on its own cannot be used to complete a session hijack.

Each in-flight sign-in gets its own per-flow verifier cookie with a
deterministic suffix derived from the sealed blob, so concurrent
sign-ins from multiple tabs no longer clobber each other.

The verifier is sealed into a single blob that serves two roles:

1. It is sent to WorkOS as the OAuth `state` query parameter.
2. It is set as a short-lived HTTP-only cookie (`wos-auth-verifier`, 10 min).
2. It is set as a short-lived HTTP-only cookie (`wos-auth-verifier-<fnv1a>`, 10 min).

The cookie is written and read through `SessionStorage`. Callers don't see
sealed blobs or cookie options:
Expand Down Expand Up @@ -253,8 +260,18 @@ if (setCookie) {
Mismatched state and cookie raise `OAuthStateMismatchError`. A missing cookie
(typical cause: Set-Cookie stripped by a proxy) raises
`PKCECookieMissingError`. On either error path — or any early bail-out before
`handleCallback` runs — call `authService.clearPendingVerifier(response)` to
emit a delete header.
`handleCallback` runs — call
`authService.clearPendingVerifier(response, { state })` with the `state` from
the callback URL to emit a delete header for the correct per-flow cookie:

```ts
if (state) {
await authService.clearPendingVerifier(response, { state });
}
```

If the callback URL has no `state` (malformed callback), skip this call — the
10-minute PKCE TTL handles the orphan.

### Direct Access (Advanced)

Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@workos/authkit-session",
"version": "0.4.0",
"version": "0.5.0",
"description": "Framework-agnostic authentication library for WorkOS with pluggable storage adapters",
"keywords": [],
"license": "MIT",
Expand Down
56 changes: 56 additions & 0 deletions src/core/pkce/cookieName.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { describe, it, expect } from 'vitest';
import {
PKCE_COOKIE_PREFIX,
getPKCECookieNameForState,
fnv1a32Hex,
} from './cookieName.js';

describe('fnv1a32Hex', () => {
// Known-answer tests against the reference FNV-1a 32-bit spec
// (http://www.isthe.com/chongo/tech/comp/fnv/). Empty string is the
// FNV offset basis 0x811c9dc5.
it('hashes the empty string to the FNV offset basis', () => {
expect(fnv1a32Hex('')).toBe('811c9dc5');
});

it('hashes "a" to 0xe40c292c', () => {
expect(fnv1a32Hex('a')).toBe('e40c292c');
});

it('hashes "foobar" to 0xbf9cf968', () => {
expect(fnv1a32Hex('foobar')).toBe('bf9cf968');
});

it('returns a zero-padded 8-char hex string', () => {
expect(fnv1a32Hex('x')).toMatch(/^[0-9a-f]{8}$/);
});

it('is deterministic', () => {
expect(fnv1a32Hex('some-sealed-state')).toBe(
fnv1a32Hex('some-sealed-state'),
);
});
});

describe('getPKCECookieNameForState', () => {
it('prefixes with wos-auth-verifier and appends an 8-char hex hash', () => {
expect(getPKCECookieNameForState('any-state')).toMatch(
/^wos-auth-verifier-[0-9a-f]{8}$/,
);
});

it('produces different names for different states', () => {
expect(getPKCECookieNameForState('state-a')).not.toBe(
getPKCECookieNameForState('state-b'),
);
});

it('is deterministic for the same input', () => {
const s = 'sealed-' + 'x'.repeat(200);
expect(getPKCECookieNameForState(s)).toBe(getPKCECookieNameForState(s));
});

it('exports the prefix constant', () => {
expect(PKCE_COOKIE_PREFIX).toBe('wos-auth-verifier');
});
});
27 changes: 27 additions & 0 deletions src/core/pkce/cookieName.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
/** Stable prefix for all PKCE verifier cookies. */
export const PKCE_COOKIE_PREFIX = 'wos-auth-verifier';

/**
* FNV-1a 32-bit hash of the input, returned as a zero-padded 8-char
* lowercase hex string. Used purely as a namespacing mechanism — not
* security-sensitive. Collision probability is ~1/4B per pair; a
* collision routes one flow's callback to the wrong cookie, which
* then fails byte-equality in `verifyCallbackState` (fail-closed).
*/
export function fnv1a32Hex(input: string): string {
let hash = 0x811c9dc5;
const bytes = new TextEncoder().encode(input);
for (const byte of bytes) {
hash = Math.imul(hash ^ byte, 0x01000193) >>> 0;
}
return hash.toString(16).padStart(8, '0');
}

/**
* Derive a flow-specific PKCE verifier cookie name from the sealed
* state blob. Each concurrent OAuth flow gets its own cookie so
* parallel sign-ins from multiple tabs don't clobber each other.
*/
export function getPKCECookieNameForState(state: string): string {
return `${PKCE_COOKIE_PREFIX}-${fnv1a32Hex(state)}`;
}
3 changes: 0 additions & 3 deletions src/core/pkce/cookieOptions.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,6 @@
import type { AuthKitConfig } from '../config/types.js';
import type { CookieOptions } from '../session/types.js';

/** Name of the PKCE verifier cookie on the wire. */
export const PKCE_COOKIE_NAME = 'wos-auth-verifier';

/**
* PKCE verifier cookie lifetime (seconds). Matches the 10-minute convention
* used by Arctic, openid-client, Clerk, and Okta for short-lived OAuth state
Expand Down
12 changes: 6 additions & 6 deletions src/core/pkce/generateAuthorizationUrl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@ import type {
GetAuthorizationUrlOptions,
SessionEncryption,
} from '../session/types.js';
import { getPKCECookieOptions, PKCE_COOKIE_NAME } from './cookieOptions.js';
import { getPKCECookieNameForState } from './cookieName.js';
import { getPKCECookieOptions } from './cookieOptions.js';
import { sealState } from './state.js';

/**
Expand Down Expand Up @@ -37,6 +38,7 @@ export const PKCE_MAX_COOKIE_BYTES = 3800;
export interface GeneratedAuthorizationUrl {
url: string;
sealedState: string;
cookieName: string;
cookieOptions: CookieOptions;
}

Expand Down Expand Up @@ -89,11 +91,8 @@ export async function generateAuthorizationUrl(params: {
// returnPathname combined with near-max state, or an unusually long
// cookieDomain attribute.
const cookieOptions = getPKCECookieOptions(config, redirectUri);
const serialized = serializeCookie(
PKCE_COOKIE_NAME,
sealedState,
cookieOptions,
);
const cookieName = getPKCECookieNameForState(sealedState);
const serialized = serializeCookie(cookieName, sealedState, cookieOptions);
const cookieBytes = new TextEncoder().encode(serialized).byteLength;
if (cookieBytes > PKCE_MAX_COOKIE_BYTES) {
throw new PKCEPayloadTooLargeError(
Expand All @@ -118,6 +117,7 @@ export async function generateAuthorizationUrl(params: {
return {
url,
sealedState,
cookieName,
cookieOptions,
};
}
9 changes: 9 additions & 0 deletions src/core/pkce/pkce.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
PKCECookieMissingError,
PKCEPayloadTooLargeError,
} from '../errors.js';
import { getPKCECookieNameForState } from './cookieName.js';
import {
generateAuthorizationUrl,
PKCE_MAX_COOKIE_BYTES,
Expand Down Expand Up @@ -89,6 +90,14 @@ describe('PKCE end-to-end round-trip', () => {
).rejects.toThrow(PKCECookieMissingError);
});

it('returns cookieName derived from the sealed state', async () => {
const result = await generate();
expect(result.cookieName).toBe(
getPKCECookieNameForState(result.sealedState),
);
expect(result.cookieName).toMatch(/^wos-auth-verifier-[0-9a-f]{8}$/);
});

it('concurrent sign-ins produce distinct sealedStates (cross-flow rejection)', async () => {
const core = makeCore();
const a = await generate();
Expand Down
12 changes: 8 additions & 4 deletions src/core/session/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -176,13 +176,17 @@ export interface SessionEncryption {
* The verifier cookie is written internally via `SessionStorage.setCookie` — callers
* only need to redirect the browser to `url` and apply any returned `headers`/`response`.
*/
export interface GetAuthorizationUrlResult {
export type CreateAuthorizationResult<TResponse> = {
url: string;
}

export type CreateAuthorizationResult<TResponse> = GetAuthorizationUrlResult & {
response?: TResponse;
headers?: HeadersBag;
/**
* Name of the PKCE verifier cookie written during this call. Useful
* for assertion-in-tests and for adapters that want to log the flow
* identifier. NOT the shape `clearPendingVerifier` consumes — that
* method takes `state`, not `cookieName`.
*/
cookieName: string;
};

export interface CookieOptions {
Expand Down
8 changes: 8 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,14 @@ export { AuthOperations } from './operations/AuthOperations.js';
// ============================================
export { CookieSessionStorage } from './core/session/CookieSessionStorage.js';

// ============================================
// PKCE Helpers
// ============================================
export {
PKCE_COOKIE_PREFIX,
getPKCECookieNameForState,
} from './core/pkce/cookieName.js';

// ============================================
// Encryption Fallback
// ============================================
Expand Down
Loading
Loading