Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .changeset/netlify-vary-cache-headers.md
Original file line number Diff line number Diff line change
@@ -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.
10 changes: 3 additions & 7 deletions packages/astro/src/server/clerk-middleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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) {
Expand Down
10 changes: 3 additions & 7 deletions packages/nextjs/src/server/clerkMiddleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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) {
Expand Down
9 changes: 3 additions & 6 deletions packages/nuxt/src/runtime/server/clerkMiddleware.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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 });
}
Expand Down
9 changes: 3 additions & 6 deletions packages/react-router/src/server/clerkMiddleware.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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 });
}
Expand Down
130 changes: 80 additions & 50 deletions packages/shared/src/__tests__/netlifyCacheHandler.spec.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
});
68 changes: 46 additions & 22 deletions packages/shared/src/netlifyCacheHandler.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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<void> {
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 });
}
9 changes: 3 additions & 6 deletions packages/tanstack-react-start/src/server/clerkMiddleware.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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 });
Expand Down
Loading