fix: isolate concurrent PKCE flows to prevent cookie clobbering#403
Open
cn-stephen wants to merge 2 commits intoworkos:mainfrom
Open
fix: isolate concurrent PKCE flows to prevent cookie clobbering#403cn-stephen wants to merge 2 commits intoworkos:mainfrom
cn-stephen wants to merge 2 commits intoworkos:mainfrom
Conversation
When a user's session expires (e.g. inactivity timeout) and they have multiple tabs open, all tabs wake up and fire requests simultaneously. Each request hits the middleware, which generates a fresh PKCE state and overwrites the single shared `wos-auth-verifier` cookie. The last write wins, so when callbacks return from WorkOS, every tab except the last one fails with "OAuth state mismatch" or "Auth cookie missing". This is reliably reproducible: open 3-4 tabs, wait for session expiry, switch back to the browser. The tabs wake up concurrently, each triggers a middleware redirect with a different PKCE state, and the shared cookie gets clobbered. Two changes fix this: 1. Per-flow cookie names: derive a unique suffix from an FNV-1a hash of the sealed state (e.g. `wos-auth-verifier-a3f7c012`). Each concurrent flow gets its own cookie, and each callback reads the correct one. Uses @sindresorhus/fnv1a for a fast, format-agnostic 32-bit hash. 2. Document-only cookies: reuse the existing `isInitialDocumentRequest` check to only set PKCE cookies on full page navigations. Fetch, XHR, RSC, and prefetch requests never follow cross-origin redirects so they can never complete the OAuth flow. Without this guard, the per-flow cookie names cause ~7 cookies per tab (one per concurrent request), which quickly exceeds browser header limits and triggers HTTP 431 (Request Header Fields Too Large).
The PKCE cookie max-age (600s / 10 minutes) was only applied in the server component path (setPKCECookie). The middleware path used getPKCECookieOptions which delegated to getCookieOptions — inheriting the session cookie's 400-day max-age. This was invisible before because the single shared cookie name meant the cookie would be deleted on callback. With per-flow cookie names, orphaned cookies (e.g. from a tab that never completed the OAuth flow) now survive with a 400-day expiry instead of self-cleaning after 10 minutes. Move PKCE_COOKIE_MAX_AGE into getPKCECookieOptions so both the server component and middleware paths use the same 10-minute TTL.
Contributor
Author
|
Tested locally, works great. |
cn-stephen
commented
Apr 10, 2026
Comment on lines
+117
to
+119
| return options | ||
| .replace(/SameSite=Strict/i, 'SameSite=Lax') | ||
| .replace(/Max-Age=\d+/, `Max-Age=${expired ? 0 : PKCE_COOKIE_MAX_AGE}`); |
Contributor
Author
There was a problem hiding this comment.
ngl this string replace technique is fragile as heck. But it existed and I'm not trying to clean up everything possible.
Hope this is OK for now
Contributor
Author
|
cc @nicknisi as well, as you're the only guy I know on the team xD and we had a good collab previously. Hope the explanations are sufficient |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
When a user's session expires (e.g. inactivity timeout) and they have multiple tabs open all tabs wake up and fire requests simultaneously, or even if a single tab fires off multiple requests at once that trigger the auth flow. Each request hits the middleware, which generates a fresh PKCE state and overwrites the single shared
wos-auth-verifiercookie. The last write wins, so when callbacks return from WorkOS, every tab except the last one fails with "OAuth state mismatch" or "Auth cookie missing".This is reliably reproducible: open 3-4 tabs, wait for session expiry, switch back to the browser. The tabs wake up concurrently, each triggers a middleware redirect with a different PKCE state, and the shared cookie gets clobbered.
Two changes fix this:
Per-flow cookie names: derive a unique suffix from an FNV-1a hash of the sealed state (e.g.
wos-auth-verifier-a3f7c012). Each concurrent flow gets its own cookie, and each callback reads the correct one. Uses @sindresorhus/fnv1a for a fast, format-agnostic 32-bit hash.Document-only cookies: reuse the existing
isInitialDocumentRequestcheck to only set PKCE cookies on full page navigations. Fetch, XHR, RSC, and prefetch requests never follow cross-origin redirects so they can never complete the OAuth flow. Without this guard, the per-flow cookie names cause many cookies per tab (one per concurrent request e.g., react-query reissuing XHR fetches on window activate), which quickly exceeds browser header limits and triggers HTTP 431 (Request Header Fields Too Large).