Skip to content

fix(clerk-js,astro,nuxt,tanstack-react-start,remix,react-router): Introduce helper function to prevent infinite handshake in Netlify #5656

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 22 commits into from
Apr 30, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
d9dabe0
chore: Handle Netlify and Clerk development instance redirect loop
wobsoriano Apr 17, 2025
9056928
test: update structure
wobsoriano Apr 17, 2025
b4bb1e4
chore: add internal prop
wobsoriano Apr 17, 2025
ee0dd4d
test: add basic tests
wobsoriano Apr 17, 2025
182acc7
chore: some comments
wobsoriano Apr 17, 2025
fa50750
chore: test tanstack netlify env var
wobsoriano Apr 17, 2025
378d5e9
chore: check netlify deployment
wobsoriano Apr 17, 2025
c91af93
chore: fix tests
wobsoriano Apr 17, 2025
c02bca6
chore: add changeset
wobsoriano Apr 17, 2025
a85981d
chore: adjust clerk.browser.js bundle max size
wobsoriano Apr 17, 2025
46a7421
Merge branch 'main' into rob/eco-603-netlify-handshake-fix
wobsoriano Apr 17, 2025
f939de3
Update packages/shared/src/netlifyCacheHandler.ts
wobsoriano Apr 22, 2025
851f3da
Merge branch 'main' into rob/eco-603-netlify-handshake-fix
wobsoriano Apr 22, 2025
6f23b2f
Merge branch 'main' into rob/eco-603-netlify-handshake-fix
wobsoriano Apr 22, 2025
6bf1121
chore: add fallback check for netlify env
wobsoriano Apr 22, 2025
b3ea30a
chore: ignore undeclared env vars
wobsoriano Apr 22, 2025
649cda4
chore: fix tests
wobsoriano Apr 22, 2025
0cfc484
Merge branch 'main' into rob/eco-603-netlify-handshake-fix
wobsoriano Apr 23, 2025
03abef9
Merge branch 'main' into rob/eco-603-netlify-handshake-fix
wobsoriano Apr 29, 2025
54c1bfa
Merge branch 'main' into rob/eco-603-netlify-handshake-fix
wobsoriano Apr 29, 2025
3f22e0c
Merge branch 'main' into rob/eco-603-netlify-handshake-fix
wobsoriano Apr 30, 2025
4eb903a
chore: add .test. to cache handler filename
wobsoriano Apr 30, 2025
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
11 changes: 11 additions & 0 deletions .changeset/plenty-hounds-sort.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
"@clerk/astro": patch
"@clerk/clerk-js": patch
"@clerk/nuxt": patch
"@clerk/react-router": patch
"@clerk/remix": patch
"@clerk/shared": patch
"@clerk/tanstack-react-start": patch
---

Fix handshake redirect loop in applications deployed to Netlify with a Clerk development instance.
5 changes: 1 addition & 4 deletions packages/astro/src/integration/create-integration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -125,10 +125,7 @@ function createIntegration<Params extends HotloadAstroClerkIntegrationParams>()
'page',
`
${command === 'dev' ? `console.log("${packageName}","Initialize Clerk: page")` : ''}
import { removeNetlifyCacheBustParam, runInjectionScript, swapDocument } from "${buildImportPath}";

// Fix an issue with infinite redirect in Netlify and Clerk dev instance
removeNetlifyCacheBustParam();
import { runInjectionScript, swapDocument } from "${buildImportPath}";

// Taken from https://github.com/withastro/astro/blob/e10b03e88c22592fbb42d7245b65c4f486ab736d/packages/astro/src/transitions/router.ts#L39.
// Importing it directly from astro:transitions/client breaks custom client-side routing
Expand Down
1 change: 0 additions & 1 deletion packages/astro/src/internal/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,4 +14,3 @@ export { runInjectionScript };

export { generateSafeId } from './utils/generateSafeId';
export { swapDocument } from './swap-document';
export { NETLIFY_CACHE_BUST_PARAM, removeNetlifyCacheBustParam } from './remove-query-param';
13 changes: 0 additions & 13 deletions packages/astro/src/internal/remove-query-param.ts

This file was deleted.

29 changes: 7 additions & 22 deletions packages/astro/src/server/clerk-middleware.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
import type { AuthObject, ClerkClient } from '@clerk/backend';
import type { AuthenticateRequestOptions, ClerkRequest, RedirectFun, RequestState } from '@clerk/backend/internal';
import { AuthStatus, constants, createClerkRequest, createRedirect } from '@clerk/backend/internal';
import { isDevelopmentFromPublishableKey, isDevelopmentFromSecretKey } from '@clerk/shared/keys';
import { isDevelopmentFromSecretKey } from '@clerk/shared/keys';
import { handleNetlifyCacheInDevInstance } from '@clerk/shared/netlifyCacheHandler';
import { isHttpOrHttps } from '@clerk/shared/proxy';
import { handleValueOrFn } from '@clerk/shared/utils';
import type { APIContext } from 'astro';

import { authAsyncStorage } from '#async-local-storage';

import { NETLIFY_CACHE_BUST_PARAM } from '../internal';
import { buildClerkHotloadScript } from './build-clerk-hotload-script';
import { clerkClient } from './clerk-client';
import { createCurrentUser } from './current-user';
Expand Down Expand Up @@ -74,7 +74,11 @@ export const clerkMiddleware: ClerkMiddleware = (...args: unknown[]): any => {

const locationHeader = requestState.headers.get(constants.Headers.Location);
if (locationHeader) {
handleNetlifyCacheInDevInstance(locationHeader, requestState);
handleNetlifyCacheInDevInstance({
locationHeader,
requestStateHeaders: requestState.headers,
publishableKey: requestState.publishableKey,
});

const res = new Response(null, { status: 307, headers: requestState.headers });
return decorateResponseWithObservabilityHeaders(res, requestState);
Expand Down Expand Up @@ -234,25 +238,6 @@ Check if signInUrl is missing from your configuration or if it is not an absolut
PUBLIC_CLERK_SIGN_IN_URL='SOME_URL'
PUBLIC_CLERK_IS_SATELLITE='true'`;

/**
* Prevents infinite redirects in Netlify's functions
* by adding a cache bust parameter to the original redirect URL. This ensures Netlify
* doesn't serve a cached response during the authentication flow.
*/
function handleNetlifyCacheInDevInstance(locationHeader: string, requestState: RequestState) {
// Only run on Netlify environment and Clerk development instance
// eslint-disable-next-line turbo/no-undeclared-env-vars
if (import.meta.env.NETLIFY && isDevelopmentFromPublishableKey(requestState.publishableKey)) {
const hasHandshakeQueryParam = locationHeader.includes('__clerk_handshake');
// If location header is the original URL before the handshake redirects, add cache bust param
if (!hasHandshakeQueryParam) {
const url = new URL(locationHeader);
url.searchParams.append(NETLIFY_CACHE_BUST_PARAM, Date.now().toString());
requestState.headers.set('Location', url.toString());
}
}
}

function decorateAstroLocal(clerkRequest: ClerkRequest, context: APIContext, requestState: RequestState) {
const { reason, message, status, token } = requestState;
context.locals.authToken = token;
Expand Down
2 changes: 2 additions & 0 deletions packages/clerk-js/src/core/clerk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { ClerkRuntimeError, EmailLinkErrorCodeStatus, is4xxError, isClerkAPIResp
import { parsePublishableKey } from '@clerk/shared/keys';
import { LocalStorageBroadcastChannel } from '@clerk/shared/localStorageBroadcastChannel';
import { logger } from '@clerk/shared/logger';
import { CLERK_NETLIFY_CACHE_BUST_PARAM } from '@clerk/shared/netlifyCacheHandler';
import { isHttpOrHttps, isValidProxyUrl, proxyUrlToAbsoluteURL } from '@clerk/shared/proxy';
import {
eventPrebuiltComponentMounted,
Expand Down Expand Up @@ -2613,6 +2614,7 @@ export class Clerk implements ClerkInterface {
#clearClerkQueryParams = () => {
try {
removeClerkQueryParam(CLERK_SYNCED);
removeClerkQueryParam(CLERK_NETLIFY_CACHE_BUST_PARAM);
// @nikos: we're looking into dropping this param completely
// in the meantime, we're removing it here to keep the URL clean
removeClerkQueryParam(CLERK_SUFFIXED_COOKIES);
Expand Down
2 changes: 2 additions & 0 deletions packages/clerk-js/src/utils/getClerkQueryParam.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { EmailLinkErrorCodeStatus } from '@clerk/shared/error';
import { CLERK_NETLIFY_CACHE_BUST_PARAM } from '@clerk/shared/netlifyCacheHandler';

import { CLERK_SATELLITE_URL, CLERK_SUFFIXED_COOKIES, CLERK_SYNCED } from '../core/constants';

Expand All @@ -10,6 +11,7 @@ const _ClerkQueryParams = [
'__clerk_modal_state',
'__clerk_handshake',
'__clerk_help',
CLERK_NETLIFY_CACHE_BUST_PARAM,
CLERK_SYNCED,
CLERK_SATELLITE_URL,
CLERK_SUFFIXED_COOKIES,
Expand Down
6 changes: 6 additions & 0 deletions packages/nuxt/src/runtime/server/clerkMiddleware.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { AuthenticateRequestOptions } from '@clerk/backend/internal';
import { AuthStatus, constants } from '@clerk/backend/internal';
import { deprecated } from '@clerk/shared/deprecated';
import { handleNetlifyCacheInDevInstance } from '@clerk/shared/netlifyCacheHandler';
import type { EventHandler } from 'h3';
import { createError, eventHandler, setResponseHeader } from 'h3';

Expand Down Expand Up @@ -84,6 +85,11 @@ export const clerkMiddleware: ClerkMiddleware = (...args: unknown[]) => {

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
12 changes: 9 additions & 3 deletions packages/react-router/src/ssr/authenticateRequest.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { createClerkClient } from '@clerk/backend';
import type { AuthenticateRequestOptions, SignedInState, SignedOutState } from '@clerk/backend/internal';
import { AuthStatus } from '@clerk/backend/internal';
import { AuthStatus, constants } from '@clerk/backend/internal';
import { handleNetlifyCacheInDevInstance } from '@clerk/shared/netlifyCacheHandler';

import type { LoaderFunctionArgs } from './types';
import { patchRequest } from './utils';
Expand Down Expand Up @@ -33,8 +34,13 @@ export async function authenticateRequest(
afterSignUpUrl,
});

const hasLocationHeader = requestState.headers.get('location');
if (hasLocationHeader) {
const locationHeader = requestState.headers.get(constants.Headers.Location);
if (locationHeader) {
handleNetlifyCacheInDevInstance({
locationHeader,
requestStateHeaders: requestState.headers,
publishableKey: requestState.publishableKey,
});
// triggering a handshake redirect
// eslint-disable-next-line @typescript-eslint/only-throw-error
throw new Response(null, { status: 307, headers: requestState.headers });
Expand Down
12 changes: 9 additions & 3 deletions packages/remix/src/ssr/authenticateRequest.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { createClerkClient } from '@clerk/backend';
import type { AuthenticateRequestOptions, SignedInState, SignedOutState } from '@clerk/backend/internal';
import { AuthStatus } from '@clerk/backend/internal';
import { AuthStatus, constants } from '@clerk/backend/internal';
import { handleNetlifyCacheInDevInstance } from '@clerk/shared/netlifyCacheHandler';

import type { LoaderFunctionArgs } from './types';
import { patchRequest } from './utils';
Expand Down Expand Up @@ -33,8 +34,13 @@ export async function authenticateRequest(
afterSignUpUrl,
});

const hasLocationHeader = requestState.headers.get('location');
if (hasLocationHeader) {
const locationHeader = requestState.headers.get(constants.Headers.Location);
if (locationHeader) {
handleNetlifyCacheInDevInstance({
locationHeader,
requestStateHeaders: requestState.headers,
publishableKey: requestState.publishableKey,
});
// triggering a handshake redirect
// eslint-disable-next-line @typescript-eslint/only-throw-error
throw new Response(null, { status: 307, headers: requestState.headers });
Expand Down
1 change: 1 addition & 0 deletions packages/shared/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,7 @@
"organization",
"jwtPayloadParser",
"eventBus",
"netlifyCacheHandler",
"clerkEventBus"
],
"scripts": {
Expand Down
63 changes: 63 additions & 0 deletions packages/shared/src/__tests__/netlifyCacheHandler.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
/* eslint-disable turbo/no-undeclared-env-vars */
import { CLERK_NETLIFY_CACHE_BUST_PARAM, handleNetlifyCacheInDevInstance } from '../netlifyCacheHandler';

const mockPublishableKey = 'pk_test_YW55LW9mZabcZS1wYWdlX3BhZ2VfcG9pbnRlci1pZF90ZXN0XzE';

describe('handleNetlifyCacheInDevInstance', () => {
beforeEach(() => {
delete process.env.URL;
delete process.env.NETLIFY;
});

it('should add cache bust parameter when on Netlify and in development', () => {
process.env.NETLIFY = 'true';
process.env.URL = 'https://example.netlify.app';

const requestStateHeaders = new Headers({
Location: 'https://example.netlify.app',
});
const locationHeader = requestStateHeaders.get('Location') || '';

handleNetlifyCacheInDevInstance({
locationHeader,
requestStateHeaders,
publishableKey: mockPublishableKey,
});

const locationUrl = new URL(requestStateHeaders.get('Location') || '');
expect(locationUrl.searchParams.has(CLERK_NETLIFY_CACHE_BUST_PARAM)).toBe(true);
});

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 requestStateHeaders = new Headers({
Location: 'https://example.netlify.app/redirect?__clerk_handshake=',
});
const locationHeader = requestStateHeaders.get('Location') || '';

handleNetlifyCacheInDevInstance({
locationHeader,
requestStateHeaders,
publishableKey: mockPublishableKey,
});

expect(requestStateHeaders.get('Location')).toBe(locationHeader);
});

it('should not modify the Location header if not on Netlify', () => {
const requestStateHeaders = new Headers({
Location: 'https://example.netlify.app',
});
const locationHeader = requestStateHeaders.get('Location') || '';

handleNetlifyCacheInDevInstance({
locationHeader,
requestStateHeaders,
publishableKey: mockPublishableKey,
});

expect(requestStateHeaders.get('Location')).toBe('https://example.netlify.app');
});
});
1 change: 1 addition & 0 deletions packages/shared/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,3 +36,4 @@ export { createWorkerTimers } from './workerTimers';
export { DEV_BROWSER_JWT_KEY, extractDevBrowserJWTFromURL, setDevBrowserJWTInURL } from './devBrowser';
export { getEnvVariable } from './getEnvVariable';
export * from './pathMatcher';
export * from './netlifyCacheHandler';
48 changes: 48 additions & 0 deletions packages/shared/src/netlifyCacheHandler.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
/* eslint-disable turbo/no-undeclared-env-vars */
import { isDevelopmentFromPublishableKey } from './keys';

/**
* Cache busting parameter for Netlify to prevent cached responses
* during handshake flows with Clerk development instances.
*
* Note: This query parameter will be removed in the "@clerk/clerk-js" package.
*
* @internal
*/
export const CLERK_NETLIFY_CACHE_BUST_PARAM = '__clerk_netlify_cache_bust';

/**
* 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.
*
* 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
*
* @internal
*/
export function handleNetlifyCacheInDevInstance({
locationHeader,
requestStateHeaders,
publishableKey,
}: {
locationHeader: string;
requestStateHeaders: Headers;
publishableKey: string;
}) {
const isOnNetlify =
process.env.NETLIFY || process.env.URL?.endsWith('netlify.app') || Boolean(process.env.NETLIFY_FUNCTIONS_TOKEN);
const isDevelopmentInstance = isDevelopmentFromPublishableKey(publishableKey);
if (isOnNetlify && isDevelopmentInstance) {
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());
}
}
}
12 changes: 9 additions & 3 deletions packages/tanstack-react-start/src/server/authenticateRequest.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { createClerkClient } from '@clerk/backend';
import type { AuthenticateRequestOptions, SignedInState, SignedOutState } from '@clerk/backend/internal';
import { AuthStatus } from '@clerk/backend/internal';
import { AuthStatus, constants } from '@clerk/backend/internal';
import { handleNetlifyCacheInDevInstance } from '@clerk/shared/netlifyCacheHandler';

import { errorThrower } from '../utils';
import { patchRequest } from './utils';
Expand Down Expand Up @@ -32,8 +33,13 @@ export async function authenticateRequest(
afterSignUpUrl,
});

const hasLocationHeader = requestState.headers.get('location');
if (hasLocationHeader) {
const locationHeader = requestState.headers.get(constants.Headers.Location);
if (locationHeader) {
handleNetlifyCacheInDevInstance({
locationHeader,
requestStateHeaders: requestState.headers,
publishableKey: requestState.publishableKey,
});
// triggering a handshake redirect
// eslint-disable-next-line @typescript-eslint/only-throw-error
throw new Response(null, { status: 307, headers: requestState.headers });
Expand Down