diff --git a/.changeset/netlify-vary-cache-headers.md b/.changeset/netlify-vary-cache-headers.md new file mode 100644 index 00000000000..6a530cbbb71 --- /dev/null +++ b/.changeset/netlify-vary-cache-headers.md @@ -0,0 +1,10 @@ +--- +"@clerk/shared": patch +"@clerk/nextjs": patch +"@clerk/astro": patch +"@clerk/react-router": patch +"@clerk/nuxt": patch +"@clerk/tanstack-react-start": patch +--- + +Add `Netlify-Vary` header to prevent Netlify CDN from caching auth responses across different users/sessions. Sets `Netlify-Vary: cookie=__client_uat,cookie=__session` on all auth responses when running on Netlify, ensuring the CDN creates separate cache entries per auth state. diff --git a/packages/astro/src/server/clerk-middleware.ts b/packages/astro/src/server/clerk-middleware.ts index 808c540aa05..4f8685d5147 100644 --- a/packages/astro/src/server/clerk-middleware.ts +++ b/packages/astro/src/server/clerk-middleware.ts @@ -16,7 +16,7 @@ import { TokenType, } from '@clerk/backend/internal'; import { isDevelopmentFromSecretKey } from '@clerk/shared/keys'; -import { handleNetlifyCacheInDevInstance } from '@clerk/shared/netlifyCacheHandler'; +import { handleNetlifyCacheHeaders } from '@clerk/shared/netlifyCacheHandler'; import { isHttpOrHttps } from '@clerk/shared/proxy'; import type { PendingSessionOptions } from '@clerk/shared/types'; import { handleValueOrFn } from '@clerk/shared/utils'; @@ -121,14 +121,10 @@ export const clerkMiddleware: ClerkMiddleware = (...args: unknown[]): any => { createAuthenticateRequestOptions(clerkRequest, keylessOptions, context), ); + await handleNetlifyCacheHeaders(requestState); + const locationHeader = requestState.headers.get(constants.Headers.Location); if (locationHeader) { - handleNetlifyCacheInDevInstance({ - locationHeader, - requestStateHeaders: requestState.headers, - publishableKey: requestState.publishableKey, - }); - const res = new Response(null, { status: 307, headers: requestState.headers }); return decorateResponseWithObservabilityHeaders(res, requestState); } else if (requestState.status === AuthStatus.Handshake) { diff --git a/packages/nextjs/src/server/clerkMiddleware.ts b/packages/nextjs/src/server/clerkMiddleware.ts index c50949ef99f..34f012da724 100644 --- a/packages/nextjs/src/server/clerkMiddleware.ts +++ b/packages/nextjs/src/server/clerkMiddleware.ts @@ -22,7 +22,7 @@ import { } from '@clerk/backend/internal'; import { clerkFrontendApiProxy, DEFAULT_PROXY_PATH, matchProxyPath } from '@clerk/backend/proxy'; import { parsePublishableKey } from '@clerk/shared/keys'; -import { handleNetlifyCacheInDevInstance } from '@clerk/shared/netlifyCacheHandler'; +import { handleNetlifyCacheHeaders } from '@clerk/shared/netlifyCacheHandler'; import { notFound as nextjsNotFound } from 'next/navigation'; import type { NextMiddleware, NextRequest } from 'next/server'; import { NextResponse } from 'next/server'; @@ -221,14 +221,10 @@ export const clerkMiddleware = ((...args: unknown[]): NextMiddleware | NextMiddl reason: requestState.reason, })); + await handleNetlifyCacheHeaders(requestState); + const locationHeader = requestState.headers.get(constants.Headers.Location); if (locationHeader) { - handleNetlifyCacheInDevInstance({ - locationHeader, - requestStateHeaders: requestState.headers, - publishableKey: requestState.publishableKey, - }); - const res = NextResponse.redirect(requestState.headers.get(constants.Headers.Location) || locationHeader); requestState.headers.forEach((value, key) => { if (key === constants.Headers.Location) { diff --git a/packages/nuxt/src/runtime/server/clerkMiddleware.ts b/packages/nuxt/src/runtime/server/clerkMiddleware.ts index 555999938b5..caacfa6070c 100644 --- a/packages/nuxt/src/runtime/server/clerkMiddleware.ts +++ b/packages/nuxt/src/runtime/server/clerkMiddleware.ts @@ -1,6 +1,6 @@ import type { AuthenticateRequestOptions } from '@clerk/backend/internal'; import { AuthStatus, constants, getAuthObjectForAcceptedToken } from '@clerk/backend/internal'; -import { handleNetlifyCacheInDevInstance } from '@clerk/shared/netlifyCacheHandler'; +import { handleNetlifyCacheHeaders } from '@clerk/shared/netlifyCacheHandler'; import type { PendingSessionOptions } from '@clerk/shared/types'; import type { EventHandler } from 'h3'; import { createError, eventHandler, setResponseHeader } from 'h3'; @@ -87,13 +87,10 @@ export const clerkMiddleware: ClerkMiddleware = (...args: unknown[]) => { acceptsToken: 'any', }); + await handleNetlifyCacheHeaders(requestState); + const locationHeader = requestState.headers.get(constants.Headers.Location); if (locationHeader) { - handleNetlifyCacheInDevInstance({ - locationHeader, - requestStateHeaders: requestState.headers, - publishableKey: requestState.publishableKey, - }); // Trigger a handshake redirect return new Response(null, { status: 307, headers: requestState.headers }); } diff --git a/packages/react-router/src/server/clerkMiddleware.ts b/packages/react-router/src/server/clerkMiddleware.ts index b78f38e05c9..6ec35dcae96 100644 --- a/packages/react-router/src/server/clerkMiddleware.ts +++ b/packages/react-router/src/server/clerkMiddleware.ts @@ -1,7 +1,7 @@ import type { AuthObject } from '@clerk/backend'; import type { RequestState } from '@clerk/backend/internal'; import { AuthStatus, constants, createClerkRequest } from '@clerk/backend/internal'; -import { handleNetlifyCacheInDevInstance } from '@clerk/shared/netlifyCacheHandler'; +import { handleNetlifyCacheHeaders } from '@clerk/shared/netlifyCacheHandler'; import type { PendingSessionOptions } from '@clerk/shared/types'; import type { MiddlewareFunction } from 'react-router'; import { createContext } from 'react-router'; @@ -88,13 +88,10 @@ export const clerkMiddleware = (options?: ClerkMiddlewareOptions): MiddlewareFun __keylessApiKeysUrl, }); + await handleNetlifyCacheHeaders(requestState); + const locationHeader = requestState.headers.get(constants.Headers.Location); if (locationHeader) { - handleNetlifyCacheInDevInstance({ - locationHeader, - requestStateHeaders: requestState.headers, - publishableKey: requestState.publishableKey, - }); // Trigger a handshake redirect return new Response(null, { status: 307, headers: requestState.headers }); } diff --git a/packages/shared/src/__tests__/netlifyCacheHandler.spec.ts b/packages/shared/src/__tests__/netlifyCacheHandler.spec.ts index e4f9c3e7984..7435911ff63 100644 --- a/packages/shared/src/__tests__/netlifyCacheHandler.spec.ts +++ b/packages/shared/src/__tests__/netlifyCacheHandler.spec.ts @@ -1,85 +1,115 @@ /* eslint-disable turbo/no-undeclared-env-vars */ import { beforeEach, describe, expect, it } from 'vitest'; -import { CLERK_NETLIFY_CACHE_BUST_PARAM, handleNetlifyCacheInDevInstance } from '../netlifyCacheHandler'; +import { getCookieSuffix } from '../keys'; +import { CLERK_NETLIFY_CACHE_BUST_PARAM, handleNetlifyCacheHeaders } from '../netlifyCacheHandler'; -const mockPublishableKey = 'pk_test_YW55LW9mZabcZS1wYWdlX3BhZ2VfcG9pbnRlci1pZF90ZXN0XzE'; +const mockDevPublishableKey = 'pk_test_YW55LW9mZabcZS1wYWdlX3BhZ2VfcG9pbnRlci1pZF90ZXN0XzE'; +const mockProdPublishableKey = 'pk_live_YW55LW9mZabcZS1wYWdlX3BhZ2VfcG9pbnRlci1pZF90ZXN0XzE'; -describe('handleNetlifyCacheInDevInstance', () => { +describe('handleNetlifyCacheHeaders', () => { beforeEach(() => { delete process.env.URL; delete process.env.NETLIFY; + delete process.env.NETLIFY_FUNCTIONS_TOKEN; }); - it('should add cache bust parameter when on Netlify and in development', () => { - process.env.NETLIFY = 'true'; - process.env.URL = 'https://example.netlify.app'; + describe('Netlify-Vary header', () => { + it('should set Netlify-Vary header with unsuffixed and suffixed cookie names when on Netlify', async () => { + process.env.NETLIFY = 'true'; - const requestStateHeaders = new Headers({ - Location: 'https://example.netlify.app', + const headers = new Headers(); + await handleNetlifyCacheHeaders({ headers, publishableKey: mockProdPublishableKey }); + + const suffix = await getCookieSuffix(mockProdPublishableKey); + expect(headers.get('Netlify-Vary')).toBe( + `cookie=__client_uat,cookie=__session,cookie=__client_uat_${suffix},cookie=__session_${suffix}`, + ); }); - const locationHeader = requestStateHeaders.get('Location') || ''; - handleNetlifyCacheInDevInstance({ - locationHeader, - requestStateHeaders, - publishableKey: mockPublishableKey, + it('should not set Netlify-Vary header when not on Netlify', async () => { + const headers = new Headers(); + await handleNetlifyCacheHeaders({ headers, publishableKey: mockProdPublishableKey }); + + expect(headers.get('Netlify-Vary')).toBeNull(); }); - const locationUrl = new URL(requestStateHeaders.get('Location') || ''); - expect(locationUrl.searchParams.has(CLERK_NETLIFY_CACHE_BUST_PARAM)).toBe(true); - }); + it('should detect Netlify via NETLIFY_FUNCTIONS_TOKEN', async () => { + process.env.NETLIFY_FUNCTIONS_TOKEN = 'some-token'; - it('should not modify the Location header if it has the handshake param', () => { - process.env.URL = 'https://example.netlify.app'; - process.env.NETLIFY = 'true'; + const headers = new Headers(); + await handleNetlifyCacheHeaders({ headers, publishableKey: mockProdPublishableKey }); - const requestStateHeaders = new Headers({ - Location: 'https://example.netlify.app/redirect?__clerk_handshake=', + expect(headers.get('Netlify-Vary')).toContain('cookie=__client_uat'); }); - const locationHeader = requestStateHeaders.get('Location') || ''; - handleNetlifyCacheInDevInstance({ - locationHeader, - requestStateHeaders, - publishableKey: mockPublishableKey, + it('should detect Netlify via URL ending with netlify.app', async () => { + process.env.URL = 'https://example.netlify.app'; + + const headers = new Headers(); + await handleNetlifyCacheHeaders({ headers, publishableKey: mockProdPublishableKey }); + + expect(headers.get('Netlify-Vary')).toContain('cookie=__client_uat'); }); - expect(requestStateHeaders.get('Location')).toBe(locationHeader); + it('should fall back to unsuffixed cookies only when publishableKey is empty', async () => { + process.env.NETLIFY = 'true'; + + const headers = new Headers(); + await handleNetlifyCacheHeaders({ headers, publishableKey: '' }); + + expect(headers.get('Netlify-Vary')).toBe('cookie=__client_uat,cookie=__session'); + }); }); - it('should not modify the Location header if not on Netlify', () => { - const requestStateHeaders = new Headers({ - Location: 'https://example.netlify.app', + describe('cache bust parameter (dev instances)', () => { + it('should add cache bust parameter when on Netlify and in development with Location header', async () => { + process.env.NETLIFY = 'true'; + + const headers = new Headers({ Location: 'https://example.netlify.app' }); + await handleNetlifyCacheHeaders({ headers, publishableKey: mockDevPublishableKey }); + + const locationUrl = new URL(headers.get('Location') || ''); + expect(locationUrl.searchParams.has(CLERK_NETLIFY_CACHE_BUST_PARAM)).toBe(true); }); - const locationHeader = requestStateHeaders.get('Location') || ''; - handleNetlifyCacheInDevInstance({ - locationHeader, - requestStateHeaders, - publishableKey: mockPublishableKey, + it('should not add cache bust parameter for production instances', async () => { + process.env.NETLIFY = 'true'; + + const headers = new Headers({ Location: 'https://example.netlify.app' }); + await handleNetlifyCacheHeaders({ headers, publishableKey: mockProdPublishableKey }); + + const locationUrl = new URL(headers.get('Location') || ''); + expect(locationUrl.searchParams.has(CLERK_NETLIFY_CACHE_BUST_PARAM)).toBe(false); }); - expect(requestStateHeaders.get('Location')).toBe('https://example.netlify.app'); - }); + it('should not modify the Location header if it has the handshake param', async () => { + process.env.NETLIFY = 'true'; - it('should ignore the URL environment variable if it is not a string', () => { - // @ts-expect-error - Random object - process.env.URL = {}; - process.env.NETLIFY = 'true'; + const locationValue = 'https://example.netlify.app/redirect?__clerk_handshake='; + const headers = new Headers({ Location: locationValue }); + await handleNetlifyCacheHeaders({ headers, publishableKey: mockDevPublishableKey }); - const requestStateHeaders = new Headers({ - Location: 'https://example.netlify.app', + expect(headers.get('Location')).toBe(locationValue); }); - const locationHeader = requestStateHeaders.get('Location') || ''; - handleNetlifyCacheInDevInstance({ - locationHeader, - requestStateHeaders, - publishableKey: mockPublishableKey, + it('should not modify the Location header if not on Netlify', async () => { + const headers = new Headers({ Location: 'https://example.com' }); + await handleNetlifyCacheHeaders({ headers, publishableKey: mockDevPublishableKey }); + + expect(headers.get('Location')).toBe('https://example.com'); }); - const locationUrl = new URL(requestStateHeaders.get('Location') || ''); - expect(locationUrl.searchParams.has(CLERK_NETLIFY_CACHE_BUST_PARAM)).toBe(true); + it('should ignore the URL environment variable if it is not a string', async () => { + // @ts-expect-error - Random object + process.env.URL = {}; + process.env.NETLIFY = 'true'; + + const headers = new Headers({ Location: 'https://example.netlify.app' }); + await handleNetlifyCacheHeaders({ headers, publishableKey: mockDevPublishableKey }); + + const locationUrl = new URL(headers.get('Location') || ''); + expect(locationUrl.searchParams.has(CLERK_NETLIFY_CACHE_BUST_PARAM)).toBe(true); + }); }); }); diff --git a/packages/shared/src/netlifyCacheHandler.ts b/packages/shared/src/netlifyCacheHandler.ts index 3d43073a826..38984d0b7df 100644 --- a/packages/shared/src/netlifyCacheHandler.ts +++ b/packages/shared/src/netlifyCacheHandler.ts @@ -1,5 +1,5 @@ /* eslint-disable turbo/no-undeclared-env-vars */ -import { isDevelopmentFromPublishableKey } from './keys'; +import { getCookieSuffix, isDevelopmentFromPublishableKey } from './keys'; /** * Cache busting parameter for Netlify to prevent cached responses @@ -29,37 +29,61 @@ function isNetlifyRuntime(): boolean { } /** - * Prevents infinite redirects in Netlify's functions by adding a cache bust parameter - * to the original redirect URL. This ensures that Netlify doesn't serve a cached response - * during the handshake flow. + * Applies Netlify-specific cache headers to the request state. * - * The issue happens only on Clerk development instances running on Netlify. This is - * a workaround until we find a better solution. - * - * See https://answers.netlify.com/t/cache-handling-recommendation-for-authentication-handshake-redirects/143969/1. + * When running on Netlify, this function: + * 1. Sets `Netlify-Vary` with both unsuffixed and suffixed Clerk cookie names to instruct + * Netlify's CDN to create separate cache entries based on auth cookie values, preventing + * cached auth state from bleeding across users/sessions. + * 2. For development instances with a redirect (Location header), adds a cache-bust query + * parameter to prevent Netlify from serving cached responses during the handshake flow. * * @internal */ -export function handleNetlifyCacheInDevInstance({ - locationHeader, - requestStateHeaders, - publishableKey, -}: { - locationHeader: string; - requestStateHeaders: Headers; +export async function handleNetlifyCacheHeaders(requestState: { + headers: Headers; publishableKey: string; -}) { - const isOnNetlify = isNetlifyRuntime(); - const isDevelopmentInstance = isDevelopmentFromPublishableKey(publishableKey); +}): Promise { + if (!isNetlifyRuntime()) { + return; + } + + const { headers, publishableKey } = requestState; - if (isOnNetlify && isDevelopmentInstance) { + // Tell Netlify CDN to vary cache by auth cookie values (all instances, dev + prod). + // Include both unsuffixed and suffixed cookie names since Clerk uses suffixed cookies + // by default for newer instances (e.g. __client_uat_AbC12345). + const cookieNames = ['__client_uat', '__session']; + if (publishableKey) { + const suffix = await getCookieSuffix(publishableKey); + cookieNames.push(`__client_uat_${suffix}`, `__session_${suffix}`); + } + headers.set('Netlify-Vary', cookieNames.map(name => `cookie=${name}`).join(',')); + + // Add cache-bust param to redirect URL for dev instances to prevent cached redirects + const locationHeader = headers.get('Location'); + if (locationHeader && isDevelopmentFromPublishableKey(publishableKey)) { const hasHandshakeQueryParam = locationHeader.includes('__clerk_handshake'); - // If location header is the original URL before the handshake flow, add cache bust param - // The param should be removed in clerk-js if (!hasHandshakeQueryParam) { const url = new URL(locationHeader); url.searchParams.append(CLERK_NETLIFY_CACHE_BUST_PARAM, Date.now().toString()); - requestStateHeaders.set('Location', url.toString()); + headers.set('Location', url.toString()); } } } + +/** + * @deprecated Use `handleNetlifyCacheHeaders` instead. + * @internal + */ +export async function handleNetlifyCacheInDevInstance({ + locationHeader: _locationHeader, + requestStateHeaders, + publishableKey, +}: { + locationHeader: string; + requestStateHeaders: Headers; + publishableKey: string; +}) { + await handleNetlifyCacheHeaders({ headers: requestStateHeaders, publishableKey }); +} diff --git a/packages/tanstack-react-start/src/server/clerkMiddleware.ts b/packages/tanstack-react-start/src/server/clerkMiddleware.ts index 4b9b69b659c..eb4560f9578 100644 --- a/packages/tanstack-react-start/src/server/clerkMiddleware.ts +++ b/packages/tanstack-react-start/src/server/clerkMiddleware.ts @@ -1,6 +1,6 @@ import type { RequestState } from '@clerk/backend/internal'; import { AuthStatus, constants, createClerkRequest } from '@clerk/backend/internal'; -import { handleNetlifyCacheInDevInstance } from '@clerk/shared/netlifyCacheHandler'; +import { handleNetlifyCacheHeaders } from '@clerk/shared/netlifyCacheHandler'; import type { PendingSessionOptions } from '@clerk/shared/types'; import type { AnyRequestMiddleware } from '@tanstack/react-start'; import { createMiddleware } from '@tanstack/react-start'; @@ -48,13 +48,10 @@ export const clerkMiddleware = ( acceptsToken: 'any', }); + await handleNetlifyCacheHeaders(requestState); + const locationHeader = requestState.headers.get(constants.Headers.Location); if (locationHeader) { - handleNetlifyCacheInDevInstance({ - locationHeader, - requestStateHeaders: requestState.headers, - publishableKey: requestState.publishableKey, - }); // Trigger a handshake redirect // eslint-disable-next-line @typescript-eslint/only-throw-error throw new Response(null, { status: 307, headers: requestState.headers });