Skip to content

Dev instances: clerk-js rewrites __clerk_db_jwt non-atomically, concurrent requests fail with dev-browser-missing #10411

Open

Description

@NaruXT

Preliminary Checks

Reproduction

No separate repository: the intermediate cookie state can be reproduced deterministically from the browser console in any Next.js app on a development instance (steps below). The race itself shows up in our Playwright suite on CI.

Publishable key

Development instance; available on request.

Description

On development instances, clerk-js rewrites the dev browser cookie non-atomically. A request to the app that leaves the browser during the rewrite carries __clerk_db_jwt but not __clerk_db_jwt_<suffix>, and clerkMiddleware treats it as signed out with reason dev-browser-missing, even though the session cookies are present and valid.

Where it comes from (clerk-js 6.40.1, dist/clerk.js, dev browser cookie handler):

o = () => { let e = iR(r); for (let t of [s, i]) t.remove(e), t.remove(), t.remove(iN) },
// ...
set: e => { /* ... */ o(), s.set(e, {...}), i.set(e, {...}) }

s is the suffixed cookie and i the unsuffixed one. Every set first removes both (three removals each), then sets the suffixed one, then the unsuffixed one. set runs on every FAPI response carrying a Clerk-Db-Jwt header (onAfterResponse), so it happens several times right after each page load (client, token, organization lists) and on every token refresh.

On the server, AuthenticateContext.initHandshakeValues() (@clerk/backend 3.23.2 and 3.26.2) reads the dev browser token with getSuffixedOrUnSuffixedCookie, which only looks at __clerk_db_jwt_<suffix> when usesSuffixedCookies() is true. With the suffixed cookie missing, authenticateRequestWithTokenInCookie returns DevBrowserMissing. For a document GET the handshake recovers, but:

  • a fetch to a route handler gets a 401;
  • a server action gets redirected to the sign-in URL and silently has no effect;
  • an RSC prefetch caches the signed-out payload, so a later client navigation lands on the sign-in page.

Evidence: we instrumented signedOut() in @clerk/backend to log the cookie names it saw when it returned signed out. Every dev-browser-missing case had __session, __session_<suffix>, __client_uat, __client_uat_<suffix> and __clerk_db_jwt, but no __clerk_db_jwt_<suffix>, which is exactly the state between s.remove(...) and i.remove(...) above. We also saw (less often) requests with neither variant, which is the state between the last removal and s.set(...). The window widens under CPU load: it hit our E2E suite several times per run on 2-vCPU CI runners, and we reproduced it locally by running Playwright with 6 to 8 workers.

Steps to reproduce (deterministic, simulating the intermediate state):

  1. Run a Next.js app with clerkMiddleware on a development instance and sign in.
  2. In the browser console, remove only the suffixed dev browser cookie and call any route that goes through the middleware:
const suffixed = document.cookie.split('; ').find((c) => c.startsWith('__clerk_db_jwt_')).split('=')[0];
document.cookie = `${suffixed}=; Max-Age=0; path=/`;
const r = await fetch('/any-route-behind-clerkMiddleware');
console.log(r.status, r.headers.get('x-clerk-auth-status'), r.headers.get('x-clerk-auth-reason'));
// -> signed-out, dev-browser-missing (401 for an API route)
  1. Reload: clerk-js writes the cookie again and everything works.

Expected behavior:

Rewriting the dev browser cookie should never leave a window where a signed-in browser sends an incomplete set. For example, set the new values first and only then remove stale variants with different attributes (or skip the removal when attributes don't change). On the backend, falling back to the unsuffixed __clerk_db_jwt when the suffixed one is missing would also close the most common window, since clerk-js always writes both with the same value.

Actual behavior:

Intermittent signed-out / dev-browser-missing for requests that carry a valid session, with silent failures in server actions and fetches.

Workaround we use: with a pk_test_ key, our middleware copies __clerk_db_jwt into __clerk_db_jwt_<suffix> (suffix taken from the __session_<suffix> cookie) when only the unsuffixed one is present, before calling clerkMiddleware.

Environment

@clerk/nextjs: 7.9.15
@clerk/backend: 3.23.2 (same lookup in 3.26.2)
@clerk/clerk-js: 6.40.1 (loaded from the instance CDN, latest 6.x)
@clerk/testing: 2.2.45
next: 16.4.1 (also seen with 16.4.0), proxy (middleware) on the Node.js runtime
Browser: Chromium via Playwright 1.64
OS: Ubuntu 24.04 (GitHub Actions, 2 vCPU) and macOS 27
Node.js: from .node-version (24.x)

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions