From 059f3fb3143d713e52a5559b603601ed9b002314 Mon Sep 17 00:00:00 2001 From: Jacek Date: Wed, 17 Jun 2026 08:10:12 -0500 Subject: [PATCH 1/4] fix(react-router): scope getAuth to the request to prevent cross-user auth bleed clerkMiddleware stored each request's auth on the React Router context and getAuth read it back. Apps that share one RouterContextProvider across requests (a custom server or getLoadContext that returns a single instance) reuse one context for every request, so under concurrency a request could be served another user's auth. Bind auth to a per-request AsyncLocalStorage scope around next() (getAuth falls back to the context slot), which also survives React Router's action-to-loader revalidation, and warn once when a reused context is detected. --- .changeset/rr-request-scoped-auth.md | 5 + .../clerkMiddleware.requestScope.test.ts | 144 ++++++++++++++++++ ...erkMiddleware.sharedContextWarning.test.ts | 86 +++++++++++ .../src/server/clerkMiddleware.ts | 67 +++++--- packages/react-router/src/server/getAuth.ts | 7 +- .../src/server/requestAuthStorage.ts | 37 +++++ 6 files changed, 326 insertions(+), 20 deletions(-) create mode 100644 .changeset/rr-request-scoped-auth.md create mode 100644 packages/react-router/src/server/__tests__/clerkMiddleware.requestScope.test.ts create mode 100644 packages/react-router/src/server/__tests__/clerkMiddleware.sharedContextWarning.test.ts create mode 100644 packages/react-router/src/server/requestAuthStorage.ts diff --git a/.changeset/rr-request-scoped-auth.md b/.changeset/rr-request-scoped-auth.md new file mode 100644 index 00000000000..c0d0198ff5d --- /dev/null +++ b/.changeset/rr-request-scoped-auth.md @@ -0,0 +1,5 @@ +--- +'@clerk/react-router': patch +--- + +`getAuth()` now resolves the current request's authentication from request-scoped storage instead of relying solely on the React Router context. Apps that share a single `RouterContextProvider` across requests (for example a custom server or `getLoadContext` that returns one instance) no longer risk one request being served another user's auth under concurrency. Auth is bound to the per-request async execution scope and also survives React Router's action-to-loader revalidation. When a context that is reused across requests is detected, `clerkMiddleware()` now logs a one-time warning, since a shared context can still leak an application's own per-request data. diff --git a/packages/react-router/src/server/__tests__/clerkMiddleware.requestScope.test.ts b/packages/react-router/src/server/__tests__/clerkMiddleware.requestScope.test.ts new file mode 100644 index 00000000000..5c3f05ce55f --- /dev/null +++ b/packages/react-router/src/server/__tests__/clerkMiddleware.requestScope.test.ts @@ -0,0 +1,144 @@ +// Request-scoped auth isolation for clerkMiddleware + getAuth. +// +// clerkMiddleware binds each request's auth to an AsyncLocalStorage scope (see +// requestAuthStorage) and also mirrors it onto the React Router context. getAuth +// reads the async scope first. That keeps auth correct per request even when an +// app shares one RouterContextProvider across requests (the custom-server / +// getLoadContext footgun) - the failure mode reported as a cross-user "bleed". +// +// Only clerkClient and loadOptions are mocked; each request authenticates as a +// distinct user encoded in its URL, so the sole variable under test is whether +// the RouterContextProvider is shared or per-request. +import type { ClerkClient } from '@clerk/backend'; +import { AuthStatus, TokenType } from '@clerk/backend/internal'; +import type { LoaderFunctionArgs } from 'react-router'; +import { RouterContextProvider } from 'react-router'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { clerkClient } from '../clerkClient'; +import { clerkMiddleware } from '../clerkMiddleware'; +import { getAuth } from '../getAuth'; +import { loadOptions } from '../loadOptions'; + +vi.mock('../clerkClient'); +vi.mock('../loadOptions'); + +const mockClerkClient = vi.mocked(clerkClient); +const mockLoadOptions = vi.mocked(loadOptions); + +// The "user" for a request is encoded in its URL (?u=...); authenticateRequest +// reads it back, so every request resolves to its own correct identity. +function fakeStateForRequest(req: { url: string }) { + const userId = new URL(req.url).searchParams.get('u'); + return { + status: AuthStatus.SignedIn, + headers: new Headers(), + toAuth: () => ({ userId, tokenType: TokenType.SessionToken }), + }; +} + +// Flush microtasks so request A finishes authenticate + context.set and parks +// inside next() before request B runs. +const flushMicrotasks = () => new Promise(resolve => setTimeout(resolve, 0)); + +async function readUserId(args: LoaderFunctionArgs): Promise { + const auth = (await getAuth(args, { acceptsToken: 'any' })) as { userId?: string | null }; + return auth.userId; +} + +describe('clerkMiddleware + getAuth request-scoped isolation', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockLoadOptions.mockReturnValue({ + audience: '', + authorizedParties: [], + signInUrl: '', + signUpUrl: '', + secretKey: 'sk_test_...', + publishableKey: 'pk_test_...', + } as unknown as ReturnType); + mockClerkClient.mockReturnValue({ + authenticateRequest: vi.fn(async (req: { url: string }) => fakeStateForRequest(req)), + } as unknown as ClerkClient); + }); + + // Interleave two concurrent requests, each using `contextFor(request)`: + // 1. A's middleware authenticates, sets context, then parks inside next(). + // 2. B's middleware authenticates, sets context, reads its own auth in next(). + // 3. A unparks and reads its auth. + async function runInterleaved(contextFor: (req: Request) => RouterContextProvider) { + const middleware = clerkMiddleware(); + const results: { A?: string | null; B?: string | null } = {}; + + let releaseA!: () => void; + const gateA = new Promise(resolve => (releaseA = resolve)); + + const reqA = new Request('http://app.test/?u=user_A'); + const reqB = new Request('http://app.test/?u=user_B'); + const argsA = { request: reqA, context: contextFor(reqA) } as unknown as LoaderFunctionArgs; + const argsB = { request: reqB, context: contextFor(reqB) } as unknown as LoaderFunctionArgs; + + const aDone = middleware(argsA, async () => { + await gateA; + results.A = await readUserId(argsA); + return new Response('A'); + }); + + await flushMicrotasks(); + + await middleware(argsB, async () => { + results.B = await readUserId(argsB); + return new Response('B'); + }); + + releaseA(); + await aDone; + + return results; + } + + // The regression: with a single shared RouterContextProvider, B's middleware + // overwrote the shared context's auth between A's middleware and A's loader + // read, so A was served B's identity. The async scope fixes this: A's loader + // runs inside A's store regardless of the shared context. + it('keeps auth per-request with a shared RouterContextProvider', async () => { + const shared = new RouterContextProvider(); + const results = await runInterleaved(() => shared); + + expect(results.A).toBe('user_A'); + expect(results.B).toBe('user_B'); + }); + + it('keeps auth per-request with a fresh RouterContextProvider per request', async () => { + const perRequest = new Map(); + const results = await runInterleaved(req => { + if (!perRequest.has(req)) perRequest.set(req, new RouterContextProvider()); + return perRequest.get(req)!; + }); + + expect(results.A).toBe('user_A'); + expect(results.B).toBe('user_B'); + }); + + // On a mutation, React Router mints a NEW Request for the post-action loader + // revalidation (a request-keyed store would miss it), but the loader still runs + // inside the middleware's async scope, so getAuth resolves the right user even + // when reading via the fresh Request and a shared context. + it('resolves the right user when the loader reads via a fresh Request (action -> loader)', async () => { + const shared = new RouterContextProvider(); + const middleware = clerkMiddleware(); + + const reqA = new Request('http://app.test/?u=user_A', { method: 'POST' }); + const argsA = { request: reqA, context: shared } as unknown as LoaderFunctionArgs; + + let seen: string | null | undefined; + await middleware(argsA, async () => { + const loaderRequest = new Request(reqA.url, { headers: reqA.headers }); + const loaderArgs = { request: loaderRequest, context: shared } as unknown as LoaderFunctionArgs; + seen = await readUserId(loaderArgs); + return new Response('A'); + }); + + expect(seen).toBe('user_A'); + }); +}); diff --git a/packages/react-router/src/server/__tests__/clerkMiddleware.sharedContextWarning.test.ts b/packages/react-router/src/server/__tests__/clerkMiddleware.sharedContextWarning.test.ts new file mode 100644 index 00000000000..8a7960207b0 --- /dev/null +++ b/packages/react-router/src/server/__tests__/clerkMiddleware.sharedContextWarning.test.ts @@ -0,0 +1,86 @@ +// clerkMiddleware warns once when it detects a React Router context reused +// across requests (the shared-RouterContextProvider footgun). Auth itself is +// kept correct by requestAuthStorage; this warning tells the app its setup is +// unsupported and may leak its own per-request data. +// +// We spy on logger.warnOnce (the call site) rather than console.warn so the +// assertions are deterministic regardless of warnOnce's per-process dedup. +import type { ClerkClient } from '@clerk/backend'; +import { AuthStatus, TokenType } from '@clerk/backend/internal'; +import { logger } from '@clerk/shared/logger'; +import type { LoaderFunctionArgs } from 'react-router'; +import { RouterContextProvider } from 'react-router'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { clerkClient } from '../clerkClient'; +import { clerkMiddleware } from '../clerkMiddleware'; +import { loadOptions } from '../loadOptions'; + +vi.mock('../clerkClient'); +vi.mock('../loadOptions'); + +const mockClerkClient = vi.mocked(clerkClient); +const mockLoadOptions = vi.mocked(loadOptions); + +function fakeStateForRequest(req: { url: string }) { + const userId = new URL(req.url).searchParams.get('u'); + return { + status: AuthStatus.SignedIn, + headers: new Headers(), + toAuth: () => ({ userId, tokenType: TokenType.SessionToken }), + }; +} + +const noop = async () => new Response('ok'); + +describe('clerkMiddleware shared-context detection', () => { + let warnOnceSpy: ReturnType; + + beforeEach(() => { + vi.clearAllMocks(); + warnOnceSpy = vi.spyOn(logger, 'warnOnce').mockImplementation(() => {}); + mockLoadOptions.mockReturnValue({ + audience: '', + authorizedParties: [], + signInUrl: '', + signUpUrl: '', + secretKey: 'sk_test_...', + publishableKey: 'pk_test_...', + } as unknown as ReturnType); + mockClerkClient.mockReturnValue({ + authenticateRequest: vi.fn(async (req: { url: string }) => fakeStateForRequest(req)), + } as unknown as ClerkClient); + }); + + it('warns once when two requests share one RouterContextProvider', async () => { + const middleware = clerkMiddleware(); + const shared = new RouterContextProvider(); + + await middleware( + { request: new Request('http://app.test/?u=user_A'), context: shared } as unknown as LoaderFunctionArgs, + noop, + ); + await middleware( + { request: new Request('http://app.test/?u=user_B'), context: shared } as unknown as LoaderFunctionArgs, + noop, + ); + + expect(warnOnceSpy).toHaveBeenCalledTimes(1); + expect(warnOnceSpy).toHaveBeenCalledWith(expect.stringContaining('reused across requests')); + }); + + it('does not warn when each request gets its own RouterContextProvider', async () => { + const middleware = clerkMiddleware(); + + await middleware( + { request: new Request('http://app.test/?u=user_A'), context: new RouterContextProvider() } as unknown as LoaderFunctionArgs, + noop, + ); + await middleware( + { request: new Request('http://app.test/?u=user_B'), context: new RouterContextProvider() } as unknown as LoaderFunctionArgs, + noop, + ); + + expect(warnOnceSpy).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/react-router/src/server/clerkMiddleware.ts b/packages/react-router/src/server/clerkMiddleware.ts index 1f49251408a..15960aead62 100644 --- a/packages/react-router/src/server/clerkMiddleware.ts +++ b/packages/react-router/src/server/clerkMiddleware.ts @@ -1,6 +1,7 @@ import type { AuthObject } from '@clerk/backend'; import type { RequestState } from '@clerk/backend/internal'; import { AuthStatus, constants, createClerkRequest } from '@clerk/backend/internal'; +import { logger } from '@clerk/shared/logger'; import { handleNetlifyCacheInDevInstance } from '@clerk/shared/netlifyCacheHandler'; import type { PendingSessionOptions } from '@clerk/shared/types'; import type { MiddlewareFunction } from 'react-router'; @@ -9,6 +10,7 @@ import { createContext } from 'react-router'; import { clerkClient } from './clerkClient'; import { resolveKeysWithKeylessFallback } from './keyless/utils'; import { loadOptions } from './loadOptions'; +import { requestAuthStorage } from './requestAuthStorage'; import type { AdditionalStateOptions, ClerkMiddlewareOptions } from './types'; import { patchRequest } from './utils'; @@ -20,6 +22,15 @@ type RequestStateContextValue = { export const authFnContext = createContext<((options?: PendingSessionOptions) => AuthObject) | null>(null); export const requestStateContext = createContext(null); +// Probe used to detect a React Router `context` that is reused across requests. +// React Router gives each request its own context, so this is unset on a fresh +// one; if a different request already stamped it, the app is sharing a single +// context across requests (commonly a custom server or getLoadContext that +// returns one RouterContextProvider). Auth stays correct regardless thanks to +// requestAuthStorage, but a shared context can still leak the app's own +// per-request values, so we warn once. +const sharedContextProbe = createContext(null); + /** * Middleware that integrates Clerk authentication into your React Router application. * It checks the request's cookies and headers for a session JWT and, if found, @@ -38,6 +49,16 @@ export const requestStateContext = createContext => { return async (args, next) => { + // Runs before the first await so the get/set is atomic per request: if the + // context already carries a different request's marker, it is being shared. + const probedRequest = args.context.get(sharedContextProbe); + if (probedRequest && probedRequest !== args.request) { + logger.warnOnce( + 'Clerk: The React Router `context` was reused across requests. clerkMiddleware() keeps each request\'s auth isolated, so sign-in state stays correct, but sharing one context across requests is unsupported and can leak your own per-request data. This usually comes from a custom server or `getLoadContext()` that returns a single RouterContextProvider; return a new RouterContextProvider() for each request instead.', + ); + } + args.context.set(sharedContextProbe, args.request); + const clerkRequest = createClerkRequest(patchRequest(args.request)); const loadedOptions = loadOptions(args, options); @@ -103,27 +124,35 @@ export const clerkMiddleware = (options?: ClerkMiddlewareOptions): MiddlewareFun throw new Error('Clerk: handshake status without redirect'); } - args.context.set(authFnContext, (opts?: PendingSessionOptions) => requestState.toAuth(opts)); - args.context.set(requestStateContext, { - requestState, - additionalState: { - __keylessClaimUrl, - __keylessApiKeysUrl, - signInForceRedirectUrl: loadedOptions.signInForceRedirectUrl, - signUpForceRedirectUrl: loadedOptions.signUpForceRedirectUrl, - signInFallbackRedirectUrl: loadedOptions.signInFallbackRedirectUrl, - signUpFallbackRedirectUrl: loadedOptions.signUpFallbackRedirectUrl, - }, - }); + const authFn = (opts?: PendingSessionOptions) => requestState.toAuth(opts); + const additionalState: AdditionalStateOptions = { + __keylessClaimUrl, + __keylessApiKeysUrl, + signInForceRedirectUrl: loadedOptions.signInForceRedirectUrl, + signUpForceRedirectUrl: loadedOptions.signUpForceRedirectUrl, + signInFallbackRedirectUrl: loadedOptions.signInFallbackRedirectUrl, + signUpFallbackRedirectUrl: loadedOptions.signUpFallbackRedirectUrl, + }; - const response = await next(); + // Mirror the auth onto the React Router context for back-compat: anything + // reading authFnContext / requestStateContext directly keeps working. + args.context.set(authFnContext, authFn); + args.context.set(requestStateContext, { requestState, additionalState }); - if (requestState.headers) { - requestState.headers.forEach((value, key) => { - response.headers.append(key, value); - }); - } + // Primary path: bind this request's auth to the async execution scope so + // getAuth resolves it per-request even when the RR context is shared across + // requests (see requestAuthStorage). next(), and every loader/action it + // runs, executes inside this scope. + return requestAuthStorage.run({ authFn, requestState, additionalState }, async () => { + const response = await next(); - return response; + if (requestState.headers) { + requestState.headers.forEach((value, key) => { + response.headers.append(key, value); + }); + } + + return response; + }); }; }; diff --git a/packages/react-router/src/server/getAuth.ts b/packages/react-router/src/server/getAuth.ts index 3455666a15e..f2e0ea51743 100644 --- a/packages/react-router/src/server/getAuth.ts +++ b/packages/react-router/src/server/getAuth.ts @@ -9,6 +9,7 @@ import type { LoaderFunctionArgs } from 'react-router'; import { IsOptIntoMiddleware } from '../server/utils'; import { noLoaderArgsPassedInGetAuth } from '../utils/errors'; import { authFnContext } from './clerkMiddleware'; +import { requestAuthStorage } from './requestAuthStorage'; type GetAuthOptions = PendingSessionOptions & { acceptsToken?: AuthenticateRequestOptions['acceptsToken'] }; @@ -22,7 +23,11 @@ export const getAuth: GetAuthFn = (async ( const { acceptsToken, treatPendingAsSignedOut } = opts || {}; - const authObjectFn = IsOptIntoMiddleware(args.context) && args.context.get(authFnContext); + // Prefer the request-scoped store (immune to a shared RouterContextProvider); + // fall back to the RR context slot for runtimes without async storage or calls + // made outside the middleware's async scope. + const scopedAuthFn = requestAuthStorage.getStore()?.authFn; + const authObjectFn = scopedAuthFn ?? (IsOptIntoMiddleware(args.context) && args.context.get(authFnContext)); if (!authObjectFn) { throw new Error( 'Clerk: clerkMiddleware() not detected. Make sure you have installed the clerkMiddleware in your root route.', diff --git a/packages/react-router/src/server/requestAuthStorage.ts b/packages/react-router/src/server/requestAuthStorage.ts new file mode 100644 index 00000000000..d63f682bc92 --- /dev/null +++ b/packages/react-router/src/server/requestAuthStorage.ts @@ -0,0 +1,37 @@ +import { AsyncLocalStorage } from 'node:async_hooks'; + +import type { AuthObject } from '@clerk/backend'; +import type { RequestState } from '@clerk/backend/internal'; +import type { PendingSessionOptions } from '@clerk/shared/types'; + +import type { AdditionalStateOptions } from './types'; + +/** + * Per-request auth, bound to the async execution context rather than to the + * React Router request `context`. + * + * `clerkMiddleware` also mirrors the auth onto the RR context (see + * `authFnContext`), but that context is only per-request if React Router is the + * one that creates it. Apps that return a single module-scoped + * `RouterContextProvider` from a custom server / `getLoadContext` share one + * context across every request, so concurrent requests overwrite each other's + * auth and `getAuth` can resolve the wrong user (the reported cross-user bleed). + * + * `AsyncLocalStorage` keys by the running async context instead, which React + * Router gives each request regardless of how the context object was + * constructed, so `getAuth` reads the right user even when the context is + * shared. It also survives the action -> loader revalidation hop, where React + * Router mints a fresh `Request` object (so a request-keyed map would miss) but + * the async scope is unchanged. + * + * Note: this module imports `node:async_hooks`, matching `@clerk/nextjs`'s + * middleware storage. On a runtime without async storage, `getAuth` falls back + * to the RR context slot (today's behavior). + */ +export type RequestAuthState = { + authFn: (options?: PendingSessionOptions) => AuthObject; + requestState: RequestState; + additionalState: AdditionalStateOptions; +}; + +export const requestAuthStorage = new AsyncLocalStorage(); From 3912903d5af3fed9abbf4556335bbf44cc9383f8 Mon Sep 17 00:00:00 2001 From: Jacek Date: Wed, 17 Jun 2026 11:12:35 -0500 Subject: [PATCH 2/4] refactor(react-router): resolve getAuth per-request instead of via AsyncLocalStorage Replace the AsyncLocalStorage request scope with stateless re-derivation: getAuth and rootAuthLoader re-authenticate from args.request using the identity-free options the middleware stashes on the context, so a shared RouterContextProvider can never return another user, including across the action-to-loader hop. Drops the node:async_hooks dependency (uniform across Node/Workers/Deno/Bun) and covers the rootAuthLoader path. clerkMiddleware throws in dev / warns once in prod when a context is reused across requests. --- .changeset/rr-request-scoped-auth.md | 2 +- ... => clerkMiddleware.authIsolation.test.ts} | 47 ++++---- ...erkMiddleware.sharedContextWarning.test.ts | 58 ++++++--- .../src/server/__tests__/getAuth.test.ts | 24 ++-- .../server/__tests__/rootAuthLoader.test.ts | 81 ++++++------- .../src/server/clerkMiddleware.ts | 113 ++++++++++++------ packages/react-router/src/server/getAuth.ts | 22 ++-- .../src/server/requestAuthStorage.ts | 37 ------ .../react-router/src/server/rootAuthLoader.ts | 10 +- 9 files changed, 205 insertions(+), 189 deletions(-) rename packages/react-router/src/server/__tests__/{clerkMiddleware.requestScope.test.ts => clerkMiddleware.authIsolation.test.ts} (68%) delete mode 100644 packages/react-router/src/server/requestAuthStorage.ts diff --git a/.changeset/rr-request-scoped-auth.md b/.changeset/rr-request-scoped-auth.md index c0d0198ff5d..441efe448bc 100644 --- a/.changeset/rr-request-scoped-auth.md +++ b/.changeset/rr-request-scoped-auth.md @@ -2,4 +2,4 @@ '@clerk/react-router': patch --- -`getAuth()` now resolves the current request's authentication from request-scoped storage instead of relying solely on the React Router context. Apps that share a single `RouterContextProvider` across requests (for example a custom server or `getLoadContext` that returns one instance) no longer risk one request being served another user's auth under concurrency. Auth is bound to the per-request async execution scope and also survives React Router's action-to-loader revalidation. When a context that is reused across requests is detected, `clerkMiddleware()` now logs a one-time warning, since a shared context can still leak an application's own per-request data. +Fixed a cross-user authentication issue for apps that share a single React Router `context` across requests (for example a custom server or `getLoadContext` that returns one `RouterContextProvider` instance). `getAuth()` and `rootAuthLoader()` now resolve the current request's auth from that request rather than from a value cached on the context, so a shared context can no longer cause one request to be served another user's auth under concurrency, including across React Router's action-to-loader revalidation. `clerkMiddleware()` also now surfaces a context reused across requests: it throws in development and logs once in production, since a shared context can still leak an application's own per-request data. diff --git a/packages/react-router/src/server/__tests__/clerkMiddleware.requestScope.test.ts b/packages/react-router/src/server/__tests__/clerkMiddleware.authIsolation.test.ts similarity index 68% rename from packages/react-router/src/server/__tests__/clerkMiddleware.requestScope.test.ts rename to packages/react-router/src/server/__tests__/clerkMiddleware.authIsolation.test.ts index 5c3f05ce55f..270b80e3953 100644 --- a/packages/react-router/src/server/__tests__/clerkMiddleware.requestScope.test.ts +++ b/packages/react-router/src/server/__tests__/clerkMiddleware.authIsolation.test.ts @@ -1,14 +1,16 @@ -// Request-scoped auth isolation for clerkMiddleware + getAuth. +// Auth isolation for clerkMiddleware + getAuth under a shared React Router context. // -// clerkMiddleware binds each request's auth to an AsyncLocalStorage scope (see -// requestAuthStorage) and also mirrors it onto the React Router context. getAuth -// reads the async scope first. That keeps auth correct per request even when an -// app shares one RouterContextProvider across requests (the custom-server / -// getLoadContext footgun) - the failure mode reported as a cross-user "bleed". +// getAuth re-derives the current request's auth from `args.request` (a pure +// function of that request's cookies/headers), so even when an app shares one +// RouterContextProvider across requests (the custom-server / getLoadContext +// footgun) getAuth still returns the right user. These tests pin that: a shared +// context is isolated, a per-request context is isolated, and the action->loader +// hop (where React Router mints a fresh Request) still resolves the right user. // -// Only clerkClient and loadOptions are mocked; each request authenticates as a -// distinct user encoded in its URL, so the sole variable under test is whether -// the RouterContextProvider is shared or per-request. +// Only clerkClient and loadOptions are mocked; authenticateRequest resolves each +// request to the user encoded in its URL (?u=...), so the sole variable is which +// request getAuth re-derives from. A pk_live key keeps the shared-context probe +// in production mode (warn, not throw) so two requests can run on one context. import type { ClerkClient } from '@clerk/backend'; import { AuthStatus, TokenType } from '@clerk/backend/internal'; import type { LoaderFunctionArgs } from 'react-router'; @@ -26,19 +28,16 @@ vi.mock('../loadOptions'); const mockClerkClient = vi.mocked(clerkClient); const mockLoadOptions = vi.mocked(loadOptions); -// The "user" for a request is encoded in its URL (?u=...); authenticateRequest -// reads it back, so every request resolves to its own correct identity. function fakeStateForRequest(req: { url: string }) { const userId = new URL(req.url).searchParams.get('u'); return { status: AuthStatus.SignedIn, headers: new Headers(), + publishableKey: 'pk_live_xxx', toAuth: () => ({ userId, tokenType: TokenType.SessionToken }), }; } -// Flush microtasks so request A finishes authenticate + context.set and parks -// inside next() before request B runs. const flushMicrotasks = () => new Promise(resolve => setTimeout(resolve, 0)); async function readUserId(args: LoaderFunctionArgs): Promise { @@ -46,7 +45,7 @@ async function readUserId(args: LoaderFunctionArgs): Promise { +describe('clerkMiddleware + getAuth auth isolation', () => { beforeEach(() => { vi.clearAllMocks(); mockLoadOptions.mockReturnValue({ @@ -54,8 +53,9 @@ describe('clerkMiddleware + getAuth request-scoped isolation', () => { authorizedParties: [], signInUrl: '', signUpUrl: '', - secretKey: 'sk_test_...', - publishableKey: 'pk_test_...', + secretKey: 'sk_live_xxx', + // pk_live -> production instance -> shared-context probe warns (does not throw). + publishableKey: 'pk_live_xxx', } as unknown as ReturnType); mockClerkClient.mockReturnValue({ authenticateRequest: vi.fn(async (req: { url: string }) => fakeStateForRequest(req)), @@ -63,8 +63,8 @@ describe('clerkMiddleware + getAuth request-scoped isolation', () => { }); // Interleave two concurrent requests, each using `contextFor(request)`: - // 1. A's middleware authenticates, sets context, then parks inside next(). - // 2. B's middleware authenticates, sets context, reads its own auth in next(). + // 1. A's middleware runs, then parks inside next(). + // 2. B's middleware runs, B's loader reads its own auth in next(). // 3. A unparks and reads its auth. async function runInterleaved(contextFor: (req: Request) => RouterContextProvider) { const middleware = clerkMiddleware(); @@ -97,10 +97,6 @@ describe('clerkMiddleware + getAuth request-scoped isolation', () => { return results; } - // The regression: with a single shared RouterContextProvider, B's middleware - // overwrote the shared context's auth between A's middleware and A's loader - // read, so A was served B's identity. The async scope fixes this: A's loader - // runs inside A's store regardless of the shared context. it('keeps auth per-request with a shared RouterContextProvider', async () => { const shared = new RouterContextProvider(); const results = await runInterleaved(() => shared); @@ -120,10 +116,9 @@ describe('clerkMiddleware + getAuth request-scoped isolation', () => { expect(results.B).toBe('user_B'); }); - // On a mutation, React Router mints a NEW Request for the post-action loader - // revalidation (a request-keyed store would miss it), but the loader still runs - // inside the middleware's async scope, so getAuth resolves the right user even - // when reading via the fresh Request and a shared context. + // React Router mints a NEW Request for post-action loader revalidation. getAuth + // re-derives from whatever request the loader was invoked with, so it resolves + // the right user even reading via the fresh Request on a shared context. it('resolves the right user when the loader reads via a fresh Request (action -> loader)', async () => { const shared = new RouterContextProvider(); const middleware = clerkMiddleware(); diff --git a/packages/react-router/src/server/__tests__/clerkMiddleware.sharedContextWarning.test.ts b/packages/react-router/src/server/__tests__/clerkMiddleware.sharedContextWarning.test.ts index 8a7960207b0..846ecba4bcd 100644 --- a/packages/react-router/src/server/__tests__/clerkMiddleware.sharedContextWarning.test.ts +++ b/packages/react-router/src/server/__tests__/clerkMiddleware.sharedContextWarning.test.ts @@ -1,10 +1,10 @@ -// clerkMiddleware warns once when it detects a React Router context reused -// across requests (the shared-RouterContextProvider footgun). Auth itself is -// kept correct by requestAuthStorage; this warning tells the app its setup is -// unsupported and may leak its own per-request data. -// -// We spy on logger.warnOnce (the call site) rather than console.warn so the -// assertions are deterministic regardless of warnOnce's per-process dedup. +// clerkMiddleware surfaces a React Router context reused across requests (the +// shared-RouterContextProvider footgun). getAuth re-derives identity per request +// so auth stays correct, but a shared context can still leak the app's own +// per-request data, so the middleware throws in development and warns once in +// production. We drive dev vs prod via the publishable key (pk_test -> dev, +// pk_live -> prod) and spy on logger.warnOnce so assertions don't depend on its +// per-process dedup. import type { ClerkClient } from '@clerk/backend'; import { AuthStatus, TokenType } from '@clerk/backend/internal'; import { logger } from '@clerk/shared/logger'; @@ -27,32 +27,37 @@ function fakeStateForRequest(req: { url: string }) { return { status: AuthStatus.SignedIn, headers: new Headers(), + publishableKey: 'pk', toAuth: () => ({ userId, tokenType: TokenType.SessionToken }), }; } const noop = async () => new Response('ok'); +function mockKeys(publishableKey: string) { + mockLoadOptions.mockReturnValue({ + audience: '', + authorizedParties: [], + signInUrl: '', + signUpUrl: '', + secretKey: 'sk_xxx', + publishableKey, + } as unknown as ReturnType); +} + describe('clerkMiddleware shared-context detection', () => { let warnOnceSpy: ReturnType; beforeEach(() => { vi.clearAllMocks(); warnOnceSpy = vi.spyOn(logger, 'warnOnce').mockImplementation(() => {}); - mockLoadOptions.mockReturnValue({ - audience: '', - authorizedParties: [], - signInUrl: '', - signUpUrl: '', - secretKey: 'sk_test_...', - publishableKey: 'pk_test_...', - } as unknown as ReturnType); mockClerkClient.mockReturnValue({ authenticateRequest: vi.fn(async (req: { url: string }) => fakeStateForRequest(req)), } as unknown as ClerkClient); }); - it('warns once when two requests share one RouterContextProvider', async () => { + it('warns once (production) when two requests share one RouterContextProvider', async () => { + mockKeys('pk_live_xxx'); const middleware = clerkMiddleware(); const shared = new RouterContextProvider(); @@ -69,7 +74,26 @@ describe('clerkMiddleware shared-context detection', () => { expect(warnOnceSpy).toHaveBeenCalledWith(expect.stringContaining('reused across requests')); }); - it('does not warn when each request gets its own RouterContextProvider', async () => { + it('throws (development) when a second request reuses the context', async () => { + mockKeys('pk_test_xxx'); + const middleware = clerkMiddleware(); + const shared = new RouterContextProvider(); + + await middleware( + { request: new Request('http://app.test/?u=user_A'), context: shared } as unknown as LoaderFunctionArgs, + noop, + ); + + await expect( + middleware( + { request: new Request('http://app.test/?u=user_B'), context: shared } as unknown as LoaderFunctionArgs, + noop, + ), + ).rejects.toThrow(/reused across requests/); + }); + + it('does not warn or throw when each request gets its own RouterContextProvider', async () => { + mockKeys('pk_test_xxx'); const middleware = clerkMiddleware(); await middleware( diff --git a/packages/react-router/src/server/__tests__/getAuth.test.ts b/packages/react-router/src/server/__tests__/getAuth.test.ts index 31173c8c7d6..4958a398024 100644 --- a/packages/react-router/src/server/__tests__/getAuth.test.ts +++ b/packages/react-router/src/server/__tests__/getAuth.test.ts @@ -2,24 +2,32 @@ import { TokenType } from '@clerk/backend/internal'; import type { LoaderFunctionArgs } from 'react-router'; import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { authFnContext } from '../clerkMiddleware'; +import { clerkClient } from '../clerkClient'; +import { requestOptionsContext } from '../clerkMiddleware'; import { getAuth } from '../getAuth'; +vi.mock('../clerkClient'); +const mockClerkClient = vi.mocked(clerkClient); + describe('getAuth', () => { beforeEach(() => { vi.clearAllMocks(); process.env.CLERK_SECRET_KEY = 'sk_test_...'; + mockClerkClient.mockReturnValue({ + authenticateRequest: vi.fn().mockResolvedValue({ + headers: new Headers(), + toAuth: (options?: any) => ({ userId: 'user_xxx', tokenType: TokenType.SessionToken, ...options }), + }), + } as any); }); - it('should work when middleware context exists', async () => { + it('should re-derive auth from the request when middleware ran', async () => { + // Middleware stashes identity-free options; getAuth re-derives the user from + // the request via authenticateRequest rather than reading a cached value. const mockContext = { get: vi.fn().mockImplementation(contextKey => { - if (contextKey === authFnContext) { - return vi.fn().mockImplementation((options?: any) => ({ - userId: 'user_xxx', - tokenType: TokenType.SessionToken, - ...options, - })); + if (contextKey === requestOptionsContext) { + return { secretKey: 'sk_test_...', publishableKey: 'pk_test_...', acceptsToken: 'any' }; } return null; }), diff --git a/packages/react-router/src/server/__tests__/rootAuthLoader.test.ts b/packages/react-router/src/server/__tests__/rootAuthLoader.test.ts index a1da41dae47..2e3cb845d29 100644 --- a/packages/react-router/src/server/__tests__/rootAuthLoader.test.ts +++ b/packages/react-router/src/server/__tests__/rootAuthLoader.test.ts @@ -2,49 +2,54 @@ import { TokenType } from '@clerk/backend/internal'; import { data, type LoaderFunctionArgs } from 'react-router'; import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { authFnContext, requestStateContext } from '../clerkMiddleware'; +import { clerkClient } from '../clerkClient'; +import { authFnContext, requestOptionsContext, requestStateContext } from '../clerkMiddleware'; import { rootAuthLoader } from '../rootAuthLoader'; +vi.mock('../clerkClient'); +const mockClerkClient = vi.mocked(clerkClient); + describe('rootAuthLoader', () => { + const mockRequestState = { + toAuth: vi.fn().mockImplementation(() => ({ + userId: 'user_xxx', + tokenType: TokenType.SessionToken, + })), + headers: new Headers(), + status: 'signed-in', + }; + beforeEach(() => { vi.clearAllMocks(); process.env.CLERK_SECRET_KEY = 'sk_test_...'; + // rootAuthLoader re-derives the request state from the request rather than + // reading the value cached on the context. + mockClerkClient.mockReturnValue({ + authenticateRequest: vi.fn().mockResolvedValue(mockRequestState), + } as any); }); describe('with middleware context', () => { - const mockRequestState = { - toAuth: vi.fn().mockImplementation(() => ({ - userId: 'user_xxx', - tokenType: TokenType.SessionToken, - })), - headers: new Headers(), - status: 'signed-in', - }; - - const mockContext = { + const makeContext = (additionalState: Record = {}) => ({ get: vi.fn().mockImplementation(contextKey => { + if (contextKey === requestOptionsContext) { + return { secretKey: 'sk_test_...', publishableKey: 'pk_test_...', acceptsToken: 'any' }; + } if (contextKey === requestStateContext) { - return { - requestState: mockRequestState, - additionalState: {}, - }; + return { requestState: mockRequestState, additionalState }; } if (contextKey === authFnContext) { - return vi.fn().mockImplementation((options?: any) => ({ - userId: 'user_xxx', - tokenType: TokenType.SessionToken, - ...options, - })); + return vi.fn().mockReturnValue({ userId: 'user_xxx', tokenType: TokenType.SessionToken }); } return null; }), set: vi.fn(), - }; + }); const args = { - context: mockContext, + context: makeContext(), request: new Request('http://clerk.com'), - } as LoaderFunctionArgs; + } as unknown as LoaderFunctionArgs; it('should work with a callback', async () => { await rootAuthLoader(args, () => ({ data: 'test' })); @@ -108,31 +113,15 @@ describe('rootAuthLoader', () => { }); it('should forward redirect URL options from additionalState into clerkState', async () => { - const mockContext2 = { - get: vi.fn().mockImplementation(contextKey => { - if (contextKey === requestStateContext) { - return { - requestState: mockRequestState, - additionalState: { - signInForceRedirectUrl: '/dashboard', - signUpForceRedirectUrl: '/welcome', - signInFallbackRedirectUrl: '/home', - signUpFallbackRedirectUrl: '/home', - }, - }; - } - if (contextKey === authFnContext) { - return vi.fn().mockReturnValue({ userId: 'user_xxx', tokenType: TokenType.SessionToken }); - } - return null; - }), - set: vi.fn(), - }; - const result = (await rootAuthLoader({ - context: mockContext2, + context: makeContext({ + signInForceRedirectUrl: '/dashboard', + signUpForceRedirectUrl: '/welcome', + signInFallbackRedirectUrl: '/home', + signUpFallbackRedirectUrl: '/home', + }), request: new Request('http://clerk.com'), - } as LoaderFunctionArgs)) as any; + } as unknown as LoaderFunctionArgs)) as any; const internalState = result.clerkState.__internal_clerk_state; expect(internalState.__signInForceRedirectUrl).toBe('/dashboard'); diff --git a/packages/react-router/src/server/clerkMiddleware.ts b/packages/react-router/src/server/clerkMiddleware.ts index 15960aead62..5dfd89cca7e 100644 --- a/packages/react-router/src/server/clerkMiddleware.ts +++ b/packages/react-router/src/server/clerkMiddleware.ts @@ -1,6 +1,7 @@ import type { AuthObject } from '@clerk/backend'; -import type { RequestState } from '@clerk/backend/internal'; +import type { AuthenticateRequestOptions, RequestState } from '@clerk/backend/internal'; import { AuthStatus, constants, createClerkRequest } from '@clerk/backend/internal'; +import { isDevelopmentFromPublishableKey } from '@clerk/shared/keys'; import { logger } from '@clerk/shared/logger'; import { handleNetlifyCacheInDevInstance } from '@clerk/shared/netlifyCacheHandler'; import type { PendingSessionOptions } from '@clerk/shared/types'; @@ -9,10 +10,9 @@ import { createContext } from 'react-router'; import { clerkClient } from './clerkClient'; import { resolveKeysWithKeylessFallback } from './keyless/utils'; -import { loadOptions } from './loadOptions'; -import { requestAuthStorage } from './requestAuthStorage'; +import { type DataFunctionArgs, loadOptions } from './loadOptions'; import type { AdditionalStateOptions, ClerkMiddlewareOptions } from './types'; -import { patchRequest } from './utils'; +import { IsOptIntoMiddleware, patchRequest } from './utils'; type RequestStateContextValue = { requestState: RequestState; @@ -22,15 +22,51 @@ type RequestStateContextValue = { export const authFnContext = createContext<((options?: PendingSessionOptions) => AuthObject) | null>(null); export const requestStateContext = createContext(null); -// Probe used to detect a React Router `context` that is reused across requests. -// React Router gives each request its own context, so this is unset on a fresh -// one; if a different request already stamped it, the app is sharing a single -// context across requests (commonly a custom server or getLoadContext that -// returns one RouterContextProvider). Auth stays correct regardless thanks to -// requestAuthStorage, but a shared context can still leak the app's own -// per-request values, so we warn once. +// The resolved AuthenticateRequestOptions for this request (keys, urls, domain, +// proxy, sync options, keyless-resolved keys). This is app/instance-level config, +// NOT user identity, so getAuth/rootAuthLoader can read it back and re-derive the +// current user from the request even if the surrounding context is shared. +export const requestOptionsContext = createContext(null); + +// Probe used to detect a React Router `context` reused across requests. React +// Router gives each request its own context, so this is unset on a fresh one; if +// a different request already stamped it, the app is sharing one context across +// requests (commonly a custom server or getLoadContext that returns a single +// RouterContextProvider). getAuth re-derives identity per request so sign-in +// stays correct regardless, but a shared context can still leak the app's own +// per-request data, so we surface it (throw in dev, warn once in prod). const sharedContextProbe = createContext(null); +const sharedContextMessage = + 'Clerk: The React Router `context` is being reused across requests. clerkMiddleware() resolves each request\'s auth from that request, so sign-in state stays correct, but sharing one context across requests is unsupported and can leak your application\'s own per-request data. This usually comes from a custom server or `getLoadContext()` that returns a single RouterContextProvider; return a new RouterContextProvider() for each request instead.'; + +/** + * Re-derives the current request's auth state from `args.request`, using the + * resolved (identity-free) options the middleware stashed on the context. + * + * Identity comes purely from this request's cookies/headers, so a context shared + * across requests can never produce a cross-user result. The stashed options are + * app/instance-level config (keys, urls), not user identity, so reading them from + * a possibly-shared context is safe. Verification is networkless once the + * middleware has warmed the JWKS cache for this process. + */ +export async function authenticateFromRequest( + args: DataFunctionArgs, + acceptsToken: AuthenticateRequestOptions['acceptsToken'] = 'any', +): Promise> { + const options = IsOptIntoMiddleware(args.context) ? args.context.get(requestOptionsContext) : null; + if (!options) { + throw new Error( + 'Clerk: clerkMiddleware() not detected. Make sure you have installed the clerkMiddleware in your root route.', + ); + } + + return clerkClient(args, options).authenticateRequest(createClerkRequest(patchRequest(args.request)), { + ...options, + acceptsToken, + }); +} + /** * Middleware that integrates Clerk authentication into your React Router application. * It checks the request's cookies and headers for a session JWT and, if found, @@ -49,14 +85,10 @@ const sharedContextProbe = createContext(null); */ export const clerkMiddleware = (options?: ClerkMiddlewareOptions): MiddlewareFunction => { return async (args, next) => { - // Runs before the first await so the get/set is atomic per request: if the + // Runs before the first await so the read/write is atomic per request: if the // context already carries a different request's marker, it is being shared. const probedRequest = args.context.get(sharedContextProbe); - if (probedRequest && probedRequest !== args.request) { - logger.warnOnce( - 'Clerk: The React Router `context` was reused across requests. clerkMiddleware() keeps each request\'s auth isolated, so sign-in state stays correct, but sharing one context across requests is unsupported and can leak your own per-request data. This usually comes from a custom server or `getLoadContext()` that returns a single RouterContextProvider; return a new RouterContextProvider() for each request instead.', - ); - } + const contextReused = !!(probedRequest && probedRequest !== args.request); args.context.set(sharedContextProbe, args.request); const clerkRequest = createClerkRequest(patchRequest(args.request)); @@ -76,6 +108,15 @@ export const clerkMiddleware = (options?: ClerkMiddlewareOptions): MiddlewareFun loadedOptions.secretKey = secretKey; } + // Surface a shared context: fail loud in development so it is caught before + // production, log once in production (auth is already isolated per request). + if (contextReused) { + if (isDevelopmentFromPublishableKey(loadedOptions.publishableKey)) { + throw new Error(sharedContextMessage); + } + logger.warnOnce(sharedContextMessage); + } + // Pick only the properties needed by authenticateRequest. // Used when manually providing options to the middleware. const { @@ -92,7 +133,7 @@ export const clerkMiddleware = (options?: ClerkMiddlewareOptions): MiddlewareFun organizationSyncOptions, } = loadedOptions; - const requestState = await clerkClient(args, options).authenticateRequest(clerkRequest, { + const authenticateOptions: AuthenticateRequestOptions = { apiUrl, secretKey: loadedOptions.secretKey, jwtKey, @@ -107,7 +148,9 @@ export const clerkMiddleware = (options?: ClerkMiddlewareOptions): MiddlewareFun signInUrl, signUpUrl, acceptsToken: 'any', - }); + }; + + const requestState = await clerkClient(args, options).authenticateRequest(clerkRequest, authenticateOptions); const locationHeader = requestState.headers.get(constants.Headers.Location); if (locationHeader) { @@ -124,7 +167,6 @@ export const clerkMiddleware = (options?: ClerkMiddlewareOptions): MiddlewareFun throw new Error('Clerk: handshake status without redirect'); } - const authFn = (opts?: PendingSessionOptions) => requestState.toAuth(opts); const additionalState: AdditionalStateOptions = { __keylessClaimUrl, __keylessApiKeysUrl, @@ -134,25 +176,22 @@ export const clerkMiddleware = (options?: ClerkMiddlewareOptions): MiddlewareFun signUpFallbackRedirectUrl: loadedOptions.signUpFallbackRedirectUrl, }; - // Mirror the auth onto the React Router context for back-compat: anything - // reading authFnContext / requestStateContext directly keeps working. - args.context.set(authFnContext, authFn); + // Stash the identity-free resolved options so getAuth/rootAuthLoader can + // re-derive this request's user from the request itself (see + // authenticateFromRequest). authFnContext/requestStateContext are still + // written for back-compat, but identity is no longer read from them. + args.context.set(requestOptionsContext, authenticateOptions); + args.context.set(authFnContext, (opts?: PendingSessionOptions) => requestState.toAuth(opts)); args.context.set(requestStateContext, { requestState, additionalState }); - // Primary path: bind this request's auth to the async execution scope so - // getAuth resolves it per-request even when the RR context is shared across - // requests (see requestAuthStorage). next(), and every loader/action it - // runs, executes inside this scope. - return requestAuthStorage.run({ authFn, requestState, additionalState }, async () => { - const response = await next(); - - if (requestState.headers) { - requestState.headers.forEach((value, key) => { - response.headers.append(key, value); - }); - } + const response = await next(); + + if (requestState.headers) { + requestState.headers.forEach((value, key) => { + response.headers.append(key, value); + }); + } - return response; - }); + return response; }; }; diff --git a/packages/react-router/src/server/getAuth.ts b/packages/react-router/src/server/getAuth.ts index f2e0ea51743..1062c5f25fd 100644 --- a/packages/react-router/src/server/getAuth.ts +++ b/packages/react-router/src/server/getAuth.ts @@ -6,10 +6,8 @@ import { import type { PendingSessionOptions } from '@clerk/shared/types'; import type { LoaderFunctionArgs } from 'react-router'; -import { IsOptIntoMiddleware } from '../server/utils'; import { noLoaderArgsPassedInGetAuth } from '../utils/errors'; -import { authFnContext } from './clerkMiddleware'; -import { requestAuthStorage } from './requestAuthStorage'; +import { authenticateFromRequest } from './clerkMiddleware'; type GetAuthOptions = PendingSessionOptions & { acceptsToken?: AuthenticateRequestOptions['acceptsToken'] }; @@ -23,19 +21,15 @@ export const getAuth: GetAuthFn = (async ( const { acceptsToken, treatPendingAsSignedOut } = opts || {}; - // Prefer the request-scoped store (immune to a shared RouterContextProvider); - // fall back to the RR context slot for runtimes without async storage or calls - // made outside the middleware's async scope. - const scopedAuthFn = requestAuthStorage.getStore()?.authFn; - const authObjectFn = scopedAuthFn ?? (IsOptIntoMiddleware(args.context) && args.context.get(authFnContext)); - if (!authObjectFn) { - throw new Error( - 'Clerk: clerkMiddleware() not detected. Make sure you have installed the clerkMiddleware in your root route.', - ); - } + // Re-derive the current request's auth from `args.request` rather than reading + // a cached value off the React Router context. Identity is a pure function of + // this request's cookies/headers, so a context shared across requests can never + // return another user. clerkMiddleware (required) already ran the handshake and + // warmed the JWKS cache, so this verification is networkless. + const requestState = await authenticateFromRequest(args, 'any'); return getAuthObjectForAcceptedToken({ - authObject: authObjectFn({ treatPendingAsSignedOut }), + authObject: requestState.toAuth({ treatPendingAsSignedOut }), acceptsToken, }); }) as GetAuthFn; diff --git a/packages/react-router/src/server/requestAuthStorage.ts b/packages/react-router/src/server/requestAuthStorage.ts deleted file mode 100644 index d63f682bc92..00000000000 --- a/packages/react-router/src/server/requestAuthStorage.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { AsyncLocalStorage } from 'node:async_hooks'; - -import type { AuthObject } from '@clerk/backend'; -import type { RequestState } from '@clerk/backend/internal'; -import type { PendingSessionOptions } from '@clerk/shared/types'; - -import type { AdditionalStateOptions } from './types'; - -/** - * Per-request auth, bound to the async execution context rather than to the - * React Router request `context`. - * - * `clerkMiddleware` also mirrors the auth onto the RR context (see - * `authFnContext`), but that context is only per-request if React Router is the - * one that creates it. Apps that return a single module-scoped - * `RouterContextProvider` from a custom server / `getLoadContext` share one - * context across every request, so concurrent requests overwrite each other's - * auth and `getAuth` can resolve the wrong user (the reported cross-user bleed). - * - * `AsyncLocalStorage` keys by the running async context instead, which React - * Router gives each request regardless of how the context object was - * constructed, so `getAuth` reads the right user even when the context is - * shared. It also survives the action -> loader revalidation hop, where React - * Router mints a fresh `Request` object (so a request-keyed map would miss) but - * the async scope is unchanged. - * - * Note: this module imports `node:async_hooks`, matching `@clerk/nextjs`'s - * middleware storage. On a runtime without async storage, `getAuth` falls back - * to the RR context slot (today's behavior). - */ -export type RequestAuthState = { - authFn: (options?: PendingSessionOptions) => AuthObject; - requestState: RequestState; - additionalState: AdditionalStateOptions; -}; - -export const requestAuthStorage = new AsyncLocalStorage(); diff --git a/packages/react-router/src/server/rootAuthLoader.ts b/packages/react-router/src/server/rootAuthLoader.ts index 5a1d625e604..6cb2c76a5dc 100644 --- a/packages/react-router/src/server/rootAuthLoader.ts +++ b/packages/react-router/src/server/rootAuthLoader.ts @@ -2,7 +2,7 @@ import type { RequestState } from '@clerk/backend/internal'; import type { LoaderFunctionArgs } from 'react-router'; import { invalidRootLoaderCallbackReturn } from '../utils/errors'; -import { authFnContext, requestStateContext } from './clerkMiddleware'; +import { authenticateFromRequest, authFnContext, requestStateContext } from './clerkMiddleware'; import type { AdditionalStateOptions, LoaderFunctionArgsWithAuth, @@ -128,6 +128,10 @@ export const rootAuthLoader: RootAuthLoader = async ( ); } - const { requestState, additionalState } = contextValue; - return processRootAuthLoader(args, requestState, additionalState, handler); + // Re-derive the request state from this request rather than using the value + // cached on the (possibly shared) context, so identity can never bleed across + // concurrent requests. additionalState is identity-free app config and is safe + // to read from the context. + const requestState = await authenticateFromRequest(args, 'any'); + return processRootAuthLoader(args, requestState, contextValue.additionalState, handler); }; From 0d47e78b5d2d4e5b6738e6153a1e99c76bf7435a Mon Sep 17 00:00:00 2001 From: Jacek Date: Wed, 17 Jun 2026 11:24:14 -0500 Subject: [PATCH 3/4] refactor(react-router): warn-only on shared context, trim comments Auth is already isolated by per-request re-derivation, so drop the dev throw: clerkMiddleware now logs once in every environment when it detects a context reused across requests (escalate to an error in a future major). Also trims verbose code comments per review feedback. --- .changeset/rr-request-scoped-auth.md | 2 +- .../clerkMiddleware.authIsolation.test.ts | 16 ++---- ...erkMiddleware.sharedContextWarning.test.ts | 53 +++++-------------- .../src/server/clerkMiddleware.ts | 49 ++++------------- packages/react-router/src/server/getAuth.ts | 7 +-- .../react-router/src/server/rootAuthLoader.ts | 6 +-- 6 files changed, 32 insertions(+), 101 deletions(-) diff --git a/.changeset/rr-request-scoped-auth.md b/.changeset/rr-request-scoped-auth.md index 441efe448bc..77818337a86 100644 --- a/.changeset/rr-request-scoped-auth.md +++ b/.changeset/rr-request-scoped-auth.md @@ -2,4 +2,4 @@ '@clerk/react-router': patch --- -Fixed a cross-user authentication issue for apps that share a single React Router `context` across requests (for example a custom server or `getLoadContext` that returns one `RouterContextProvider` instance). `getAuth()` and `rootAuthLoader()` now resolve the current request's auth from that request rather than from a value cached on the context, so a shared context can no longer cause one request to be served another user's auth under concurrency, including across React Router's action-to-loader revalidation. `clerkMiddleware()` also now surfaces a context reused across requests: it throws in development and logs once in production, since a shared context can still leak an application's own per-request data. +Fixed a cross-user authentication issue for apps that share a single React Router `context` across requests (for example a custom server or `getLoadContext` that returns one `RouterContextProvider` instance). `getAuth()` and `rootAuthLoader()` now resolve the current request's auth from that request rather than from a value cached on the context, so a shared context can no longer cause one request to be served another user's auth under concurrency, including across React Router's action-to-loader revalidation. `clerkMiddleware()` also logs a warning once when it detects a context reused across requests, since a shared context can still leak an application's own per-request data. diff --git a/packages/react-router/src/server/__tests__/clerkMiddleware.authIsolation.test.ts b/packages/react-router/src/server/__tests__/clerkMiddleware.authIsolation.test.ts index 270b80e3953..e11a7a21cbf 100644 --- a/packages/react-router/src/server/__tests__/clerkMiddleware.authIsolation.test.ts +++ b/packages/react-router/src/server/__tests__/clerkMiddleware.authIsolation.test.ts @@ -1,16 +1,6 @@ -// Auth isolation for clerkMiddleware + getAuth under a shared React Router context. -// -// getAuth re-derives the current request's auth from `args.request` (a pure -// function of that request's cookies/headers), so even when an app shares one -// RouterContextProvider across requests (the custom-server / getLoadContext -// footgun) getAuth still returns the right user. These tests pin that: a shared -// context is isolated, a per-request context is isolated, and the action->loader -// hop (where React Router mints a fresh Request) still resolves the right user. -// -// Only clerkClient and loadOptions are mocked; authenticateRequest resolves each -// request to the user encoded in its URL (?u=...), so the sole variable is which -// request getAuth re-derives from. A pk_live key keeps the shared-context probe -// in production mode (warn, not throw) so two requests can run on one context. +// getAuth re-derives auth from `args.request`, so it returns the right user even +// when an app shares one RouterContextProvider across requests. authenticateRequest +// is mocked to resolve each request to the user encoded in its URL (?u=...). import type { ClerkClient } from '@clerk/backend'; import { AuthStatus, TokenType } from '@clerk/backend/internal'; import type { LoaderFunctionArgs } from 'react-router'; diff --git a/packages/react-router/src/server/__tests__/clerkMiddleware.sharedContextWarning.test.ts b/packages/react-router/src/server/__tests__/clerkMiddleware.sharedContextWarning.test.ts index 846ecba4bcd..e20125cfae2 100644 --- a/packages/react-router/src/server/__tests__/clerkMiddleware.sharedContextWarning.test.ts +++ b/packages/react-router/src/server/__tests__/clerkMiddleware.sharedContextWarning.test.ts @@ -1,10 +1,6 @@ -// clerkMiddleware surfaces a React Router context reused across requests (the -// shared-RouterContextProvider footgun). getAuth re-derives identity per request -// so auth stays correct, but a shared context can still leak the app's own -// per-request data, so the middleware throws in development and warns once in -// production. We drive dev vs prod via the publishable key (pk_test -> dev, -// pk_live -> prod) and spy on logger.warnOnce so assertions don't depend on its -// per-process dedup. +// clerkMiddleware warns once when it detects a React Router context reused across +// requests (the shared-RouterContextProvider footgun). We spy on logger.warnOnce +// so assertions don't depend on its per-process dedup. import type { ClerkClient } from '@clerk/backend'; import { AuthStatus, TokenType } from '@clerk/backend/internal'; import { logger } from '@clerk/shared/logger'; @@ -34,30 +30,26 @@ function fakeStateForRequest(req: { url: string }) { const noop = async () => new Response('ok'); -function mockKeys(publishableKey: string) { - mockLoadOptions.mockReturnValue({ - audience: '', - authorizedParties: [], - signInUrl: '', - signUpUrl: '', - secretKey: 'sk_xxx', - publishableKey, - } as unknown as ReturnType); -} - describe('clerkMiddleware shared-context detection', () => { let warnOnceSpy: ReturnType; beforeEach(() => { vi.clearAllMocks(); warnOnceSpy = vi.spyOn(logger, 'warnOnce').mockImplementation(() => {}); + mockLoadOptions.mockReturnValue({ + audience: '', + authorizedParties: [], + signInUrl: '', + signUpUrl: '', + secretKey: 'sk_live_xxx', + publishableKey: 'pk_live_xxx', + } as unknown as ReturnType); mockClerkClient.mockReturnValue({ authenticateRequest: vi.fn(async (req: { url: string }) => fakeStateForRequest(req)), } as unknown as ClerkClient); }); - it('warns once (production) when two requests share one RouterContextProvider', async () => { - mockKeys('pk_live_xxx'); + it('warns once when two requests share one RouterContextProvider', async () => { const middleware = clerkMiddleware(); const shared = new RouterContextProvider(); @@ -74,26 +66,7 @@ describe('clerkMiddleware shared-context detection', () => { expect(warnOnceSpy).toHaveBeenCalledWith(expect.stringContaining('reused across requests')); }); - it('throws (development) when a second request reuses the context', async () => { - mockKeys('pk_test_xxx'); - const middleware = clerkMiddleware(); - const shared = new RouterContextProvider(); - - await middleware( - { request: new Request('http://app.test/?u=user_A'), context: shared } as unknown as LoaderFunctionArgs, - noop, - ); - - await expect( - middleware( - { request: new Request('http://app.test/?u=user_B'), context: shared } as unknown as LoaderFunctionArgs, - noop, - ), - ).rejects.toThrow(/reused across requests/); - }); - - it('does not warn or throw when each request gets its own RouterContextProvider', async () => { - mockKeys('pk_test_xxx'); + it('does not warn when each request gets its own RouterContextProvider', async () => { const middleware = clerkMiddleware(); await middleware( diff --git a/packages/react-router/src/server/clerkMiddleware.ts b/packages/react-router/src/server/clerkMiddleware.ts index 5dfd89cca7e..b84033d0361 100644 --- a/packages/react-router/src/server/clerkMiddleware.ts +++ b/packages/react-router/src/server/clerkMiddleware.ts @@ -1,7 +1,6 @@ import type { AuthObject } from '@clerk/backend'; import type { AuthenticateRequestOptions, RequestState } from '@clerk/backend/internal'; import { AuthStatus, constants, createClerkRequest } from '@clerk/backend/internal'; -import { isDevelopmentFromPublishableKey } from '@clerk/shared/keys'; import { logger } from '@clerk/shared/logger'; import { handleNetlifyCacheInDevInstance } from '@clerk/shared/netlifyCacheHandler'; import type { PendingSessionOptions } from '@clerk/shared/types'; @@ -21,34 +20,16 @@ type RequestStateContextValue = { export const authFnContext = createContext<((options?: PendingSessionOptions) => AuthObject) | null>(null); export const requestStateContext = createContext(null); - -// The resolved AuthenticateRequestOptions for this request (keys, urls, domain, -// proxy, sync options, keyless-resolved keys). This is app/instance-level config, -// NOT user identity, so getAuth/rootAuthLoader can read it back and re-derive the -// current user from the request even if the surrounding context is shared. +// Identity-free resolved options, reused by getAuth/rootAuthLoader to re-derive the request's user. export const requestOptionsContext = createContext(null); - -// Probe used to detect a React Router `context` reused across requests. React -// Router gives each request its own context, so this is unset on a fresh one; if -// a different request already stamped it, the app is sharing one context across -// requests (commonly a custom server or getLoadContext that returns a single -// RouterContextProvider). getAuth re-derives identity per request so sign-in -// stays correct regardless, but a shared context can still leak the app's own -// per-request data, so we surface it (throw in dev, warn once in prod). const sharedContextProbe = createContext(null); const sharedContextMessage = 'Clerk: The React Router `context` is being reused across requests. clerkMiddleware() resolves each request\'s auth from that request, so sign-in state stays correct, but sharing one context across requests is unsupported and can leak your application\'s own per-request data. This usually comes from a custom server or `getLoadContext()` that returns a single RouterContextProvider; return a new RouterContextProvider() for each request instead.'; /** - * Re-derives the current request's auth state from `args.request`, using the - * resolved (identity-free) options the middleware stashed on the context. - * - * Identity comes purely from this request's cookies/headers, so a context shared - * across requests can never produce a cross-user result. The stashed options are - * app/instance-level config (keys, urls), not user identity, so reading them from - * a possibly-shared context is safe. Verification is networkless once the - * middleware has warmed the JWKS cache for this process. + * Re-derives the request's auth from `args.request` using the identity-free options + * stashed by clerkMiddleware, so a shared context can never return another user. */ export async function authenticateFromRequest( args: DataFunctionArgs, @@ -85,10 +66,13 @@ export async function authenticateFromRequest( */ export const clerkMiddleware = (options?: ClerkMiddlewareOptions): MiddlewareFunction => { return async (args, next) => { - // Runs before the first await so the read/write is atomic per request: if the - // context already carries a different request's marker, it is being shared. + // A context reused across requests means the app is sharing one + // RouterContextProvider. Auth stays correct (it is re-derived per request), + // but a shared context can leak the app's own per-request data, so warn once. const probedRequest = args.context.get(sharedContextProbe); - const contextReused = !!(probedRequest && probedRequest !== args.request); + if (probedRequest && probedRequest !== args.request) { + logger.warnOnce(sharedContextMessage); + } args.context.set(sharedContextProbe, args.request); const clerkRequest = createClerkRequest(patchRequest(args.request)); @@ -108,15 +92,6 @@ export const clerkMiddleware = (options?: ClerkMiddlewareOptions): MiddlewareFun loadedOptions.secretKey = secretKey; } - // Surface a shared context: fail loud in development so it is caught before - // production, log once in production (auth is already isolated per request). - if (contextReused) { - if (isDevelopmentFromPublishableKey(loadedOptions.publishableKey)) { - throw new Error(sharedContextMessage); - } - logger.warnOnce(sharedContextMessage); - } - // Pick only the properties needed by authenticateRequest. // Used when manually providing options to the middleware. const { @@ -176,10 +151,8 @@ export const clerkMiddleware = (options?: ClerkMiddlewareOptions): MiddlewareFun signUpFallbackRedirectUrl: loadedOptions.signUpFallbackRedirectUrl, }; - // Stash the identity-free resolved options so getAuth/rootAuthLoader can - // re-derive this request's user from the request itself (see - // authenticateFromRequest). authFnContext/requestStateContext are still - // written for back-compat, but identity is no longer read from them. + // Stash identity-free options for re-derivation; authFnContext/requestStateContext + // remain for back-compat but identity is no longer read from them. args.context.set(requestOptionsContext, authenticateOptions); args.context.set(authFnContext, (opts?: PendingSessionOptions) => requestState.toAuth(opts)); args.context.set(requestStateContext, { requestState, additionalState }); diff --git a/packages/react-router/src/server/getAuth.ts b/packages/react-router/src/server/getAuth.ts index 1062c5f25fd..a3d21c242a5 100644 --- a/packages/react-router/src/server/getAuth.ts +++ b/packages/react-router/src/server/getAuth.ts @@ -21,11 +21,8 @@ export const getAuth: GetAuthFn = (async ( const { acceptsToken, treatPendingAsSignedOut } = opts || {}; - // Re-derive the current request's auth from `args.request` rather than reading - // a cached value off the React Router context. Identity is a pure function of - // this request's cookies/headers, so a context shared across requests can never - // return another user. clerkMiddleware (required) already ran the handshake and - // warmed the JWKS cache, so this verification is networkless. + // Re-derive auth from this request rather than reading a cached value, so a + // shared context can never return another user. const requestState = await authenticateFromRequest(args, 'any'); return getAuthObjectForAcceptedToken({ diff --git a/packages/react-router/src/server/rootAuthLoader.ts b/packages/react-router/src/server/rootAuthLoader.ts index 6cb2c76a5dc..f5d23440eb2 100644 --- a/packages/react-router/src/server/rootAuthLoader.ts +++ b/packages/react-router/src/server/rootAuthLoader.ts @@ -128,10 +128,8 @@ export const rootAuthLoader: RootAuthLoader = async ( ); } - // Re-derive the request state from this request rather than using the value - // cached on the (possibly shared) context, so identity can never bleed across - // concurrent requests. additionalState is identity-free app config and is safe - // to read from the context. + // Re-derive from this request rather than the cached context value, so identity + // can never bleed across concurrent requests. const requestState = await authenticateFromRequest(args, 'any'); return processRootAuthLoader(args, requestState, contextValue.additionalState, handler); }; From f5a9200a946ae1fade8706189af48130a8db0243 Mon Sep 17 00:00:00 2001 From: Jacek Date: Wed, 17 Jun 2026 11:30:17 -0500 Subject: [PATCH 4/4] chore(react-router): formatting and lint fixes --- .../clerkMiddleware.authIsolation.test.ts | 6 ++++-- .../clerkMiddleware.sharedContextWarning.test.ts | 14 ++++++++++---- .../react-router/src/server/clerkMiddleware.ts | 2 +- 3 files changed, 15 insertions(+), 7 deletions(-) diff --git a/packages/react-router/src/server/__tests__/clerkMiddleware.authIsolation.test.ts b/packages/react-router/src/server/__tests__/clerkMiddleware.authIsolation.test.ts index e11a7a21cbf..508ec2e797c 100644 --- a/packages/react-router/src/server/__tests__/clerkMiddleware.authIsolation.test.ts +++ b/packages/react-router/src/server/__tests__/clerkMiddleware.authIsolation.test.ts @@ -48,7 +48,7 @@ describe('clerkMiddleware + getAuth auth isolation', () => { publishableKey: 'pk_live_xxx', } as unknown as ReturnType); mockClerkClient.mockReturnValue({ - authenticateRequest: vi.fn(async (req: { url: string }) => fakeStateForRequest(req)), + authenticateRequest: vi.fn((req: { url: string }) => Promise.resolve(fakeStateForRequest(req))), } as unknown as ClerkClient); }); @@ -98,7 +98,9 @@ describe('clerkMiddleware + getAuth auth isolation', () => { it('keeps auth per-request with a fresh RouterContextProvider per request', async () => { const perRequest = new Map(); const results = await runInterleaved(req => { - if (!perRequest.has(req)) perRequest.set(req, new RouterContextProvider()); + if (!perRequest.has(req)) { + perRequest.set(req, new RouterContextProvider()); + } return perRequest.get(req)!; }); diff --git a/packages/react-router/src/server/__tests__/clerkMiddleware.sharedContextWarning.test.ts b/packages/react-router/src/server/__tests__/clerkMiddleware.sharedContextWarning.test.ts index e20125cfae2..ef11a5ea320 100644 --- a/packages/react-router/src/server/__tests__/clerkMiddleware.sharedContextWarning.test.ts +++ b/packages/react-router/src/server/__tests__/clerkMiddleware.sharedContextWarning.test.ts @@ -28,7 +28,7 @@ function fakeStateForRequest(req: { url: string }) { }; } -const noop = async () => new Response('ok'); +const noop = () => Promise.resolve(new Response('ok')); describe('clerkMiddleware shared-context detection', () => { let warnOnceSpy: ReturnType; @@ -45,7 +45,7 @@ describe('clerkMiddleware shared-context detection', () => { publishableKey: 'pk_live_xxx', } as unknown as ReturnType); mockClerkClient.mockReturnValue({ - authenticateRequest: vi.fn(async (req: { url: string }) => fakeStateForRequest(req)), + authenticateRequest: vi.fn((req: { url: string }) => Promise.resolve(fakeStateForRequest(req))), } as unknown as ClerkClient); }); @@ -70,11 +70,17 @@ describe('clerkMiddleware shared-context detection', () => { const middleware = clerkMiddleware(); await middleware( - { request: new Request('http://app.test/?u=user_A'), context: new RouterContextProvider() } as unknown as LoaderFunctionArgs, + { + request: new Request('http://app.test/?u=user_A'), + context: new RouterContextProvider(), + } as unknown as LoaderFunctionArgs, noop, ); await middleware( - { request: new Request('http://app.test/?u=user_B'), context: new RouterContextProvider() } as unknown as LoaderFunctionArgs, + { + request: new Request('http://app.test/?u=user_B'), + context: new RouterContextProvider(), + } as unknown as LoaderFunctionArgs, noop, ); diff --git a/packages/react-router/src/server/clerkMiddleware.ts b/packages/react-router/src/server/clerkMiddleware.ts index b84033d0361..3271e797a41 100644 --- a/packages/react-router/src/server/clerkMiddleware.ts +++ b/packages/react-router/src/server/clerkMiddleware.ts @@ -25,7 +25,7 @@ export const requestOptionsContext = createContext(null); const sharedContextMessage = - 'Clerk: The React Router `context` is being reused across requests. clerkMiddleware() resolves each request\'s auth from that request, so sign-in state stays correct, but sharing one context across requests is unsupported and can leak your application\'s own per-request data. This usually comes from a custom server or `getLoadContext()` that returns a single RouterContextProvider; return a new RouterContextProvider() for each request instead.'; + "Clerk: The React Router `context` is being reused across requests. clerkMiddleware() resolves each request's auth from that request, so sign-in state stays correct, but sharing one context across requests is unsupported and can leak your application's own per-request data. This usually comes from a custom server or `getLoadContext()` that returns a single RouterContextProvider; return a new RouterContextProvider() for each request instead."; /** * Re-derives the request's auth from `args.request` using the identity-free options