Skip to content

fix: evict stale PKCE verifier cookies to prevent HTTP 431 (#76)#104

Merged
nicknisi merged 4 commits into
mainfrom
fix/pkce-verifier-cookie-eviction
Jun 16, 2026
Merged

fix: evict stale PKCE verifier cookies to prevent HTTP 431 (#76)#104
nicknisi merged 4 commits into
mainfrom
fix/pkce-verifier-cookie-eviction

Conversation

@nicknisi

Copy link
Copy Markdown
Member

Closes #76.

Problem

getSignInUrl() / getSignUpUrl() / getAuthorizationUrl() read like pure getters, but each call starts an OAuth flow and sets a wos-auth-verifier-<hash> cookie. The per-flow hashed name (so concurrent sign-ins across tabs don't clobber each other) means cookies never overwrite. Using these helpers to display a link — e.g. prefetching in a route loader, or calling unconditionally in beforeLoad even when already signed in — mints a fresh orphan cookie on every navigation. They pile up until the 10-minute PKCE TTL and eventually bloat the Cookie request header into an HTTP 431.

This is the consumer half of the fix; the shared primitive shipped in @workos/authkit-session@0.6.0 (workos/authkit-session#42).

Changes

  • Bump @workos/authkit-session^0.6.0 (required for the new exports).
  • Bounded eviction in server-fn-bodies.ts. After minting a verifier, parse the incoming Cookie header, compute stale verifier names via selectStalePKCEVerifierCookieNames(names, { keep: result.cookieName }) (cap 5, all-but-newest), and clear each via AuthService.clearPendingVerifierByName through middleware's existing pending-Set-Cookie channel. Best-effort — wrapped so a cleanup failure can never break URL generation. Applies to all three helpers.
  • JSDoc warnings on the public helpers in server-functions.ts: they have a cookie side effect and should be called on user action / from a redirect route, not prefetched in a loader for display.

The lazy body/shell boundary is preserved: the new @workos/authkit-session value import lives in server-fn-bodies.ts (a body file, already allowed to import server-only modules), reached only via the RPC-rewritten shell's dynamic import().

Why eviction (not just docs or "don't set a cookie")

  • Can't "return URL without a cookie": PKCE requires the codeVerifier be persisted before the callback. The only cookie-free variant is the redirect-route pattern, which the example already uses.
  • Docs alone are insufficient: the API shape invites the misuse (the Convex TanStack guide prefetches these in loaders). Eviction bounds the damage regardless of how the helper is called; the JSDoc steers people to the right pattern.
  • Mirrors authkit-nextjs (MAX_PKCE_COOKIES = 5).

Tests

  • server-functions.spec.ts — 5 new cases: no eviction within cap; evict all-but-newest over cap; never evicts the freshly minted cookie (even if already on the request); applies to getSignUpUrl/getAuthorizationUrl; URL still generated when eviction throws.
  • Full suite 227 passing; typecheck, lint, SDK build, example build, and build:check (no server-only fingerprints in the client bundle) all clean against the published 0.6.0.

The example app already uses the recommended redirect-route pattern (/api/auth/sign-in), so no example change was needed.

Calling getSignInUrl()/getSignUpUrl()/getAuthorizationUrl() to display a
link — e.g. prefetching in a route loader — mints a uniquely-named
wos-auth-verifier-* cookie on every call that is never navigated to. The
per-flow naming means cookies never overwrite, so orphans accumulate until
their 10-minute TTL and eventually bloat the Cookie header into an HTTP 431.

Wire up the eviction primitives added in @workos/authkit-session@0.6.0:
after minting a new verifier, parse the incoming Cookie header, compute the
stale verifier names via selectStalePKCEVerifierCookieNames (keeping the one
just created), and clear each through middleware's pending-Set-Cookie channel
via clearPendingVerifierByName. Best-effort — a cleanup failure never breaks
URL generation.

Also document the side effect on the public helpers: each call starts an
OAuth flow and sets a cookie, so they should be invoked on user action or
from a redirect route, not prefetched in a loader for display.

Bumps @workos/authkit-session to ^0.6.0 (required for the new exports).

Closes #76
@greptile-apps

greptile-apps Bot commented Jun 15, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR fixes HTTP 431 errors caused by unbounded accumulation of wos-auth-verifier-* PKCE cookies — each call to getSignInUrl/getSignUpUrl/getAuthorizationUrl mints a uniquely-named cookie that never overwrites prior ones, so misuse in loaders or beforeLoad piles up orphan cookies until the Cookie request header overflows.

  • Bounded eviction: a new evictStalePendingVerifiers helper reads the incoming Cookie header (via a lightweight parseCookieNames utility), calls selectStalePKCEVerifierCookieNames from @workos/authkit-session@0.6.0 to identify stale verifiers, and clears them through the existing pending-Set-Cookie channel using Promise.allSettled for best-effort parallel cleanup.
  • Dependency bump: @workos/authkit-session^0.6.0, which is required to access selectStalePKCEVerifierCookieNames and the new CreateAuthorizationResult type (including cookieName).
  • JSDoc warnings added to all three public URL helpers advising against prefetching them in route loaders.

Confidence Score: 5/5

Safe to merge — the eviction path is purely additive and best-effort, so it cannot block URL generation or affect auth flow correctness.

The fix is well-scoped, touching only the authorization URL pipeline and cookie name utility. The eviction is best-effort so failures leave orphaned cookies to expire naturally without breaking sign-in. The freshly minted cookie is always protected via the keep argument, and five dedicated test cases validate all boundary conditions.

No files require special attention.

Important Files Changed

Filename Overview
src/server/server-fn-bodies.ts Core fix: refactored authorization URL pipeline into a single authorizationUrl helper with a new evictStalePendingVerifiers step; eviction is best-effort via Promise.allSettled, reads only incoming cookie names, and protects the freshly minted cookie via the keep argument.
src/server/cookie-utils.ts New parseCookieNames utility efficiently extracts cookie names only, skipping value allocations; handles valueless cookies and empty headers correctly.
src/server/server-functions.spec.ts Five new test cases covering: under-cap no-eviction, over-cap eviction, protection of the freshly minted cookie, coverage across all three URL helpers, and URL resilience when eviction throws.
src/server/cookie-utils.spec.ts Five new tests for parseCookieNames covering basic parsing, base64-padded values, whitespace trimming, empty headers, and valueless cookie segments.
src/server/server-functions.ts JSDoc @remarks blocks added to all three authorization URL helpers warning about the PKCE cookie side effect and the loader antipattern.
package.json Dependency bump @workos/authkit-session from ^0.5.3 to ^0.6.0 to pick up selectStalePKCEVerifierCookieNames and CreateAuthorizationResult.

Sequence Diagram

%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
    participant Client
    participant Pipeline as authorizationUrl
    participant AuthKit as AuthService
    participant Headers as PendingSetCookieChannel
    participant Evict as evictStalePendingVerifiers

    Client->>Pipeline: call with incoming Cookie header
    Pipeline->>AuthKit: createSignIn or createSignUp or createAuthorization
    AuthKit-->>Pipeline: result with url and cookieName
    Pipeline->>Headers: emit new verifier Set-Cookie header
    Pipeline->>Evict: pass ctx, authkit, and cookieName to keep
    Evict->>Evict: parseCookieNames from request Cookie header
    Evict->>AuthKit: selectStalePKCEVerifierCookieNames with keep
    AuthKit-->>Evict: stale list empty if within cap
    alt stale verifiers found over cap
        Evict->>AuthKit: Promise.allSettled clearPendingVerifierByName for each stale name
        AuthKit->>Headers: append expiry Set-Cookie for each stale verifier
    end
    Pipeline-->>Client: return URL string
Loading
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
sequenceDiagram
    participant Client
    participant Pipeline as authorizationUrl
    participant AuthKit as AuthService
    participant Headers as PendingSetCookieChannel
    participant Evict as evictStalePendingVerifiers

    Client->>Pipeline: call with incoming Cookie header
    Pipeline->>AuthKit: createSignIn or createSignUp or createAuthorization
    AuthKit-->>Pipeline: result with url and cookieName
    Pipeline->>Headers: emit new verifier Set-Cookie header
    Pipeline->>Evict: pass ctx, authkit, and cookieName to keep
    Evict->>Evict: parseCookieNames from request Cookie header
    Evict->>AuthKit: selectStalePKCEVerifierCookieNames with keep
    AuthKit-->>Evict: stale list empty if within cap
    alt stale verifiers found over cap
        Evict->>AuthKit: Promise.allSettled clearPendingVerifierByName for each stale name
        AuthKit->>Headers: append expiry Set-Cookie for each stale verifier
    end
    Pipeline-->>Client: return URL string
Loading

Reviews (4): Last reviewed commit: "refactor: simplify authorization-URL bod..." | Re-trigger Greptile

Comment thread src/server/server-fn-bodies.ts Outdated

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

✅ Devin Review: No Issues Found

Devin Review analyzed this PR and found no bugs or issues to report.

Open in Devin Review

nicknisi added 2 commits June 15, 2026 18:15
Per PR review: the stale-verifier deletes are independent, so fire them
together via Promise.allSettled rather than awaiting sequentially. allSettled
isolates per-cookie failures, preserving the best-effort semantics (a failed
clear just leaves an orphan to expire on its TTL) without the per-iteration
try/catch.
Pass the already-resolved middleware context from forwardAuthorizationCookies
into evictStalePendingVerifiers instead of re-deriving it. This collapses three
getGlobalStartContext() reads per authorization request to one (the function
re-fetched the context and separately called getRedirectUriFromContext, both of
which resolve the same object the caller already holds), uses ctx.redirectUri
directly, and drops a now-dead null guard. Fully explicit data flow; behavior
unchanged.

Also strengthen the "freshly minted cookie is never evicted" test to actually
exercise the over-cap boundary with the kept cookie present on the request,
rather than sitting under the cap where no eviction runs at all.
@nicknisi nicknisi requested a review from gjtorikian June 16, 2026 12:35
Two cleanups from a quality pass over the eviction change:

- Collapse the three near-identical URL bodies (getAuthorizationUrlBody /
  getSignInUrlBody / getSignUpUrlBody) into a single `authorizationUrl(create)`
  pipeline parameterized by the create callback. This folds away the two-arg
  pass-through `forwardAuthorizationCookies(authkit, result)` (authkit was only
  threaded through to eviction), de-duplicates the resolve→create→forward→evict
  sequence and the string→returnPathname normalization (now `toAuthOptions`),
  and keeps the create calls explicit/greppable (no string dispatch).

- Add `parseCookieNames` to cookie-utils and use it on the eviction path
  instead of `Object.keys(parseCookies(header))`. The verifier-eviction path
  exists for pathologically large Cookie headers; parsing names-only avoids
  allocating every (large, encrypted) cookie value just to discard it.

Behavior unchanged; 232 tests pass, bundle-leak check clean.
@nicknisi nicknisi merged commit 1c77259 into main Jun 16, 2026
5 checks passed
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.

Cookie multiplying eventually kills server with header too large code 431

2 participants