Skip to content
Open
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
78 changes: 78 additions & 0 deletions .changeset/satellite-auto-sync.md
Original file line number Diff line number Diff line change
@@ -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
<ClerkProvider
publishableKey="pk_..."
isSatellite={true}
domain="satellite.example.com"
signInUrl="https://primary.example.com/sign-in"
satelliteAutoSync={false}
>
{children}
</ClerkProvider>
```

### 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'),
}));
```
82 changes: 82 additions & 0 deletions integration/tests/handshake.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,13 +42,15 @@ 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"),
proxyUrl: req.headers.get("x-proxy-url"),
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)
};

Expand Down Expand Up @@ -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_sync=1 - prod', async () => {
const config = generateConfig({
mode: 'live',
});
const res = await fetch(app.serverUrl + '/?__clerk_sync=1', {
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_sync=1 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_sync=2 (completed) - prod', async () => {
const config = generateConfig({
mode: 'live',
});
const res = await fetch(app.serverUrl + '/?__clerk_sync=2', {
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_sync=2 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',
Expand Down
2 changes: 2 additions & 0 deletions packages/astro/src/server/clerk-middleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
});
Expand Down Expand Up @@ -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;
Expand Down
64 changes: 31 additions & 33 deletions packages/backend/src/__tests__/createRedirect.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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`,
);
});
});
});
12 changes: 12 additions & 0 deletions packages/backend/src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
/** Trigger sync - satellite needs to handshake after returning from primary sign-in */
NeedsSync: 'false',
/** Sync completed - prevents re-sync loop after handshake completes */
Completed: 'true',
} as const;

/**
* @internal
*/
Expand All @@ -83,6 +94,7 @@ export const constants = {
Headers,
ContentTypes,
QueryParameters,
ClerkSyncStatus,
} as const;

export type Constants = typeof constants;
22 changes: 15 additions & 7 deletions packages/backend/src/createRedirect.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,15 @@
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 = (
_baseUrl: string | URL,
_targetUrl: string | URL,
_returnBackUrl?: string | URL | null,
_devBrowserToken?: string | null,
_isSatellite?: boolean,
) => {
if (_baseUrl === '') {
return legacyBuildUrl(_targetUrl.toString(), _returnBackUrl?.toString());
Expand All @@ -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());
Expand Down Expand Up @@ -77,13 +80,14 @@ type CreateRedirect = <ReturnType>(params: {
signInUrl?: URL | string;
signUpUrl?: URL | string;
sessionStatus?: SessionStatusClaim | null;
isSatellite?: boolean;
}) => {
redirectToSignIn: RedirectFun<ReturnType>;
redirectToSignUp: RedirectFun<ReturnType>;
};

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';
Expand All @@ -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),
);
};

Expand All @@ -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 = {}) => {
Expand All @@ -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 };
Expand Down
Loading
Loading