Skip to content

Commit a4c3b47

Browse files
authored
feat(*): optimize satellite handshakes and introduce satelliteAutoSync prop
Satellite domains previously always triggered a handshake redirect to the primary domain on first visit, even when users weren't authenticated. This caused unnecessary latency and poor UX for apps where most visitors are anonymous. Changes: - Add `satelliteAutoSync` option (defaults to false) - When false, satellites skip handshake if no cookies exist and no sync trigger - Use `__clerk_synced` query param: 'false' triggers sync, 'true' marks completion - Server-side redirects include sync param for post-sign-in handshake trigger - Client-side adds sync param to forceRedirectUrl and fallbackRedirectUrl - TanStack Start middleware supports callback function for dynamic options Packages affected: - @clerk/backend: authenticateRequest logic and redirect handling - @clerk/clerk-js: CSR support and redirect URL modification - @clerk/shared: sync constants and option types - @clerk/nextjs, @clerk/astro, @clerk/tanstack-react-start: option passthrough
1 parent fd87d03 commit a4c3b47

File tree

18 files changed

+610
-84
lines changed

18 files changed

+610
-84
lines changed

.changeset/satellite-auto-sync.md

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
---
2+
"@clerk/backend": minor
3+
"@clerk/shared": minor
4+
"@clerk/clerk-js": minor
5+
"@clerk/tanstack-react-start": minor
6+
"@clerk/nextjs": patch
7+
"@clerk/astro": patch
8+
---
9+
10+
Add `satelliteAutoSync` option to optimize satellite app handshake behavior
11+
12+
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.
13+
14+
**New option: `satelliteAutoSync`** (default: `false`)
15+
- When `false` (default): Skip automatic handshake if no session cookies exist, only trigger after explicit sign-in action
16+
- When `true`: Satellite apps automatically trigger handshake on first load (previous behavior)
17+
18+
**New query parameter: `__clerk_sync`**
19+
- `__clerk_sync=1` (NeedsSync): Triggers handshake after returning from primary sign-in
20+
- `__clerk_sync=2` (Completed): Prevents re-sync loop after handshake completes
21+
22+
Backwards compatible: Still reads legacy `__clerk_synced=true` parameter.
23+
24+
**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.
25+
26+
**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`.
27+
28+
## Usage
29+
30+
### SSR (Next.js Middleware)
31+
```typescript
32+
import { clerkMiddleware } from '@clerk/nextjs/server';
33+
34+
export default clerkMiddleware({
35+
isSatellite: true,
36+
domain: 'satellite.example.com',
37+
signInUrl: 'https://primary.example.com/sign-in',
38+
// Set to true to automatically sync auth state on first load
39+
satelliteAutoSync: true,
40+
});
41+
```
42+
43+
### SSR (TanStack Start)
44+
```typescript
45+
import { clerkMiddleware } from '@clerk/tanstack-react-start/server';
46+
47+
export default clerkMiddleware({
48+
isSatellite: true,
49+
domain: 'satellite.example.com',
50+
signInUrl: 'https://primary.example.com/sign-in',
51+
// Set to true to automatically sync auth state on first load
52+
satelliteAutoSync: true,
53+
});
54+
```
55+
56+
### CSR (ClerkProvider)
57+
```tsx
58+
<ClerkProvider
59+
publishableKey="pk_..."
60+
isSatellite={true}
61+
domain="satellite.example.com"
62+
signInUrl="https://primary.example.com/sign-in"
63+
// Set to true to automatically sync auth state on first load
64+
satelliteAutoSync={true}
65+
>
66+
{children}
67+
</ClerkProvider>
68+
```
69+
70+
### SSR (TanStack Start with callback)
71+
```typescript
72+
import { clerkMiddleware } from '@clerk/tanstack-react-start/server';
73+
74+
// Options callback - receives context object, returns options
75+
export default clerkMiddleware(({ url }) => ({
76+
isSatellite: true,
77+
domain: 'satellite.example.com',
78+
signInUrl: 'https://primary.example.com/sign-in',
79+
satelliteAutoSync: url.pathname.startsWith('/dashboard'),
80+
}));
81+
```
82+
83+
## Migration Guide
84+
85+
### Behavior change: `satelliteAutoSync` defaults to `false`
86+
87+
Previously, satellite apps would automatically trigger a handshake redirect on every first page load to sync authentication state with the primary domain—even when no session cookies existed. This caused unnecessary redirects to the primary domain for users who weren't authenticated.
88+
89+
The new default (`satelliteAutoSync: false`) provides a better experience for end users. Performance-wise, the satellite app can be shown immediately without attempting to sync state first, which is the right behavior for most use cases.
90+
91+
**To preserve the previous behavior** where visiting a satellite while already signed in on the primary domain automatically syncs your session, set `satelliteAutoSync: true`:
92+
93+
```typescript
94+
export default clerkMiddleware({
95+
isSatellite: true,
96+
domain: 'satellite.example.com',
97+
signInUrl: 'https://primary.example.com/sign-in',
98+
satelliteAutoSync: true, // Opt-in to automatic sync on first load
99+
});
100+
```
101+
102+
### TanStack Start: Function props to options callback
103+
104+
The `clerkMiddleware` function no longer accepts individual props as functions. If you were using the function form for props like `domain`, `proxyUrl`, or `isSatellite`, migrate to the options callback pattern.
105+
106+
**Before (prop function form - no longer supported):**
107+
```typescript
108+
import { clerkMiddleware } from '@clerk/tanstack-react-start/server';
109+
110+
export default clerkMiddleware({
111+
isSatellite: true,
112+
// ❌ Function form for individual props no longer works
113+
domain: (url) => url.hostname,
114+
});
115+
```
116+
117+
**After (options callback form):**
118+
```typescript
119+
import { clerkMiddleware } from '@clerk/tanstack-react-start/server';
120+
121+
// ✅ Wrap entire options in a callback function
122+
export default clerkMiddleware(({ url }) => ({
123+
isSatellite: true,
124+
domain: url.hostname,
125+
}));
126+
```
127+
128+
The callback receives a context object with the `url` property (a `URL` instance) and can return options synchronously or as a Promise for async configuration.

integration/tests/handshake.test.ts

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,13 +42,15 @@ test.describe('Client handshake @generic', () => {
4242
() => `import { clerkMiddleware } from '@clerk/nextjs/server';
4343
4444
export const middleware = (req, evt) => {
45+
const satelliteAutoSyncHeader = req.headers.get('x-satellite-auto-sync');
4546
return clerkMiddleware({
4647
publishableKey: req.headers.get("x-publishable-key"),
4748
secretKey: req.headers.get("x-secret-key"),
4849
proxyUrl: req.headers.get("x-proxy-url"),
4950
domain: req.headers.get("x-domain"),
5051
isSatellite: req.headers.get('x-satellite') === 'true',
5152
signInUrl: req.headers.get("x-sign-in-url"),
53+
satelliteAutoSync: satelliteAutoSyncHeader === null ? undefined : satelliteAutoSyncHeader === 'true',
5254
})(req, evt)
5355
};
5456
@@ -567,6 +569,86 @@ test.describe('Client handshake @generic', () => {
567569
expect(res.status).toBe(200);
568570
});
569571

572+
test('signed out satellite with satelliteAutoSync=false skips handshake - prod', async () => {
573+
const config = generateConfig({
574+
mode: 'live',
575+
});
576+
const res = await fetch(app.serverUrl + '/', {
577+
headers: new Headers({
578+
'X-Publishable-Key': config.pk,
579+
'X-Secret-Key': config.sk,
580+
'X-Satellite': 'true',
581+
'X-Domain': 'example.com',
582+
'X-Satellite-Auto-Sync': 'false',
583+
'Sec-Fetch-Dest': 'document',
584+
}),
585+
redirect: 'manual',
586+
});
587+
// Should NOT redirect to handshake when satelliteAutoSync=false and no cookies
588+
expect(res.status).toBe(200);
589+
});
590+
591+
test('signed out satellite with satelliteAutoSync=false triggers handshake when __clerk_synced=false - prod', async () => {
592+
const config = generateConfig({
593+
mode: 'live',
594+
});
595+
const res = await fetch(app.serverUrl + '/?__clerk_synced=false', {
596+
headers: new Headers({
597+
'X-Publishable-Key': config.pk,
598+
'X-Secret-Key': config.sk,
599+
'X-Satellite': 'true',
600+
'X-Domain': 'example.com',
601+
'X-Satellite-Auto-Sync': 'false',
602+
'Sec-Fetch-Dest': 'document',
603+
}),
604+
redirect: 'manual',
605+
});
606+
// Should redirect to handshake when __clerk_synced=false is present
607+
expect(res.status).toBe(307);
608+
const locationUrl = new URL(res.headers.get('location'));
609+
expect(locationUrl.origin + locationUrl.pathname).toBe('https://clerk.example.com/v1/client/handshake');
610+
expect(locationUrl.searchParams.get('__clerk_hs_reason')).toBe('satellite-needs-syncing');
611+
});
612+
613+
test('signed out satellite skips handshake when __clerk_synced=true (completed) - prod', async () => {
614+
const config = generateConfig({
615+
mode: 'live',
616+
});
617+
const res = await fetch(app.serverUrl + '/?__clerk_synced=true', {
618+
headers: new Headers({
619+
'X-Publishable-Key': config.pk,
620+
'X-Secret-Key': config.sk,
621+
'X-Satellite': 'true',
622+
'X-Domain': 'example.com',
623+
'Sec-Fetch-Dest': 'document',
624+
}),
625+
redirect: 'manual',
626+
});
627+
// Should NOT redirect when __clerk_synced=true indicates sync already completed
628+
expect(res.status).toBe(200);
629+
});
630+
631+
test('signed out satellite with satelliteAutoSync=true (default) triggers handshake - prod', async () => {
632+
const config = generateConfig({
633+
mode: 'live',
634+
});
635+
const res = await fetch(app.serverUrl + '/', {
636+
headers: new Headers({
637+
'X-Publishable-Key': config.pk,
638+
'X-Secret-Key': config.sk,
639+
'X-Satellite': 'true',
640+
'X-Domain': 'example.com',
641+
'X-Satellite-Auto-Sync': 'true',
642+
'Sec-Fetch-Dest': 'document',
643+
}),
644+
redirect: 'manual',
645+
});
646+
// Should redirect to handshake with default/true satelliteAutoSync
647+
expect(res.status).toBe(307);
648+
const locationUrl = new URL(res.headers.get('location'));
649+
expect(locationUrl.origin + locationUrl.pathname).toBe('https://clerk.example.com/v1/client/handshake');
650+
});
651+
570652
test('missing session token, missing uat (indicating signed out), missing devbrowser - dev', async () => {
571653
const config = generateConfig({
572654
mode: 'test',

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -300,6 +300,7 @@ function decorateAstroLocal(
300300
signInUrl: requestState.signInUrl,
301301
signUpUrl: requestState.signUpUrl,
302302
sessionStatus: requestState.toAuth()?.sessionStatus,
303+
isSatellite: requestState.isSatellite,
303304
}).redirectToSignIn({
304305
returnBackUrl: opts.returnBackUrl === null ? '' : opts.returnBackUrl || clerkUrl.toString(),
305306
});
@@ -422,6 +423,7 @@ const handleControlFlowErrors = (
422423
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
423424
publishableKey: getSafeEnv(context).pk!,
424425
sessionStatus: requestState.toAuth()?.sessionStatus,
426+
isSatellite: requestState.isSatellite,
425427
}).redirectToSignIn({ returnBackUrl: e.returnBackUrl });
426428
default:
427429
throw e;

packages/backend/src/__tests__/createRedirect.test.ts

Lines changed: 31 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -99,26 +99,44 @@ describe('redirect(redirectAdapter)', () => {
9999
);
100100
});
101101

102-
it('removes __clerk_synced when cross-origin redirect', () => {
103-
const returnBackUrl = 'https://current.url:3000/path?__clerk_synced=true&q=1#hash';
104-
const encodedUrl = 'https%3A%2F%2Fcurrent.url%3A3000%2Fpath%3Fq%3D1%23hash';
105-
const signUpUrl = 'https://lcl.dev/sign-up';
102+
it('adds __clerk_synced=false to returnBackUrl for satellite apps on cross-origin redirect', () => {
103+
const returnBackUrl = 'https://satellite.example.com/dashboard';
104+
const encodedUrl = 'https%3A%2F%2Fsatellite.example.com%2Fdashboard%3F__clerk_synced%3Dfalse';
105+
const signInUrl = 'https://primary.example.com/sign-in';
106106

107107
const redirectAdapterSpy = vi.fn().mockImplementation(_url => 'redirectAdapterValue');
108-
const { redirectToSignUp } = createRedirect({
109-
baseUrl: 'http://www.clerk.com',
110-
devBrowserToken: 'deadbeef',
108+
const { redirectToSignIn } = createRedirect({
109+
baseUrl: 'https://satellite.example.com',
111110
redirectAdapter: redirectAdapterSpy,
112-
publishableKey: 'pk_test_Y2xlcmsubGNsLmRldiQ',
111+
publishableKey: 'pk_test_Y2xlcmsuZXhhbXBsZS5jb20k',
113112
sessionStatus: 'active',
114-
signUpUrl,
113+
signInUrl,
114+
isSatellite: true,
115115
});
116116

117-
const result = redirectToSignUp({ returnBackUrl });
117+
const result = redirectToSignIn({ returnBackUrl });
118118
expect(result).toBe('redirectAdapterValue');
119-
expect(redirectAdapterSpy).toHaveBeenCalledWith(
120-
`${signUpUrl}?redirect_url=${encodedUrl}&__clerk_db_jwt=deadbeef`,
121-
);
119+
expect(redirectAdapterSpy).toHaveBeenCalledWith(`${signInUrl}?redirect_url=${encodedUrl}`);
120+
});
121+
122+
it('does not add __clerk_synced=false for non-satellite apps', () => {
123+
const returnBackUrl = 'https://app.example.com/dashboard';
124+
const encodedUrl = 'https%3A%2F%2Fapp.example.com%2Fdashboard';
125+
const signInUrl = 'https://accounts.example.com/sign-in';
126+
127+
const redirectAdapterSpy = vi.fn().mockImplementation(_url => 'redirectAdapterValue');
128+
const { redirectToSignIn } = createRedirect({
129+
baseUrl: 'https://app.example.com',
130+
redirectAdapter: redirectAdapterSpy,
131+
publishableKey: 'pk_test_Y2xlcmsuZXhhbXBsZS5jb20k',
132+
sessionStatus: 'active',
133+
signInUrl,
134+
isSatellite: false,
135+
});
136+
137+
const result = redirectToSignIn({ returnBackUrl });
138+
expect(result).toBe('redirectAdapterValue');
139+
expect(redirectAdapterSpy).toHaveBeenCalledWith(`${signInUrl}?redirect_url=${encodedUrl}`);
122140
});
123141

124142
it('returns path based url with development (kima) publishableKey (with staging Clerk) but without signUpUrl to redirectToSignUp', () => {
@@ -342,25 +360,5 @@ describe('redirect(redirectAdapter)', () => {
342360
`https://included.katydid-92.accounts.dev/sign-up/tasks?redirect_url=${encodedUrl}&__clerk_db_jwt=deadbeef`,
343361
);
344362
});
345-
346-
it('removes __clerk_synced when cross-origin redirect', () => {
347-
const returnBackUrl = 'https://current.url:3000/path?__clerk_synced=true&q=1#hash';
348-
const encodedUrl = 'https%3A%2F%2Fcurrent.url%3A3000%2Fpath%3Fq%3D1%23hash';
349-
350-
const redirectAdapterSpy = vi.fn().mockImplementation(_url => 'redirectAdapterValue');
351-
const { redirectToSignUp } = createRedirect({
352-
baseUrl: 'http://www.clerk.com',
353-
devBrowserToken: 'deadbeef',
354-
redirectAdapter: redirectAdapterSpy,
355-
publishableKey: 'pk_test_Y2xlcmsubGNsLmRldiQ',
356-
sessionStatus: 'pending',
357-
});
358-
359-
const result = redirectToSignUp({ returnBackUrl });
360-
expect(result).toBe('redirectAdapterValue');
361-
expect(redirectAdapterSpy).toHaveBeenCalledWith(
362-
`https://accounts.lcl.dev/sign-up/tasks?redirect_url=${encodedUrl}&__clerk_db_jwt=deadbeef`,
363-
);
364-
});
365363
});
366364
});

packages/backend/src/constants.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,17 @@ const ContentTypes = {
7474
Json: 'application/json',
7575
} as const;
7676

77+
/**
78+
* Sync status values for the __clerk_synced query parameter.
79+
* Used to coordinate satellite domain authentication flows.
80+
*/
81+
export const ClerkSyncStatus = {
82+
/** Not synced - satellite needs handshake after returning from primary sign-in */
83+
NeedsSync: 'false',
84+
/** Sync completed - prevents re-sync loop after handshake completes */
85+
Completed: 'true',
86+
} as const;
87+
7788
/**
7889
* @internal
7990
*/
@@ -83,6 +94,7 @@ export const constants = {
8394
Headers,
8495
ContentTypes,
8596
QueryParameters,
97+
ClerkSyncStatus,
8698
} as const;
8799

88100
export type Constants = typeof constants;

0 commit comments

Comments
 (0)