Skip to content

Commit 2c89aed

Browse files
committed
fix(shared): Add Netlify-Vary header to prevent CDN caching of auth responses
Consolidate Netlify cache handling into a single `handleNetlifyCacheHeaders` function that sets `Netlify-Vary: cookie=__client_uat,cookie=__session` on all auth responses when running on Netlify. This prevents the CDN from serving stale session state across different users/sessions. The function is called once per request in each framework SDK middleware, right after `authenticateRequest` returns, replacing the previous dev-only cache-bust approach with proper CDN cache isolation for both dev and prod.
1 parent 20a5620 commit 2c89aed

8 files changed

Lines changed: 128 additions & 104 deletions

File tree

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
---
2+
"@clerk/shared": patch
3+
"@clerk/nextjs": patch
4+
"@clerk/astro": patch
5+
"@clerk/react-router": patch
6+
"@clerk/nuxt": patch
7+
"@clerk/tanstack-react-start": patch
8+
---
9+
10+
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.

packages/astro/src/server/clerk-middleware.ts

Lines changed: 3 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ import {
1616
TokenType,
1717
} from '@clerk/backend/internal';
1818
import { isDevelopmentFromSecretKey } from '@clerk/shared/keys';
19-
import { handleNetlifyCacheInDevInstance } from '@clerk/shared/netlifyCacheHandler';
19+
import { handleNetlifyCacheHeaders } from '@clerk/shared/netlifyCacheHandler';
2020
import { isHttpOrHttps } from '@clerk/shared/proxy';
2121
import type { PendingSessionOptions } from '@clerk/shared/types';
2222
import { handleValueOrFn } from '@clerk/shared/utils';
@@ -121,14 +121,10 @@ export const clerkMiddleware: ClerkMiddleware = (...args: unknown[]): any => {
121121
createAuthenticateRequestOptions(clerkRequest, keylessOptions, context),
122122
);
123123

124+
handleNetlifyCacheHeaders(requestState);
125+
124126
const locationHeader = requestState.headers.get(constants.Headers.Location);
125127
if (locationHeader) {
126-
handleNetlifyCacheInDevInstance({
127-
locationHeader,
128-
requestStateHeaders: requestState.headers,
129-
publishableKey: requestState.publishableKey,
130-
});
131-
132128
const res = new Response(null, { status: 307, headers: requestState.headers });
133129
return decorateResponseWithObservabilityHeaders(res, requestState);
134130
} else if (requestState.status === AuthStatus.Handshake) {

packages/nextjs/src/server/clerkMiddleware.ts

Lines changed: 3 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ import {
2222
} from '@clerk/backend/internal';
2323
import { clerkFrontendApiProxy, DEFAULT_PROXY_PATH, matchProxyPath } from '@clerk/backend/proxy';
2424
import { parsePublishableKey } from '@clerk/shared/keys';
25-
import { handleNetlifyCacheInDevInstance } from '@clerk/shared/netlifyCacheHandler';
25+
import { handleNetlifyCacheHeaders } from '@clerk/shared/netlifyCacheHandler';
2626
import { notFound as nextjsNotFound } from 'next/navigation';
2727
import type { NextMiddleware, NextRequest } from 'next/server';
2828
import { NextResponse } from 'next/server';
@@ -221,14 +221,10 @@ export const clerkMiddleware = ((...args: unknown[]): NextMiddleware | NextMiddl
221221
reason: requestState.reason,
222222
}));
223223

224+
handleNetlifyCacheHeaders(requestState);
225+
224226
const locationHeader = requestState.headers.get(constants.Headers.Location);
225227
if (locationHeader) {
226-
handleNetlifyCacheInDevInstance({
227-
locationHeader,
228-
requestStateHeaders: requestState.headers,
229-
publishableKey: requestState.publishableKey,
230-
});
231-
232228
const res = NextResponse.redirect(requestState.headers.get(constants.Headers.Location) || locationHeader);
233229
requestState.headers.forEach((value, key) => {
234230
if (key === constants.Headers.Location) {

packages/nuxt/src/runtime/server/clerkMiddleware.ts

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import type { AuthenticateRequestOptions } from '@clerk/backend/internal';
22
import { AuthStatus, constants, getAuthObjectForAcceptedToken } from '@clerk/backend/internal';
3-
import { handleNetlifyCacheInDevInstance } from '@clerk/shared/netlifyCacheHandler';
3+
import { handleNetlifyCacheHeaders } from '@clerk/shared/netlifyCacheHandler';
44
import type { PendingSessionOptions } from '@clerk/shared/types';
55
import type { EventHandler } from 'h3';
66
import { createError, eventHandler, setResponseHeader } from 'h3';
@@ -87,13 +87,10 @@ export const clerkMiddleware: ClerkMiddleware = (...args: unknown[]) => {
8787
acceptsToken: 'any',
8888
});
8989

90+
handleNetlifyCacheHeaders(requestState);
91+
9092
const locationHeader = requestState.headers.get(constants.Headers.Location);
9193
if (locationHeader) {
92-
handleNetlifyCacheInDevInstance({
93-
locationHeader,
94-
requestStateHeaders: requestState.headers,
95-
publishableKey: requestState.publishableKey,
96-
});
9794
// Trigger a handshake redirect
9895
return new Response(null, { status: 307, headers: requestState.headers });
9996
}

packages/react-router/src/server/clerkMiddleware.ts

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import type { AuthObject } from '@clerk/backend';
22
import type { RequestState } from '@clerk/backend/internal';
33
import { AuthStatus, constants, createClerkRequest } from '@clerk/backend/internal';
4-
import { handleNetlifyCacheInDevInstance } from '@clerk/shared/netlifyCacheHandler';
4+
import { handleNetlifyCacheHeaders } from '@clerk/shared/netlifyCacheHandler';
55
import type { PendingSessionOptions } from '@clerk/shared/types';
66
import type { MiddlewareFunction } from 'react-router';
77
import { createContext } from 'react-router';
@@ -88,13 +88,10 @@ export const clerkMiddleware = (options?: ClerkMiddlewareOptions): MiddlewareFun
8888
__keylessApiKeysUrl,
8989
});
9090

91+
handleNetlifyCacheHeaders(requestState);
92+
9193
const locationHeader = requestState.headers.get(constants.Headers.Location);
9294
if (locationHeader) {
93-
handleNetlifyCacheInDevInstance({
94-
locationHeader,
95-
requestStateHeaders: requestState.headers,
96-
publishableKey: requestState.publishableKey,
97-
});
9895
// Trigger a handshake redirect
9996
return new Response(null, { status: 307, headers: requestState.headers });
10097
}
Lines changed: 68 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -1,85 +1,102 @@
11
/* eslint-disable turbo/no-undeclared-env-vars */
22
import { beforeEach, describe, expect, it } from 'vitest';
33

4-
import { CLERK_NETLIFY_CACHE_BUST_PARAM, handleNetlifyCacheInDevInstance } from '../netlifyCacheHandler';
4+
import { CLERK_NETLIFY_CACHE_BUST_PARAM, handleNetlifyCacheHeaders } from '../netlifyCacheHandler';
55

6-
const mockPublishableKey = 'pk_test_YW55LW9mZabcZS1wYWdlX3BhZ2VfcG9pbnRlci1pZF90ZXN0XzE';
6+
const mockDevPublishableKey = 'pk_test_YW55LW9mZabcZS1wYWdlX3BhZ2VfcG9pbnRlci1pZF90ZXN0XzE';
7+
const mockProdPublishableKey = 'pk_live_YW55LW9mZabcZS1wYWdlX3BhZ2VfcG9pbnRlci1pZF90ZXN0XzE';
78

8-
describe('handleNetlifyCacheInDevInstance', () => {
9+
describe('handleNetlifyCacheHeaders', () => {
910
beforeEach(() => {
1011
delete process.env.URL;
1112
delete process.env.NETLIFY;
13+
delete process.env.NETLIFY_FUNCTIONS_TOKEN;
1214
});
1315

14-
it('should add cache bust parameter when on Netlify and in development', () => {
15-
process.env.NETLIFY = 'true';
16-
process.env.URL = 'https://example.netlify.app';
16+
describe('Netlify-Vary header', () => {
17+
it('should set Netlify-Vary header when on Netlify', () => {
18+
process.env.NETLIFY = 'true';
1719

18-
const requestStateHeaders = new Headers({
19-
Location: 'https://example.netlify.app',
20+
const headers = new Headers();
21+
handleNetlifyCacheHeaders({ headers, publishableKey: mockProdPublishableKey });
22+
23+
expect(headers.get('Netlify-Vary')).toBe('cookie=__client_uat,cookie=__session');
2024
});
21-
const locationHeader = requestStateHeaders.get('Location') || '';
2225

23-
handleNetlifyCacheInDevInstance({
24-
locationHeader,
25-
requestStateHeaders,
26-
publishableKey: mockPublishableKey,
26+
it('should not set Netlify-Vary header when not on Netlify', () => {
27+
const headers = new Headers();
28+
handleNetlifyCacheHeaders({ headers, publishableKey: mockProdPublishableKey });
29+
30+
expect(headers.get('Netlify-Vary')).toBeNull();
2731
});
2832

29-
const locationUrl = new URL(requestStateHeaders.get('Location') || '');
30-
expect(locationUrl.searchParams.has(CLERK_NETLIFY_CACHE_BUST_PARAM)).toBe(true);
31-
});
33+
it('should detect Netlify via NETLIFY_FUNCTIONS_TOKEN', () => {
34+
process.env.NETLIFY_FUNCTIONS_TOKEN = 'some-token';
3235

33-
it('should not modify the Location header if it has the handshake param', () => {
34-
process.env.URL = 'https://example.netlify.app';
35-
process.env.NETLIFY = 'true';
36+
const headers = new Headers();
37+
handleNetlifyCacheHeaders({ headers, publishableKey: mockProdPublishableKey });
3638

37-
const requestStateHeaders = new Headers({
38-
Location: 'https://example.netlify.app/redirect?__clerk_handshake=',
39+
expect(headers.get('Netlify-Vary')).toBe('cookie=__client_uat,cookie=__session');
3940
});
40-
const locationHeader = requestStateHeaders.get('Location') || '';
4141

42-
handleNetlifyCacheInDevInstance({
43-
locationHeader,
44-
requestStateHeaders,
45-
publishableKey: mockPublishableKey,
46-
});
42+
it('should detect Netlify via URL ending with netlify.app', () => {
43+
process.env.URL = 'https://example.netlify.app';
4744

48-
expect(requestStateHeaders.get('Location')).toBe(locationHeader);
45+
const headers = new Headers();
46+
handleNetlifyCacheHeaders({ headers, publishableKey: mockProdPublishableKey });
47+
48+
expect(headers.get('Netlify-Vary')).toBe('cookie=__client_uat,cookie=__session');
49+
});
4950
});
5051

51-
it('should not modify the Location header if not on Netlify', () => {
52-
const requestStateHeaders = new Headers({
53-
Location: 'https://example.netlify.app',
52+
describe('cache bust parameter (dev instances)', () => {
53+
it('should add cache bust parameter when on Netlify and in development with Location header', () => {
54+
process.env.NETLIFY = 'true';
55+
56+
const headers = new Headers({ Location: 'https://example.netlify.app' });
57+
handleNetlifyCacheHeaders({ headers, publishableKey: mockDevPublishableKey });
58+
59+
const locationUrl = new URL(headers.get('Location') || '');
60+
expect(locationUrl.searchParams.has(CLERK_NETLIFY_CACHE_BUST_PARAM)).toBe(true);
5461
});
55-
const locationHeader = requestStateHeaders.get('Location') || '';
5662

57-
handleNetlifyCacheInDevInstance({
58-
locationHeader,
59-
requestStateHeaders,
60-
publishableKey: mockPublishableKey,
63+
it('should not add cache bust parameter for production instances', () => {
64+
process.env.NETLIFY = 'true';
65+
66+
const headers = new Headers({ Location: 'https://example.netlify.app' });
67+
handleNetlifyCacheHeaders({ headers, publishableKey: mockProdPublishableKey });
68+
69+
const locationUrl = new URL(headers.get('Location') || '');
70+
expect(locationUrl.searchParams.has(CLERK_NETLIFY_CACHE_BUST_PARAM)).toBe(false);
6171
});
6272

63-
expect(requestStateHeaders.get('Location')).toBe('https://example.netlify.app');
64-
});
73+
it('should not modify the Location header if it has the handshake param', () => {
74+
process.env.NETLIFY = 'true';
6575

66-
it('should ignore the URL environment variable if it is not a string', () => {
67-
// @ts-expect-error - Random object
68-
process.env.URL = {};
69-
process.env.NETLIFY = 'true';
76+
const locationValue = 'https://example.netlify.app/redirect?__clerk_handshake=';
77+
const headers = new Headers({ Location: locationValue });
78+
handleNetlifyCacheHeaders({ headers, publishableKey: mockDevPublishableKey });
7079

71-
const requestStateHeaders = new Headers({
72-
Location: 'https://example.netlify.app',
80+
expect(headers.get('Location')).toBe(locationValue);
7381
});
74-
const locationHeader = requestStateHeaders.get('Location') || '';
7582

76-
handleNetlifyCacheInDevInstance({
77-
locationHeader,
78-
requestStateHeaders,
79-
publishableKey: mockPublishableKey,
83+
it('should not modify the Location header if not on Netlify', () => {
84+
const headers = new Headers({ Location: 'https://example.com' });
85+
handleNetlifyCacheHeaders({ headers, publishableKey: mockDevPublishableKey });
86+
87+
expect(headers.get('Location')).toBe('https://example.com');
8088
});
8189

82-
const locationUrl = new URL(requestStateHeaders.get('Location') || '');
83-
expect(locationUrl.searchParams.has(CLERK_NETLIFY_CACHE_BUST_PARAM)).toBe(true);
90+
it('should ignore the URL environment variable if it is not a string', () => {
91+
// @ts-expect-error - Random object
92+
process.env.URL = {};
93+
process.env.NETLIFY = 'true';
94+
95+
const headers = new Headers({ Location: 'https://example.netlify.app' });
96+
handleNetlifyCacheHeaders({ headers, publishableKey: mockDevPublishableKey });
97+
98+
const locationUrl = new URL(headers.get('Location') || '');
99+
expect(locationUrl.searchParams.has(CLERK_NETLIFY_CACHE_BUST_PARAM)).toBe(true);
100+
});
84101
});
85102
});

packages/shared/src/netlifyCacheHandler.ts

Lines changed: 35 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -29,37 +29,51 @@ function isNetlifyRuntime(): boolean {
2929
}
3030

3131
/**
32-
* Prevents infinite redirects in Netlify's functions by adding a cache bust parameter
33-
* to the original redirect URL. This ensures that Netlify doesn't serve a cached response
34-
* during the handshake flow.
32+
* Applies Netlify-specific cache headers to the request state.
3533
*
36-
* The issue happens only on Clerk development instances running on Netlify. This is
37-
* a workaround until we find a better solution.
38-
*
39-
* See https://answers.netlify.com/t/cache-handling-recommendation-for-authentication-handshake-redirects/143969/1.
34+
* When running on Netlify, this function:
35+
* 1. Sets `Netlify-Vary: cookie=__client_uat,cookie=__session` to instruct Netlify's CDN
36+
* to create separate cache entries based on auth cookie values, preventing cached auth
37+
* state from bleeding across users/sessions.
38+
* 2. For development instances with a redirect (Location header), adds a cache-bust query
39+
* parameter to prevent Netlify from serving cached responses during the handshake flow.
4040
*
4141
* @internal
4242
*/
43+
export function handleNetlifyCacheHeaders(requestState: { headers: Headers; publishableKey: string }): void {
44+
if (!isNetlifyRuntime()) {
45+
return;
46+
}
47+
48+
const { headers, publishableKey } = requestState;
49+
50+
// Tell Netlify CDN to vary cache by auth cookie values (all instances, dev + prod)
51+
headers.set('Netlify-Vary', 'cookie=__client_uat,cookie=__session');
52+
53+
// Add cache-bust param to redirect URL for dev instances to prevent cached redirects
54+
const locationHeader = headers.get('Location');
55+
if (locationHeader && isDevelopmentFromPublishableKey(publishableKey)) {
56+
const hasHandshakeQueryParam = locationHeader.includes('__clerk_handshake');
57+
if (!hasHandshakeQueryParam) {
58+
const url = new URL(locationHeader);
59+
url.searchParams.append(CLERK_NETLIFY_CACHE_BUST_PARAM, Date.now().toString());
60+
headers.set('Location', url.toString());
61+
}
62+
}
63+
}
64+
65+
/**
66+
* @deprecated Use `handleNetlifyCacheHeaders` instead.
67+
* @internal
68+
*/
4369
export function handleNetlifyCacheInDevInstance({
44-
locationHeader,
70+
locationHeader: _locationHeader,
4571
requestStateHeaders,
4672
publishableKey,
4773
}: {
4874
locationHeader: string;
4975
requestStateHeaders: Headers;
5076
publishableKey: string;
5177
}) {
52-
const isOnNetlify = isNetlifyRuntime();
53-
const isDevelopmentInstance = isDevelopmentFromPublishableKey(publishableKey);
54-
55-
if (isOnNetlify && isDevelopmentInstance) {
56-
const hasHandshakeQueryParam = locationHeader.includes('__clerk_handshake');
57-
// If location header is the original URL before the handshake flow, add cache bust param
58-
// The param should be removed in clerk-js
59-
if (!hasHandshakeQueryParam) {
60-
const url = new URL(locationHeader);
61-
url.searchParams.append(CLERK_NETLIFY_CACHE_BUST_PARAM, Date.now().toString());
62-
requestStateHeaders.set('Location', url.toString());
63-
}
64-
}
78+
handleNetlifyCacheHeaders({ headers: requestStateHeaders, publishableKey });
6579
}

packages/tanstack-react-start/src/server/clerkMiddleware.ts

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import type { RequestState } from '@clerk/backend/internal';
22
import { AuthStatus, constants, createClerkRequest } from '@clerk/backend/internal';
3-
import { handleNetlifyCacheInDevInstance } from '@clerk/shared/netlifyCacheHandler';
3+
import { handleNetlifyCacheHeaders } from '@clerk/shared/netlifyCacheHandler';
44
import type { PendingSessionOptions } from '@clerk/shared/types';
55
import type { AnyRequestMiddleware } from '@tanstack/react-start';
66
import { createMiddleware } from '@tanstack/react-start';
@@ -48,13 +48,10 @@ export const clerkMiddleware = (
4848
acceptsToken: 'any',
4949
});
5050

51+
handleNetlifyCacheHeaders(requestState);
52+
5153
const locationHeader = requestState.headers.get(constants.Headers.Location);
5254
if (locationHeader) {
53-
handleNetlifyCacheInDevInstance({
54-
locationHeader,
55-
requestStateHeaders: requestState.headers,
56-
publishableKey: requestState.publishableKey,
57-
});
5855
// Trigger a handshake redirect
5956
// eslint-disable-next-line @typescript-eslint/only-throw-error
6057
throw new Response(null, { status: 307, headers: requestState.headers });

0 commit comments

Comments
 (0)