diff --git a/.changeset/satellite-auto-sync.md b/.changeset/satellite-auto-sync.md
new file mode 100644
index 00000000000..84317c1bee4
--- /dev/null
+++ b/.changeset/satellite-auto-sync.md
@@ -0,0 +1,78 @@
+---
+"@clerk/backend": minor
+"@clerk/shared": minor
+"@clerk/clerk-js": minor
+"@clerk/tanstack-react-start": minor
+"@clerk/nextjs": patch
+"@clerk/astro": patch
+---
+
+Add `satelliteAutoSync` option to optimize satellite app handshake behavior
+
+Satellite apps currently trigger a handshake redirect on every first page load, even when no cookies exist. This creates unnecessary redirects to the primary domain for apps where most users aren't authenticated.
+
+**New option: `satelliteAutoSync`** (default: `true`)
+- When `true` (default): Satellite apps automatically trigger handshake on first load (existing behavior)
+- When `false`: Skip automatic handshake if no session cookies exist, only trigger after explicit sign-in action
+
+**New query parameter: `__clerk_sync`**
+- `__clerk_sync=1` (NeedsSync): Triggers handshake after returning from primary sign-in
+- `__clerk_sync=2` (Completed): Prevents re-sync loop after handshake completes
+
+Backwards compatible: Still reads legacy `__clerk_synced=true` parameter.
+
+**SSR redirect fix**: Server-side redirects (e.g., `redirectToSignIn()` from middleware) now correctly add `__clerk_sync=1` to the return URL for satellite apps. This ensures the handshake is triggered when the user returns from sign-in on the primary domain.
+
+**CSR redirect fix**: Client-side redirects now add `__clerk_sync=1` to all redirect URL variants (`forceRedirectUrl`, `fallbackRedirectUrl`) for satellite apps, not just the default `redirectUrl`.
+
+## Usage
+
+### SSR (Next.js Middleware)
+```typescript
+import { clerkMiddleware } from '@clerk/nextjs/server';
+
+export default clerkMiddleware({
+ isSatellite: true,
+ domain: 'satellite.example.com',
+ signInUrl: 'https://primary.example.com/sign-in',
+ satelliteAutoSync: false,
+});
+```
+
+### SSR (TanStack Start)
+```typescript
+import { clerkMiddleware } from '@clerk/tanstack-react-start/server';
+
+export default clerkMiddleware({
+ isSatellite: true,
+ domain: 'satellite.example.com',
+ signInUrl: 'https://primary.example.com/sign-in',
+ satelliteAutoSync: false,
+});
+```
+
+### CSR (ClerkProvider)
+```tsx
+
+ {children}
+
+```
+
+### SSR (TanStack Start with callback)
+```typescript
+import { clerkMiddleware } from '@clerk/tanstack-react-start/server';
+
+// Options callback - receives context object, returns options
+export default clerkMiddleware(({ url }) => ({
+ isSatellite: true,
+ domain: 'satellite.example.com',
+ signInUrl: 'https://primary.example.com/sign-in',
+ satelliteAutoSync: url.pathname.startsWith('/dashboard'),
+}));
+```
diff --git a/integration/tests/handshake.test.ts b/integration/tests/handshake.test.ts
index ae9ee00e007..dc6975fc524 100644
--- a/integration/tests/handshake.test.ts
+++ b/integration/tests/handshake.test.ts
@@ -42,6 +42,7 @@ test.describe('Client handshake @generic', () => {
() => `import { clerkMiddleware } from '@clerk/nextjs/server';
export const middleware = (req, evt) => {
+ const satelliteAutoSyncHeader = req.headers.get('x-satellite-auto-sync');
return clerkMiddleware({
publishableKey: req.headers.get("x-publishable-key"),
secretKey: req.headers.get("x-secret-key"),
@@ -49,6 +50,7 @@ test.describe('Client handshake @generic', () => {
domain: req.headers.get("x-domain"),
isSatellite: req.headers.get('x-satellite') === 'true',
signInUrl: req.headers.get("x-sign-in-url"),
+ satelliteAutoSync: satelliteAutoSyncHeader === null ? undefined : satelliteAutoSyncHeader === 'true',
})(req, evt)
};
@@ -567,6 +569,86 @@ test.describe('Client handshake @generic', () => {
expect(res.status).toBe(200);
});
+ test('signed out satellite with satelliteAutoSync=false skips handshake - prod', async () => {
+ const config = generateConfig({
+ mode: 'live',
+ });
+ const res = await fetch(app.serverUrl + '/', {
+ headers: new Headers({
+ 'X-Publishable-Key': config.pk,
+ 'X-Secret-Key': config.sk,
+ 'X-Satellite': 'true',
+ 'X-Domain': 'example.com',
+ 'X-Satellite-Auto-Sync': 'false',
+ 'Sec-Fetch-Dest': 'document',
+ }),
+ redirect: 'manual',
+ });
+ // Should NOT redirect to handshake when satelliteAutoSync=false and no cookies
+ expect(res.status).toBe(200);
+ });
+
+ test('signed out satellite with satelliteAutoSync=false triggers handshake when __clerk_synced=false - prod', async () => {
+ const config = generateConfig({
+ mode: 'live',
+ });
+ const res = await fetch(app.serverUrl + '/?__clerk_synced=false', {
+ headers: new Headers({
+ 'X-Publishable-Key': config.pk,
+ 'X-Secret-Key': config.sk,
+ 'X-Satellite': 'true',
+ 'X-Domain': 'example.com',
+ 'X-Satellite-Auto-Sync': 'false',
+ 'Sec-Fetch-Dest': 'document',
+ }),
+ redirect: 'manual',
+ });
+ // Should redirect to handshake when __clerk_synced=false is present
+ expect(res.status).toBe(307);
+ const locationUrl = new URL(res.headers.get('location'));
+ expect(locationUrl.origin + locationUrl.pathname).toBe('https://clerk.example.com/v1/client/handshake');
+ expect(locationUrl.searchParams.get('__clerk_hs_reason')).toBe('satellite-needs-syncing');
+ });
+
+ test('signed out satellite skips handshake when __clerk_synced=true (completed) - prod', async () => {
+ const config = generateConfig({
+ mode: 'live',
+ });
+ const res = await fetch(app.serverUrl + '/?__clerk_synced=true', {
+ headers: new Headers({
+ 'X-Publishable-Key': config.pk,
+ 'X-Secret-Key': config.sk,
+ 'X-Satellite': 'true',
+ 'X-Domain': 'example.com',
+ 'Sec-Fetch-Dest': 'document',
+ }),
+ redirect: 'manual',
+ });
+ // Should NOT redirect when __clerk_synced=true indicates sync already completed
+ expect(res.status).toBe(200);
+ });
+
+ test('signed out satellite with satelliteAutoSync=true (default) triggers handshake - prod', async () => {
+ const config = generateConfig({
+ mode: 'live',
+ });
+ const res = await fetch(app.serverUrl + '/', {
+ headers: new Headers({
+ 'X-Publishable-Key': config.pk,
+ 'X-Secret-Key': config.sk,
+ 'X-Satellite': 'true',
+ 'X-Domain': 'example.com',
+ 'X-Satellite-Auto-Sync': 'true',
+ 'Sec-Fetch-Dest': 'document',
+ }),
+ redirect: 'manual',
+ });
+ // Should redirect to handshake with default/true satelliteAutoSync
+ expect(res.status).toBe(307);
+ const locationUrl = new URL(res.headers.get('location'));
+ expect(locationUrl.origin + locationUrl.pathname).toBe('https://clerk.example.com/v1/client/handshake');
+ });
+
test('missing session token, missing uat (indicating signed out), missing devbrowser - dev', async () => {
const config = generateConfig({
mode: 'test',
diff --git a/packages/astro/src/server/clerk-middleware.ts b/packages/astro/src/server/clerk-middleware.ts
index b40c656de99..fa0500f2197 100644
--- a/packages/astro/src/server/clerk-middleware.ts
+++ b/packages/astro/src/server/clerk-middleware.ts
@@ -300,6 +300,7 @@ function decorateAstroLocal(
signInUrl: requestState.signInUrl,
signUpUrl: requestState.signUpUrl,
sessionStatus: requestState.toAuth()?.sessionStatus,
+ isSatellite: requestState.isSatellite,
}).redirectToSignIn({
returnBackUrl: opts.returnBackUrl === null ? '' : opts.returnBackUrl || clerkUrl.toString(),
});
@@ -422,6 +423,7 @@ const handleControlFlowErrors = (
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
publishableKey: getSafeEnv(context).pk!,
sessionStatus: requestState.toAuth()?.sessionStatus,
+ isSatellite: requestState.isSatellite,
}).redirectToSignIn({ returnBackUrl: e.returnBackUrl });
default:
throw e;
diff --git a/packages/backend/src/__tests__/createRedirect.test.ts b/packages/backend/src/__tests__/createRedirect.test.ts
index ee4fb558d16..0877146bb89 100644
--- a/packages/backend/src/__tests__/createRedirect.test.ts
+++ b/packages/backend/src/__tests__/createRedirect.test.ts
@@ -99,26 +99,44 @@ describe('redirect(redirectAdapter)', () => {
);
});
- it('removes __clerk_synced when cross-origin redirect', () => {
- const returnBackUrl = 'https://current.url:3000/path?__clerk_synced=true&q=1#hash';
- const encodedUrl = 'https%3A%2F%2Fcurrent.url%3A3000%2Fpath%3Fq%3D1%23hash';
- const signUpUrl = 'https://lcl.dev/sign-up';
+ it('adds __clerk_synced=false to returnBackUrl for satellite apps on cross-origin redirect', () => {
+ const returnBackUrl = 'https://satellite.example.com/dashboard';
+ const encodedUrl = 'https%3A%2F%2Fsatellite.example.com%2Fdashboard%3F__clerk_synced%3Dfalse';
+ const signInUrl = 'https://primary.example.com/sign-in';
const redirectAdapterSpy = vi.fn().mockImplementation(_url => 'redirectAdapterValue');
- const { redirectToSignUp } = createRedirect({
- baseUrl: 'http://www.clerk.com',
- devBrowserToken: 'deadbeef',
+ const { redirectToSignIn } = createRedirect({
+ baseUrl: 'https://satellite.example.com',
redirectAdapter: redirectAdapterSpy,
- publishableKey: 'pk_test_Y2xlcmsubGNsLmRldiQ',
+ publishableKey: 'pk_test_Y2xlcmsuZXhhbXBsZS5jb20k',
sessionStatus: 'active',
- signUpUrl,
+ signInUrl,
+ isSatellite: true,
});
- const result = redirectToSignUp({ returnBackUrl });
+ const result = redirectToSignIn({ returnBackUrl });
expect(result).toBe('redirectAdapterValue');
- expect(redirectAdapterSpy).toHaveBeenCalledWith(
- `${signUpUrl}?redirect_url=${encodedUrl}&__clerk_db_jwt=deadbeef`,
- );
+ expect(redirectAdapterSpy).toHaveBeenCalledWith(`${signInUrl}?redirect_url=${encodedUrl}`);
+ });
+
+ it('does not add __clerk_synced=false for non-satellite apps', () => {
+ const returnBackUrl = 'https://app.example.com/dashboard';
+ const encodedUrl = 'https%3A%2F%2Fapp.example.com%2Fdashboard';
+ const signInUrl = 'https://accounts.example.com/sign-in';
+
+ const redirectAdapterSpy = vi.fn().mockImplementation(_url => 'redirectAdapterValue');
+ const { redirectToSignIn } = createRedirect({
+ baseUrl: 'https://app.example.com',
+ redirectAdapter: redirectAdapterSpy,
+ publishableKey: 'pk_test_Y2xlcmsuZXhhbXBsZS5jb20k',
+ sessionStatus: 'active',
+ signInUrl,
+ isSatellite: false,
+ });
+
+ const result = redirectToSignIn({ returnBackUrl });
+ expect(result).toBe('redirectAdapterValue');
+ expect(redirectAdapterSpy).toHaveBeenCalledWith(`${signInUrl}?redirect_url=${encodedUrl}`);
});
it('returns path based url with development (kima) publishableKey (with staging Clerk) but without signUpUrl to redirectToSignUp', () => {
@@ -342,25 +360,5 @@ describe('redirect(redirectAdapter)', () => {
`https://included.katydid-92.accounts.dev/sign-up/tasks?redirect_url=${encodedUrl}&__clerk_db_jwt=deadbeef`,
);
});
-
- it('removes __clerk_synced when cross-origin redirect', () => {
- const returnBackUrl = 'https://current.url:3000/path?__clerk_synced=true&q=1#hash';
- const encodedUrl = 'https%3A%2F%2Fcurrent.url%3A3000%2Fpath%3Fq%3D1%23hash';
-
- const redirectAdapterSpy = vi.fn().mockImplementation(_url => 'redirectAdapterValue');
- const { redirectToSignUp } = createRedirect({
- baseUrl: 'http://www.clerk.com',
- devBrowserToken: 'deadbeef',
- redirectAdapter: redirectAdapterSpy,
- publishableKey: 'pk_test_Y2xlcmsubGNsLmRldiQ',
- sessionStatus: 'pending',
- });
-
- const result = redirectToSignUp({ returnBackUrl });
- expect(result).toBe('redirectAdapterValue');
- expect(redirectAdapterSpy).toHaveBeenCalledWith(
- `https://accounts.lcl.dev/sign-up/tasks?redirect_url=${encodedUrl}&__clerk_db_jwt=deadbeef`,
- );
- });
});
});
diff --git a/packages/backend/src/constants.ts b/packages/backend/src/constants.ts
index ceaa1721d37..2d5f49625f8 100644
--- a/packages/backend/src/constants.ts
+++ b/packages/backend/src/constants.ts
@@ -74,6 +74,17 @@ const ContentTypes = {
Json: 'application/json',
} as const;
+/**
+ * Sync status values for the __clerk_synced query parameter.
+ * Used to coordinate satellite domain authentication flows.
+ */
+export const ClerkSyncStatus = {
+ /** Not synced - satellite needs handshake after returning from primary sign-in */
+ NeedsSync: 'false',
+ /** Sync completed - prevents re-sync loop after handshake completes */
+ Completed: 'true',
+} as const;
+
/**
* @internal
*/
@@ -83,6 +94,7 @@ export const constants = {
Headers,
ContentTypes,
QueryParameters,
+ ClerkSyncStatus,
} as const;
export type Constants = typeof constants;
diff --git a/packages/backend/src/createRedirect.ts b/packages/backend/src/createRedirect.ts
index 41eb4fd7626..1c06b8302c1 100644
--- a/packages/backend/src/createRedirect.ts
+++ b/packages/backend/src/createRedirect.ts
@@ -1,7 +1,7 @@
import { buildAccountsBaseUrl } from '@clerk/shared/buildAccountsBaseUrl';
import type { SessionStatusClaim } from '@clerk/shared/types';
-import { constants } from './constants';
+import { ClerkSyncStatus, constants } from './constants';
import { errorThrower, parsePublishableKey } from './util/shared';
const buildUrl = (
@@ -9,6 +9,7 @@ const buildUrl = (
_targetUrl: string | URL,
_returnBackUrl?: string | URL | null,
_devBrowserToken?: string | null,
+ _isSatellite?: boolean,
) => {
if (_baseUrl === '') {
return legacyBuildUrl(_targetUrl.toString(), _returnBackUrl?.toString());
@@ -20,8 +21,10 @@ const buildUrl = (
const isCrossOriginRedirect = `${baseUrl.hostname}:${baseUrl.port}` !== `${res.hostname}:${res.port}`;
if (returnBackUrl) {
- if (isCrossOriginRedirect) {
- returnBackUrl.searchParams.delete(constants.QueryParameters.ClerkSynced);
+ // For satellite apps redirecting to primary sign-in, add sync trigger to returnBackUrl
+ // This ensures handshake is triggered when the user returns after signing in
+ if (isCrossOriginRedirect && _isSatellite) {
+ returnBackUrl.searchParams.set(constants.QueryParameters.ClerkSynced, ClerkSyncStatus.NeedsSync);
}
res.searchParams.set('redirect_url', returnBackUrl.toString());
@@ -77,13 +80,14 @@ type CreateRedirect = (params: {
signInUrl?: URL | string;
signUpUrl?: URL | string;
sessionStatus?: SessionStatusClaim | null;
+ isSatellite?: boolean;
}) => {
redirectToSignIn: RedirectFun;
redirectToSignUp: RedirectFun;
};
export const createRedirect: CreateRedirect = params => {
- const { publishableKey, redirectAdapter, signInUrl, signUpUrl, baseUrl, sessionStatus } = params;
+ const { publishableKey, redirectAdapter, signInUrl, signUpUrl, baseUrl, sessionStatus, isSatellite } = params;
const parsedPublishableKey = parsePublishableKey(publishableKey);
const frontendApi = parsedPublishableKey?.frontendApi;
const isDevelopment = parsedPublishableKey?.instanceType === 'development';
@@ -92,7 +96,7 @@ export const createRedirect: CreateRedirect = params => {
const redirectToTasks = (url: string | URL, { returnBackUrl }: RedirectToParams) => {
return redirectAdapter(
- buildUrl(baseUrl, `${url}/tasks`, returnBackUrl, isDevelopment ? params.devBrowserToken : null),
+ buildUrl(baseUrl, `${url}/tasks`, returnBackUrl, isDevelopment ? params.devBrowserToken : null, isSatellite),
);
};
@@ -119,7 +123,9 @@ export const createRedirect: CreateRedirect = params => {
return redirectToTasks(targetUrl, { returnBackUrl });
}
- return redirectAdapter(buildUrl(baseUrl, targetUrl, returnBackUrl, isDevelopment ? params.devBrowserToken : null));
+ return redirectAdapter(
+ buildUrl(baseUrl, targetUrl, returnBackUrl, isDevelopment ? params.devBrowserToken : null, isSatellite),
+ );
};
const redirectToSignIn = ({ returnBackUrl }: RedirectToParams = {}) => {
@@ -134,7 +140,9 @@ export const createRedirect: CreateRedirect = params => {
return redirectToTasks(targetUrl, { returnBackUrl });
}
- return redirectAdapter(buildUrl(baseUrl, targetUrl, returnBackUrl, isDevelopment ? params.devBrowserToken : null));
+ return redirectAdapter(
+ buildUrl(baseUrl, targetUrl, returnBackUrl, isDevelopment ? params.devBrowserToken : null, isSatellite),
+ );
};
return { redirectToSignUp, redirectToSignIn };
diff --git a/packages/backend/src/tokens/__tests__/request.test.ts b/packages/backend/src/tokens/__tests__/request.test.ts
index 450da616de5..115dd24be2b 100644
--- a/packages/backend/src/tokens/__tests__/request.test.ts
+++ b/packages/backend/src/tokens/__tests__/request.test.ts
@@ -688,15 +688,15 @@ describe('tokens.authenticateRequest(options)', () => {
const requestState = await authenticateRequest(
mockRequestWithCookies(
- {},
+ { ...defaultHeaders, 'sec-fetch-dest': 'document' },
{
__client_uat: '0',
+ __clerk_db_jwt: mockJwt,
},
),
mockOptions({
- secretKey: 'deadbeef',
+ secretKey: 'sk_test_deadbeef',
publishableKey: PK_TEST,
- clientUat: '0',
isSatellite: true,
signInUrl: 'https://primary.dev/sign-in',
domain: 'satellite.dev',
@@ -790,6 +790,84 @@ describe('tokens.authenticateRequest(options)', () => {
expect(requestState.toAuth()).toBeSignedOutToAuth();
});
+ test('cookieToken: returns signed out without handshake when satelliteAutoSync is false and no cookies', async () => {
+ const requestState = await authenticateRequest(
+ mockRequestWithCookies(
+ { ...defaultHeaders, 'sec-fetch-dest': 'document' },
+ { __client_uat: '0' },
+ `http://satellite.example/path`,
+ ),
+ mockOptions({
+ secretKey: 'deadbeef',
+ publishableKey: PK_LIVE,
+ signInUrl: 'https://primary.example/sign-in',
+ isSatellite: true,
+ domain: 'satellite.example',
+ satelliteAutoSync: false,
+ }),
+ );
+
+ expect(requestState).toBeSignedOut({
+ reason: AuthErrorReason.SessionTokenAndUATMissing,
+ isSatellite: true,
+ domain: 'satellite.example',
+ signInUrl: 'https://primary.example/sign-in',
+ });
+ expect(requestState.toAuth()).toBeSignedOutToAuth();
+ // Should NOT have a location header (no handshake redirect)
+ expect(requestState.headers.get('location')).toBeNull();
+ });
+
+ test('cookieToken: triggers handshake when satelliteAutoSync is false but __clerk_synced=false is present', async () => {
+ const requestState = await authenticateRequest(
+ mockRequestWithCookies(
+ { ...defaultHeaders, 'sec-fetch-dest': 'document' },
+ { __client_uat: '0' },
+ `http://satellite.example/path?__clerk_synced=false`,
+ ),
+ mockOptions({
+ secretKey: 'deadbeef',
+ publishableKey: PK_LIVE,
+ signInUrl: 'https://primary.example/sign-in',
+ isSatellite: true,
+ domain: 'satellite.example',
+ satelliteAutoSync: false,
+ }),
+ );
+
+ expect(requestState).toMatchHandshake({
+ reason: AuthErrorReason.SatelliteCookieNeedsSyncing,
+ isSatellite: true,
+ domain: 'satellite.example',
+ signInUrl: 'https://primary.example/sign-in',
+ });
+ });
+
+ test('cookieToken: returns signed out when __clerk_synced=true (completed) is present', async () => {
+ const requestState = await authenticateRequest(
+ mockRequestWithCookies(
+ { ...defaultHeaders, 'sec-fetch-dest': 'document' },
+ { __client_uat: '0' },
+ `http://satellite.example/path?__clerk_synced=true`,
+ ),
+ mockOptions({
+ secretKey: 'deadbeef',
+ publishableKey: PK_LIVE,
+ signInUrl: 'https://primary.example/sign-in',
+ isSatellite: true,
+ domain: 'satellite.example',
+ }),
+ );
+
+ expect(requestState).toBeSignedOut({
+ reason: AuthErrorReason.SessionTokenAndUATMissing,
+ isSatellite: true,
+ domain: 'satellite.example',
+ signInUrl: 'https://primary.example/sign-in',
+ });
+ expect(requestState.toAuth()).toBeSignedOutToAuth();
+ });
+
test('cookieToken: returns handshake when app is not satellite and responds to syncing on dev instances[12y]', async () => {
const sp = new URLSearchParams();
sp.set('__clerk_redirect_url', 'http://localhost:3000');
@@ -833,7 +911,35 @@ describe('tokens.authenticateRequest(options)', () => {
const location = requestState.headers.get('location');
expect(location).toBeTruthy();
expect(location).toContain('localhost:3001/dashboard');
+ // Should contain the sync param (with Completed status)
+ expect(location).toContain('__clerk_synced=true');
+ });
+
+ test('cookieToken: primary responds to syncing overwrites __clerk_synced=false with __clerk_synced=true (no duplicates)', async () => {
+ const sp = new URLSearchParams();
+ // Redirect URL already contains __clerk_synced=false (NeedsSync)
+ sp.set('__clerk_redirect_url', 'http://localhost:3001/dashboard?__clerk_synced=false');
+ sp.set('__clerk_db_jwt', mockJwt);
+ const requestUrl = `http://localhost:3000/sign-in?${sp.toString()}`;
+ const requestState = await authenticateRequest(
+ mockRequestWithCookies(
+ { ...defaultHeaders, 'sec-fetch-dest': 'document' },
+ { __client_uat: '12345', __session: mockJwt, __clerk_db_jwt: mockJwt },
+ requestUrl,
+ ),
+ mockOptions({ secretKey: 'sk_test_deadbeef', isSatellite: false }),
+ );
+
+ expect(requestState).toMatchHandshake({
+ reason: AuthErrorReason.PrimaryRespondsToSyncing,
+ });
+
+ const location = requestState.headers.get('location');
+ expect(location).toBeTruthy();
+ // Should overwrite __clerk_synced=false with __clerk_synced=true, not append
expect(location).toContain('__clerk_synced=true');
+ // Should NOT contain __clerk_synced=false anymore
+ expect(location).not.toContain('__clerk_synced=false');
});
test('cookieToken: returns signed out when no cookieToken and no clientUat in production [4y]', async () => {
diff --git a/packages/backend/src/tokens/request.ts b/packages/backend/src/tokens/request.ts
index 9bf4c2a422d..c9b444e3623 100644
--- a/packages/backend/src/tokens/request.ts
+++ b/packages/backend/src/tokens/request.ts
@@ -466,28 +466,77 @@ export const authenticateRequest: AuthenticateRequest = (async (
/**
* Begin multi-domain sync flows
+ *
+ * Sync status values (__clerk_synced query param):
+ * - 'false' (NeedsSync): Trigger sync - satellite returning from primary sign-in
+ * - 'true' (Completed): Sync done - prevents re-sync loop
+ *
+ * With satelliteAutoSync=false:
+ * - Skip handshake on first visit if no cookies exist (return signedOut immediately)
+ * - Trigger handshake when __clerk_synced=false is present (post sign-in redirect)
+ * - Allow normal token verification flow when cookies exist (enables refresh)
*/
- if (authenticateContext.instanceType === 'production' && isRequestEligibleForMultiDomainSync) {
- return handleMaybeHandshakeStatus(authenticateContext, AuthErrorReason.SatelliteCookieNeedsSyncing, '');
+
+ // Check sync status param (__clerk_synced=false triggers sync, __clerk_synced=true means completed)
+ const syncedParam = authenticateContext.clerkUrl.searchParams.get(constants.QueryParameters.ClerkSynced);
+ const needsSync = syncedParam === constants.ClerkSyncStatus.NeedsSync;
+ const syncCompleted = syncedParam === constants.ClerkSyncStatus.Completed;
+
+ // Check if cookies exist (session token or active client UAT)
+ const hasCookies = hasSessionToken || hasActiveClient;
+
+ // Determine if we should skip handshake for satellites with no cookies
+ // satelliteAutoSync defaults to true, so we only skip when explicitly set to false
+ const shouldSkipSatelliteHandshake = authenticateContext.satelliteAutoSync === false && !hasCookies && !needsSync;
+
+ if (authenticateContext.instanceType === 'production' && isRequestEligibleForMultiDomainSync && !syncCompleted) {
+ // With satelliteAutoSync=false: skip handshake if no cookies and no sync trigger
+ if (shouldSkipSatelliteHandshake) {
+ return signedOut({
+ tokenType: TokenType.SessionToken,
+ authenticateContext,
+ reason: AuthErrorReason.SessionTokenAndUATMissing,
+ });
+ }
+
+ // If cookies exist, fall through to normal token verification flow (enables refresh)
+ // Only trigger handshake if no cookies exist (or sync was explicitly requested)
+ if (!hasCookies || needsSync) {
+ return handleMaybeHandshakeStatus(authenticateContext, AuthErrorReason.SatelliteCookieNeedsSyncing, '');
+ }
+ // Fall through to normal token verification flow when cookies exist
}
// Multi-domain development sync flow
- if (
- authenticateContext.instanceType === 'development' &&
- isRequestEligibleForMultiDomainSync &&
- !authenticateContext.clerkUrl.searchParams.has(constants.QueryParameters.ClerkSynced)
- ) {
- // initiate MD sync
+ if (authenticateContext.instanceType === 'development' && isRequestEligibleForMultiDomainSync && !syncCompleted) {
+ // With satelliteAutoSync=false: skip sync if no cookies and no sync trigger
+ if (shouldSkipSatelliteHandshake) {
+ return signedOut({
+ tokenType: TokenType.SessionToken,
+ authenticateContext,
+ reason: AuthErrorReason.SessionTokenAndUATMissing,
+ });
+ }
- // signInUrl exists, checked at the top of `authenticateRequest`
- // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
- const redirectURL = new URL(authenticateContext.signInUrl!);
- redirectURL.searchParams.append(
- constants.QueryParameters.ClerkRedirectUrl,
- authenticateContext.clerkUrl.toString(),
- );
- const headers = new Headers({ [constants.Headers.Location]: redirectURL.toString() });
- return handleMaybeHandshakeStatus(authenticateContext, AuthErrorReason.SatelliteCookieNeedsSyncing, '', headers);
+ // If cookies exist, fall through to normal flow (enables refresh)
+ if (!hasCookies || needsSync) {
+ // initiate MD sync
+ // signInUrl exists, checked at the top of `authenticateRequest`
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
+ const redirectURL = new URL(authenticateContext.signInUrl!);
+ redirectURL.searchParams.append(
+ constants.QueryParameters.ClerkRedirectUrl,
+ authenticateContext.clerkUrl.toString(),
+ );
+ const headers = new Headers({ [constants.Headers.Location]: redirectURL.toString() });
+ return handleMaybeHandshakeStatus(
+ authenticateContext,
+ AuthErrorReason.SatelliteCookieNeedsSyncing,
+ '',
+ headers,
+ );
+ }
+ // Fall through to normal token verification flow when cookies exist
}
// Multi-domain development sync flow - primary responds to syncing
@@ -507,7 +556,12 @@ export const authenticateRequest: AuthenticateRequest = (async (
authenticateContext.devBrowserToken,
);
}
- redirectBackToSatelliteUrl.searchParams.append(constants.QueryParameters.ClerkSynced, 'true');
+ // Use set (not append) to ensure completion status overwrites any existing NeedsSync value
+ // This prevents sync loops when the redirect URL already contains __clerk_synced=false
+ redirectBackToSatelliteUrl.searchParams.set(
+ constants.QueryParameters.ClerkSynced,
+ constants.ClerkSyncStatus.Completed,
+ );
const headers = new Headers({ [constants.Headers.Location]: redirectBackToSatelliteUrl.toString() });
return handleMaybeHandshakeStatus(authenticateContext, AuthErrorReason.PrimaryRespondsToSyncing, '', headers);
diff --git a/packages/backend/src/tokens/types.ts b/packages/backend/src/tokens/types.ts
index 17def630280..657f4c9829d 100644
--- a/packages/backend/src/tokens/types.ts
+++ b/packages/backend/src/tokens/types.ts
@@ -72,6 +72,20 @@ export type AuthenticateRequestOptions = {
* This will override the Clerk secret key.
*/
machineSecretKey?: string;
+ /**
+ * Controls whether satellite apps automatically sync with the primary domain on initial page load.
+ *
+ * When `true` (default), satellite apps will automatically trigger a handshake redirect
+ * to sync authentication state with the primary domain, even if no session cookies exist.
+ *
+ * When `false`, satellite apps will skip the automatic handshake if no session cookies exist,
+ * and only trigger the handshake after an explicit sign-in action (when the `__clerk_synced=false`
+ * query parameter is present). This optimizes performance for satellite apps where users
+ * may not be authenticated, avoiding unnecessary redirects to the primary domain.
+ *
+ * @default true
+ */
+ satelliteAutoSync?: boolean;
} & VerifyTokenOptions;
/**
diff --git a/packages/clerk-js/src/core/clerk.ts b/packages/clerk-js/src/core/clerk.ts
index 48d108389ce..9d3629909dc 100644
--- a/packages/clerk-js/src/core/clerk.ts
+++ b/packages/clerk-js/src/core/clerk.ts
@@ -22,6 +22,7 @@ import {
CLERK_SATELLITE_URL,
CLERK_SUFFIXED_COOKIES,
CLERK_SYNCED,
+ CLERK_SYNCED_STATUS,
ERROR_CODES,
} from '@clerk/shared/internal/clerk-js/constants';
import { RedirectUrls } from '@clerk/shared/internal/clerk-js/redirectUrls';
@@ -1680,21 +1681,75 @@ export class Clerk implements ClerkInterface {
}
public buildSignInUrl(options?: SignInRedirectOptions): string {
- return this.#buildUrl(
- 'signInUrl',
- { ...options, redirectUrl: options?.redirectUrl || window.location.href },
- options?.initialValues,
- );
+ let redirectUrl = options?.redirectUrl || window.location.href;
+
+ // For satellites, add sync trigger param to all redirect URLs
+ // This ensures handshake is triggered when returning from primary sign-in
+ if (this.isSatellite) {
+ redirectUrl = this.#addSyncTriggerToUrl(redirectUrl);
+ }
+
+ const modifiedOptions = this.isSatellite ? this.#addSyncTriggerToRedirectOptions(options) : options;
+
+ return this.#buildUrl('signInUrl', { ...modifiedOptions, redirectUrl }, options?.initialValues);
}
public buildSignUpUrl(options?: SignUpRedirectOptions): string {
- return this.#buildUrl(
- 'signUpUrl',
- { ...options, redirectUrl: options?.redirectUrl || window.location.href },
- options?.initialValues,
- );
+ let redirectUrl = options?.redirectUrl || window.location.href;
+
+ // For satellites, add sync trigger param to all redirect URLs
+ // This ensures handshake is triggered when returning from primary sign-up
+ if (this.isSatellite) {
+ redirectUrl = this.#addSyncTriggerToUrl(redirectUrl);
+ }
+
+ const modifiedOptions = this.isSatellite ? this.#addSyncTriggerToRedirectOptions(options) : options;
+
+ return this.#buildUrl('signUpUrl', { ...modifiedOptions, redirectUrl }, options?.initialValues);
}
+ /**
+ * Adds the __clerk_synced=false param to a URL to trigger satellite handshake
+ * when the user returns from primary domain after sign-in/sign-up.
+ */
+ #addSyncTriggerToUrl = (urlString: string): string => {
+ try {
+ const url = new URL(urlString, window.location.origin);
+ url.searchParams.set(CLERK_SYNCED, CLERK_SYNCED_STATUS.NeedsSync);
+ return url.toString();
+ } catch {
+ // If URL parsing fails, return original
+ return urlString;
+ }
+ };
+
+ /**
+ * Adds the __clerk_synced=false param to all redirect URL options for satellites.
+ * This handles forceRedirectUrl and fallbackRedirectUrl variants.
+ */
+ #addSyncTriggerToRedirectOptions = (options: T): T => {
+ if (!options) {
+ return options;
+ }
+
+ const result = { ...options } as RedirectOptions;
+
+ if (result.signInForceRedirectUrl) {
+ result.signInForceRedirectUrl = this.#addSyncTriggerToUrl(result.signInForceRedirectUrl);
+ }
+ if (result.signInFallbackRedirectUrl) {
+ result.signInFallbackRedirectUrl = this.#addSyncTriggerToUrl(result.signInFallbackRedirectUrl);
+ }
+ if (result.signUpForceRedirectUrl) {
+ result.signUpForceRedirectUrl = this.#addSyncTriggerToUrl(result.signUpForceRedirectUrl);
+ }
+ if (result.signUpFallbackRedirectUrl) {
+ result.signUpFallbackRedirectUrl = this.#addSyncTriggerToUrl(result.signUpFallbackRedirectUrl);
+ }
+
+ return result as T;
+ };
+
public buildUserProfileUrl(): string {
if (!this.environment || !this.environment.displayConfig) {
return '';
@@ -1806,8 +1861,9 @@ export class Clerk implements ClerkInterface {
if (!inBrowser()) {
return;
}
+ // Use __clerk_synced=true to signal sync completion
const searchParams = new URLSearchParams({
- [CLERK_SYNCED]: 'true',
+ [CLERK_SYNCED]: CLERK_SYNCED_STATUS.Completed,
});
const satelliteUrl = getClerkQueryParam(CLERK_SATELLITE_URL);
@@ -2609,7 +2665,9 @@ export class Clerk implements ClerkInterface {
};
#shouldSyncWithPrimary = (): boolean => {
- if (getClerkQueryParam(CLERK_SYNCED) === 'true') {
+ // Check sync status: __clerk_synced=true means completed, __clerk_synced=false means needs sync
+ const syncStatus = getClerkQueryParam(CLERK_SYNCED);
+ if (syncStatus === CLERK_SYNCED_STATUS.Completed) {
return false;
}
@@ -2617,6 +2675,19 @@ export class Clerk implements ClerkInterface {
return false;
}
+ // Check if sync was triggered (e.g., after returning from primary sign-in)
+ const needsSync = syncStatus === CLERK_SYNCED_STATUS.NeedsSync;
+ if (needsSync) {
+ return true;
+ }
+
+ // Check if satelliteAutoSync is disabled - if so, skip automatic sync
+ // unless explicitly triggered via __clerk_synced=false
+ if (this.#options.satelliteAutoSync === false) {
+ // Skip automatic sync when satelliteAutoSync is false
+ return false;
+ }
+
return !!this.#authService?.isSignedOut();
};
diff --git a/packages/nextjs/src/app-router/server/auth.ts b/packages/nextjs/src/app-router/server/auth.ts
index c8e0549e82d..f5ce9a3190d 100644
--- a/packages/nextjs/src/app-router/server/auth.ts
+++ b/packages/nextjs/src/app-router/server/auth.ts
@@ -109,6 +109,7 @@ export const auth: AuthFn = (async (options?: AuthOptions) => {
signInUrl: decryptedRequestData.signInUrl || SIGN_IN_URL,
signUpUrl: decryptedRequestData.signUpUrl || SIGN_UP_URL,
sessionStatus: authObject.tokenType === TokenType.SessionToken ? authObject.sessionStatus : null,
+ isSatellite: decryptedRequestData.isSatellite,
}),
returnBackUrl === null ? '' : returnBackUrl || clerkUrl?.toString(),
] as const;
diff --git a/packages/nextjs/src/server/clerkMiddleware.ts b/packages/nextjs/src/server/clerkMiddleware.ts
index 671c034af63..9c9763128c1 100644
--- a/packages/nextjs/src/server/clerkMiddleware.ts
+++ b/packages/nextjs/src/server/clerkMiddleware.ts
@@ -506,6 +506,7 @@ const handleControlFlowErrors = (
signUpUrl: requestState.signUpUrl,
publishableKey: requestState.publishableKey,
sessionStatus: requestState.toAuth()?.sessionStatus,
+ isSatellite: requestState.isSatellite,
});
const { returnBackUrl } = e;
diff --git a/packages/shared/src/internal/clerk-js/constants.ts b/packages/shared/src/internal/clerk-js/constants.ts
index bcb2a632529..fae8b43b265 100644
--- a/packages/shared/src/internal/clerk-js/constants.ts
+++ b/packages/shared/src/internal/clerk-js/constants.ts
@@ -13,6 +13,12 @@ export const PRESERVED_QUERYSTRING_PARAMS = [
export const CLERK_MODAL_STATE = '__clerk_modal_state';
export const CLERK_SYNCED = '__clerk_synced';
+export const CLERK_SYNCED_STATUS = {
+ /** Not synced - satellite needs handshake after returning from primary sign-in */
+ NeedsSync: 'false',
+ /** Sync completed - prevents re-sync loop after handshake completes */
+ Completed: 'true',
+} as const;
export const CLERK_SUFFIXED_COOKIES = 'suffixed_cookies';
export const CLERK_SATELLITE_URL = '__clerk_satellite_url';
export const ERROR_CODES = {
diff --git a/packages/shared/src/types/clerk.ts b/packages/shared/src/types/clerk.ts
index 33b15d3bfe8..7b88855fdc3 100644
--- a/packages/shared/src/types/clerk.ts
+++ b/packages/shared/src/types/clerk.ts
@@ -1132,6 +1132,20 @@ export type ClerkOptions = ClerkOptionsNavigation &
* This option defines that the application is a satellite application.
*/
isSatellite?: boolean | ((url: URL) => boolean);
+ /**
+ * Controls whether satellite apps automatically sync with the primary domain on initial page load.
+ *
+ * When `true` (default), satellite apps will automatically trigger a handshake redirect
+ * to sync authentication state with the primary domain, even if no session cookies exist.
+ *
+ * When `false`, satellite apps will skip the automatic handshake if no session cookies exist,
+ * and only trigger the handshake after an explicit sign-in action (when the `__clerk_synced=false`
+ * query parameter is present). This optimizes performance for satellite apps where users
+ * may not be authenticated, avoiding unnecessary redirects to the primary domain.
+ *
+ * @default true
+ */
+ satelliteAutoSync?: boolean;
/**
* Controls whether or not Clerk will collect [telemetry data](https://clerk.com/docs/guides/how-clerk-works/security/clerk-telemetry). If set to `debug`, telemetry events are only logged to the console and not sent to Clerk.
*/
diff --git a/packages/tanstack-react-start/src/server/clerkMiddleware.ts b/packages/tanstack-react-start/src/server/clerkMiddleware.ts
index 661d9705ac2..89d0631a988 100644
--- a/packages/tanstack-react-start/src/server/clerkMiddleware.ts
+++ b/packages/tanstack-react-start/src/server/clerkMiddleware.ts
@@ -7,13 +7,20 @@ import { createMiddleware, json } from '@tanstack/react-start';
import { clerkClient } from './clerkClient';
import { loadOptions } from './loadOptions';
-import type { ClerkMiddlewareOptions } from './types';
+import type { ClerkMiddlewareOptions, ClerkMiddlewareOptionsCallback } from './types';
import { getResponseClerkState, patchRequest } from './utils';
-export const clerkMiddleware = (options?: ClerkMiddlewareOptions): AnyRequestMiddleware => {
+export const clerkMiddleware = (
+ options?: ClerkMiddlewareOptions | ClerkMiddlewareOptionsCallback,
+): AnyRequestMiddleware => {
return createMiddleware().server(async args => {
const clerkRequest = createClerkRequest(patchRequest(args.request));
- const loadedOptions = loadOptions(clerkRequest, options);
+
+ // Resolve options: if function, call it with context object; otherwise use as-is
+ const resolvedOptions = typeof options === 'function' ? await options({ url: clerkRequest.clerkUrl }) : options;
+
+ const loadedOptions = loadOptions(clerkRequest, resolvedOptions);
+
const requestState = await clerkClient().authenticateRequest(clerkRequest, {
...loadedOptions,
acceptsToken: 'any',
diff --git a/packages/tanstack-react-start/src/server/index.ts b/packages/tanstack-react-start/src/server/index.ts
index 5bf8d415361..1c5def3396e 100644
--- a/packages/tanstack-react-start/src/server/index.ts
+++ b/packages/tanstack-react-start/src/server/index.ts
@@ -1,6 +1,7 @@
export { auth } from './auth';
export { clerkClient } from './clerkClient';
export { clerkMiddleware } from './clerkMiddleware';
+export type { ClerkMiddlewareOptions, ClerkMiddlewareOptionsCallback } from './types';
/**
* Re-export resource types from @clerk/backend
diff --git a/packages/tanstack-react-start/src/server/loadOptions.ts b/packages/tanstack-react-start/src/server/loadOptions.ts
index 5fc6e348618..934c95f67df 100644
--- a/packages/tanstack-react-start/src/server/loadOptions.ts
+++ b/packages/tanstack-react-start/src/server/loadOptions.ts
@@ -3,7 +3,6 @@ import { apiUrlFromPublishableKey } from '@clerk/shared/apiUrlFromPublishableKey
import { getEnvVariable } from '@clerk/shared/getEnvVariable';
import { isDevelopmentFromSecretKey } from '@clerk/shared/keys';
import { isHttpOrHttps, isProxyUrlRelative } from '@clerk/shared/proxy';
-import { handleValueOrFn } from '@clerk/shared/utils';
import { errorThrower } from '../utils';
import { commonEnvs } from './constants';
@@ -16,11 +15,12 @@ export const loadOptions = (request: ClerkRequest, overrides: LoaderOptions = {}
const publishableKey = overrides.publishableKey || commonEnv.PUBLISHABLE_KEY;
const jwtKey = overrides.jwtKey || commonEnv.CLERK_JWT_KEY;
const apiUrl = getEnvVariable('CLERK_API_URL') || apiUrlFromPublishableKey(publishableKey);
- const domain = handleValueOrFn(overrides.domain, new URL(request.url)) || commonEnv.DOMAIN;
- const isSatellite = handleValueOrFn(overrides.isSatellite, new URL(request.url)) || commonEnv.IS_SATELLITE;
- const relativeOrAbsoluteProxyUrl = handleValueOrFn(overrides?.proxyUrl, request.clerkUrl, commonEnv.PROXY_URL);
+ const domain = overrides.domain || commonEnv.DOMAIN;
+ const isSatellite = overrides.isSatellite || commonEnv.IS_SATELLITE;
+ const relativeOrAbsoluteProxyUrl = overrides.proxyUrl || commonEnv.PROXY_URL;
const signInUrl = overrides.signInUrl || commonEnv.SIGN_IN_URL;
const signUpUrl = overrides.signUpUrl || commonEnv.SIGN_UP_URL;
+ const satelliteAutoSync = overrides.satelliteAutoSync;
let proxyUrl;
if (!!relativeOrAbsoluteProxyUrl && isProxyUrlRelative(relativeOrAbsoluteProxyUrl)) {
@@ -57,5 +57,6 @@ export const loadOptions = (request: ClerkRequest, overrides: LoaderOptions = {}
proxyUrl,
signInUrl,
signUpUrl,
+ satelliteAutoSync,
};
};
diff --git a/packages/tanstack-react-start/src/server/types.ts b/packages/tanstack-react-start/src/server/types.ts
index 55963186ade..df35ae5d1e8 100644
--- a/packages/tanstack-react-start/src/server/types.ts
+++ b/packages/tanstack-react-start/src/server/types.ts
@@ -1,7 +1,7 @@
import type { VerifyTokenOptions } from '@clerk/backend';
import type { OrganizationSyncOptions } from '@clerk/backend/internal';
import type {
- MultiDomainAndOrProxy,
+ MultiDomainAndOrProxyPrimitives,
SignInFallbackRedirectUrl,
SignInForceRedirectUrl,
SignUpFallbackRedirectUrl,
@@ -16,8 +16,22 @@ export type ClerkMiddlewareOptions = {
signInUrl?: string;
signUpUrl?: string;
organizationSyncOptions?: OrganizationSyncOptions;
+ /**
+ * Controls whether satellite apps automatically sync with the primary domain on initial page load.
+ *
+ * When `true` (default), satellite apps will automatically trigger a handshake redirect
+ * to sync authentication state with the primary domain, even if no session cookies exist.
+ *
+ * When `false`, satellite apps will skip the automatic handshake if no session cookies exist,
+ * and only trigger the handshake after an explicit sign-in action (when the `__clerk_synced=false`
+ * query parameter is present). This optimizes performance for satellite apps where users
+ * may not be authenticated, avoiding unnecessary redirects to the primary domain.
+ *
+ * @default true
+ */
+ satelliteAutoSync?: boolean;
} & Pick &
- MultiDomainAndOrProxy &
+ MultiDomainAndOrProxyPrimitives &
SignInForceRedirectUrl &
SignInFallbackRedirectUrl &
SignUpForceRedirectUrl &
@@ -25,6 +39,14 @@ export type ClerkMiddlewareOptions = {
export type LoaderOptions = ClerkMiddlewareOptions;
+/**
+ * Callback function that receives request context and returns middleware options.
+ * Allows dynamic configuration based on the current request.
+ */
+export type ClerkMiddlewareOptionsCallback = (args: {
+ url: URL;
+}) => ClerkMiddlewareOptions | Promise;
+
export type AdditionalStateOptions = SignInFallbackRedirectUrl &
SignUpFallbackRedirectUrl &
SignInForceRedirectUrl &