Merged
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
5 changes: 5 additions & 0 deletions .changeset/yellow-vans-walk.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
---
'@clerk/backend': patch
---

Fix logic for forcing a session sync on cross origin requests.
247 changes: 247 additions & 0 deletions packages/backend/src/tokens/__tests__/request.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -1520,6 +1520,31 @@ describe('tokens.authenticateRequest(options)', () => {
});
});

test('does not trigger handshake when referer is same origin', async () => {
const request = mockRequestWithCookies(
{
host: 'localhost:3000',
referer: 'http://localhost:3000',
'sec-fetch-dest': 'document',
},
{
__clerk_db_jwt: mockJwt,
__session: mockJwt,
__client_uat: '12345',
},
'http://localhost:3000',
);

const requestState = await authenticateRequest(request, {
...mockOptions(),
signInUrl: 'http://localhost:3000/sign-in',
});

expect(requestState).toBeSignedIn({
signInUrl: 'http://localhost:3000/sign-in',
});
});

test('does not trigger handshake when no referer header', async () => {
const request = mockRequestWithCookies(
{
Expand DownExpand Up@@ -1605,5 +1630,227 @@ describe('tokens.authenticateRequest(options)', () => {
signInUrl: 'https://primary.com/sign-in',
});
});

test('does not trigger handshake when referer is from production accounts portal', async () => {
const request = mockRequestWithCookies(
{
referer: 'https://accounts.example.com/sign-in',
'sec-fetch-dest': 'document',
'sec-fetch-site': 'cross-site',
},
{
__session: mockJwt,
__client_uat: '12345',
},
'https://primary.com/dashboard',
);

const requestState = await authenticateRequest(request, {
...mockOptions(),
publishableKey: PK_LIVE,
domain: 'primary.com',
isSatellite: false,
signInUrl: 'https://primary.com/sign-in',
});

expect(requestState).toBeSignedIn({
domain: 'primary.com',
isSatellite: false,
signInUrl: 'https://primary.com/sign-in',
});
});

Comment on lines +1633 to +1662

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Test currently passes due to permissive “accounts.*” fallback; align with real Clerk host.

This case uses https://accounts.example.com/..., which is not derivable from the publishable key in mockOptions(). It only passes because production code whitelists any accounts.* host. If we tighten matching (recommended), this test will fail.

Change the referer to the expected accounts origin derived from the PK used in tests (e.g., accounts.inspired.puma-74.lcl.dev) or compute it from the PK to avoid coupling to the permissive fallback.

- referer: 'https://accounts.example.com/sign-in',+ referer: 'https://accounts.inspired.puma-74.lcl.dev/sign-in',

Alternatively, if you want to exercise a truly “prod-style” hostname, derive it from buildAccountsBaseUrl(frontendApi) seeded by the parsed PK rather than hardcoding example.com.

📝 Committable suggestion

‼️IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
test('does not trigger handshake when referer is from production accounts portal',async()=>{
constrequest=mockRequestWithCookies(
{
referer: 'https://accounts.example.com/sign-in',
'sec-fetch-dest': 'document',
'sec-fetch-site': 'cross-site',
},
{
__session: mockJwt,
__client_uat: '12345',
},
'https://primary.com/dashboard',
);
constrequestState=awaitauthenticateRequest(request,{
...mockOptions(),
publishableKey: PK_LIVE,
domain: 'primary.com',
isSatellite: false,
signInUrl: 'https://primary.com/sign-in',
});
expect(requestState).toBeSignedIn({
domain: 'primary.com',
isSatellite: false,
signInUrl: 'https://primary.com/sign-in',
});
});
test('does not trigger handshake when referer is from production accounts portal',async()=>{
constrequest=mockRequestWithCookies(
{
referer: 'https://accounts.inspired.puma-74.lcl.dev/sign-in',
'sec-fetch-dest': 'document',
'sec-fetch-site': 'cross-site',
},
{
__session: mockJwt,
__client_uat: '12345',
},
'https://primary.com/dashboard',
);
constrequestState=awaitauthenticateRequest(request,{
...mockOptions(),
publishableKey: PK_LIVE,
domain: 'primary.com',
isSatellite: false,
signInUrl: 'https://primary.com/sign-in',
});
expect(requestState).toBeSignedIn({
domain: 'primary.com',
isSatellite: false,
signInUrl: 'https://primary.com/sign-in',
});
});
🤖 Prompt for AI Agents
In packages/backend/src/tokens/__tests__/request.test.ts around lines 1608 to
1637, the test hardcodes referer "https://accounts.example.com/..." which only
passes because production code currently allows any accounts.* host; update the
test to use the actual accounts origin derived from the publishable key in
mockOptions() (e.g., call the same helper used in production like
buildAccountsBaseUrl(frontendApi) or construct
"https://accounts.<frontendApi-derived-host>") so the referer matches the PK
used in the test instead of relying on the permissive accounts.* fallback.

test('does not trigger handshake when referer is from dev accounts portal (current format)', async () => {
const request = mockRequestWithCookies(
{
referer: 'https://foo-bar-13.accounts.dev/sign-in',
'sec-fetch-dest': 'document',
'sec-fetch-site': 'cross-site',
},
{
__session: mockJwt,
__client_uat: '12345',
},
'https://primary.com/dashboard',
);

const requestState = await authenticateRequest(request, {
...mockOptions(),
publishableKey: PK_LIVE,
domain: 'primary.com',
isSatellite: false,
signInUrl: 'https://primary.com/sign-in',
});

expect(requestState).toBeSignedIn({
domain: 'primary.com',
isSatellite: false,
signInUrl: 'https://primary.com/sign-in',
});
});

test('does not trigger handshake when referer is from dev accounts portal (legacy format)', async () => {
const request = mockRequestWithCookies(
{
referer: 'https://accounts.foo-bar-13.lcl.dev/sign-in',
'sec-fetch-dest': 'document',
'sec-fetch-site': 'cross-site',
},
{
__session: mockJwt,
__client_uat: '12345',
},
'https://primary.com/dashboard',
);

const requestState = await authenticateRequest(request, {
...mockOptions(),
publishableKey: PK_LIVE,
domain: 'primary.com',
isSatellite: false,
signInUrl: 'https://primary.com/sign-in',
});

expect(requestState).toBeSignedIn({
domain: 'primary.com',
isSatellite: false,
signInUrl: 'https://primary.com/sign-in',
});
});

test('does not trigger cross-origin handshake when referer is from expected accounts portal derived from frontend API', async () => {
const request = mockRequestWithCookies(
{
referer: 'https://accounts.inspired.puma-74.lcl.dev/sign-in',
'sec-fetch-dest': 'document',
'sec-fetch-site': 'cross-site',
},
{
__session: mockJwt,
__client_uat: '12345',
},
'https://primary.com/dashboard',
);

const requestState = await authenticateRequest(request, {
...mockOptions(),
domain: 'primary.com',
isSatellite: false,
signInUrl: 'https://primary.com/sign-in',
});

// Should not trigger the specific cross-origin sync handshake we're trying to prevent
expect(requestState.reason).not.toBe(AuthErrorReason.PrimaryDomainCrossOriginSync);
});
Comment on lines +1721 to +1744

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Add a negative test to prevent regressions: “accounts.attacker.com” must still trigger cross-origin handshake.

To guard against over-broad whitelisting, add a case where the referer is an unrelated accounts.* domain and assert that PrimaryDomainCrossOriginSync is triggered.

@@
describe('Cross-origin sync', () => {
@@
+ test('triggers handshake when referer is unrelated accounts.* domain', async () => {+ const request = mockRequestWithCookies(+ {+ referer: 'https://accounts.attacker.com/signin',+ 'sec-fetch-dest': 'document',+ 'sec-fetch-site': 'cross-site',+ },+ {+ __session: mockJwt,+ __client_uat: '12345',+ },+ 'https://primary.com/dashboard',+ );++ const requestState = await authenticateRequest(request, {+ ...mockOptions(),+ publishableKey: PK_LIVE,+ domain: 'primary.com',+ isSatellite: false,+ signInUrl: 'https://primary.com/sign-in',+ });++ expect(requestState).toMatchHandshake({+ reason: AuthErrorReason.PrimaryDomainCrossOriginSync,+ domain: 'primary.com',+ signInUrl: 'https://primary.com/sign-in',+ });+ });
📝 Committable suggestion

‼️IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
test('does not trigger cross-origin handshake when referer is from expected accounts portal derived from frontend API',async()=>{
constrequest=mockRequestWithCookies(
{
referer: 'https://accounts.inspired.puma-74.lcl.dev/sign-in',
'sec-fetch-dest': 'document',
'sec-fetch-site': 'cross-site',
},
{
__session: mockJwt,
__client_uat: '12345',
},
'https://primary.com/dashboard',
);
constrequestState=awaitauthenticateRequest(request,{
...mockOptions(),
domain: 'primary.com',
isSatellite: false,
signInUrl: 'https://primary.com/sign-in',
});
// Should not trigger the specific cross-origin sync handshake we're trying to prevent
expect(requestState.reason).not.toBe(AuthErrorReason.PrimaryDomainCrossOriginSync);
});
describe('Cross-origin sync',()=>{
test('does not trigger cross-origin handshake when referer is from expected accounts portal derived from frontend API',async()=>{
constrequest=mockRequestWithCookies(
{
referer: 'https://accounts.inspired.puma-74.lcl.dev/sign-in',
'sec-fetch-dest': 'document',
'sec-fetch-site': 'cross-site',
},
{
__session: mockJwt,
__client_uat: '12345',
},
'https://primary.com/dashboard',
);
constrequestState=awaitauthenticateRequest(request,{
...mockOptions(),
domain: 'primary.com',
isSatellite: false,
signInUrl: 'https://primary.com/sign-in',
});
// Should not trigger the specific cross-origin sync handshake we're trying to prevent
expect(requestState.reason).not.toBe(AuthErrorReason.PrimaryDomainCrossOriginSync);
});
test('triggers handshake when referer is unrelated accounts.* domain',async()=>{
constrequest=mockRequestWithCookies(
{
referer: 'https://accounts.attacker.com/signin',
'sec-fetch-dest': 'document',
'sec-fetch-site': 'cross-site',
},
{
__session: mockJwt,
__client_uat: '12345',
},
'https://primary.com/dashboard',
);
constrequestState=awaitauthenticateRequest(request,{
...mockOptions(),
publishableKey: PK_LIVE,
domain: 'primary.com',
isSatellite: false,
signInUrl: 'https://primary.com/sign-in',
});
expect(requestState).toMatchHandshake({
reason: AuthErrorReason.PrimaryDomainCrossOriginSync,
domain: 'primary.com',
signInUrl: 'https://primary.com/sign-in',
});
});
});
🤖 Prompt for AI Agents
In packages/backend/src/tokens/__tests__/request.test.ts around lines 1696 to
1719, add a negative test case that ensures an unrelated accounts.* origin still
triggers the cross-origin handshake: create a request similar to the existing
test but with referer 'https://accounts.attacker.com/sign-in' (keep
'sec-fetch-site': 'cross-site', cookies same, and origin
'https://primary.com/dashboard'), call authenticateRequest with the same
mockOptions (domain: 'primary.com', isSatellite: false, signInUrl:
'https://primary.com/sign-in'), and assert that requestState.reason ===
AuthErrorReason.PrimaryDomainCrossOriginSync to prevent over-broad whitelisting.


test('does not trigger handshake when referer is from FAPI domain (redirect-based auth)', async () => {
const request = mockRequestWithCookies(
{
referer: 'https://clerk.inspired.puma-74.lcl.dev/v1/client/sign_ins/12345/attempt_first_factor',
'sec-fetch-dest': 'document',
'sec-fetch-site': 'cross-site',
},
{
__session: mockJwt,
__client_uat: '12345',
},
'https://primary.com/dashboard',
);

const requestState = await authenticateRequest(request, {
...mockOptions(),
domain: 'primary.com',
isSatellite: false,
signInUrl: 'https://primary.com/sign-in',
});

// Should not trigger the specific cross-origin sync handshake we're trying to prevent
expect(requestState.reason).not.toBe(AuthErrorReason.PrimaryDomainCrossOriginSync);
});

test('does not trigger handshake when referer is from FAPI domain with https prefix', async () => {
const request = mockRequestWithCookies(
{
referer: 'https://clerk.inspired.puma-74.lcl.dev/sign-in',
'sec-fetch-dest': 'document',
'sec-fetch-site': 'cross-site',
},
{
__session: mockJwt,
__client_uat: '12345',
},
'https://primary.com/dashboard',
);

const requestState = await authenticateRequest(request, {
...mockOptions(),
domain: 'primary.com',
isSatellite: false,
signInUrl: 'https://primary.com/sign-in',
});

// Should not trigger the specific cross-origin sync handshake we're trying to prevent
expect(requestState.reason).not.toBe(AuthErrorReason.PrimaryDomainCrossOriginSync);
});

test('still triggers handshake for legitimate cross-origin requests from non-accounts domains', async () => {
const request = mockRequestWithCookies(
{
referer: 'https://satellite.com/sign-in',
'sec-fetch-dest': 'document',
'sec-fetch-site': 'cross-site',
},
{
__session: mockJwt,
__client_uat: '12345',
},
'https://primary.com/dashboard',
);

const requestState = await authenticateRequest(request, {
...mockOptions(),
publishableKey: PK_LIVE,
domain: 'primary.com',
isSatellite: false,
signInUrl: 'https://primary.com/sign-in',
});

expect(requestState).toMatchHandshake({
reason: AuthErrorReason.PrimaryDomainCrossOriginSync,
domain: 'primary.com',
signInUrl: 'https://primary.com/sign-in',
});
});

test('does not trigger handshake when referrer matches current origin despite sec-fetch-site cross-site (redirect chain)', async () => {
const request = mockRequestWithCookies(
{
host: 'primary.com',
referer: 'https://primary.com/some-page',
'sec-fetch-dest': 'document',
'sec-fetch-site': 'cross-site', // This can happen due to redirect chains through Clerk domains
},
{
__session: mockJwt,
__client_uat: '12345',
},
'https://primary.com/dashboard',
);

const requestState = await authenticateRequest(request, {
...mockOptions(),
publishableKey: PK_LIVE,
domain: 'primary.com',
isSatellite: false,
signInUrl: 'https://primary.com/sign-in',
});

// Should not trigger handshake because referrer origin matches current origin
expect(requestState).toBeSignedIn({
domain: 'primary.com',
isSatellite: false,
signInUrl: 'https://primary.com/sign-in',
});
});
});
});
56 changes: 52 additions & 4 deletions packages/backend/src/tokens/authenticateContext.ts
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
import { buildAccountsBaseUrl } from '@clerk/shared/buildAccountsBaseUrl';
import { isCurrentDevAccountPortalOrigin, isLegacyDevAccountPortalOrigin } from '@clerk/shared/url';
import type { Jwt } from '@clerk/types';

import { constants } from '../constants';
Expand DownExpand Up@@ -186,10 +188,6 @@ class AuthenticateContext implements AuthenticateContext {
}

try {
if (this.getHeader(constants.Headers.SecFetchSite) === 'cross-site') {
return true;
}

const referrerOrigin = new URL(this.referrer).origin;
return referrerOrigin !== this.clerkUrl.origin;
} catch {
Expand All@@ -198,6 +196,56 @@ class AuthenticateContext implements AuthenticateContext {
}
}

/**
* Determines if the referrer URL is from a Clerk domain (accounts portal or FAPI).
* This includes both development and production account portal domains, as well as FAPI domains
* used for redirect-based authentication flows.
*
* @returns {boolean} True if the referrer is from a Clerk accounts portal or FAPI domain, false otherwise
*/
public isKnownClerkReferrer(): boolean {
if (!this.referrer) {
return false;
}

try {
const referrerOrigin = new URL(this.referrer);
const referrerHost = referrerOrigin.hostname;

// Check if referrer is the FAPI domain itself (redirect-based auth flows)
if (this.frontendApi) {
const fapiHost = this.frontendApi.startsWith('http') ? new URL(this.frontendApi).hostname : this.frontendApi;
if (referrerHost === fapiHost) {
return true;
}
}

// Check for development account portal patterns
if (isLegacyDevAccountPortalOrigin(referrerHost) || isCurrentDevAccountPortalOrigin(referrerHost)) {
return true;
}

// Check for production account portal by comparing with expected accounts URL
const expectedAccountsUrl = buildAccountsBaseUrl(this.frontendApi);
if (expectedAccountsUrl) {
const expectedAccountsOrigin = new URL(expectedAccountsUrl).origin;
if (referrerOrigin.origin === expectedAccountsOrigin) {
return true;
}
}

// Check for generic production accounts patterns (accounts.*)
if (referrerHost.startsWith('accounts.')) {
return true;
}

return false;
} catch {
// Invalid URL format
return false;
}
}

private initPublishableKeyValues(options: AuthenticateRequestOptions) {
assertValidPublishableKey(options.publishableKey);
this.publishableKey = options.publishableKey;
Expand Down
3 changes: 2 additions & 1 deletion packages/backend/src/tokens/request.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -576,7 +576,8 @@ export const authenticateRequest: AuthenticateRequest = (async (
const shouldForceHandshakeForCrossDomain =
!authenticateContext.isSatellite && // We're on primary
authenticateContext.secFetchDest === 'document' && // Document navigation
authenticateContext.isCrossOriginReferrer(); // Came from different domain
authenticateContext.isCrossOriginReferrer() && // Came from different domain
!authenticateContext.isKnownClerkReferrer(); // Not from Clerk accounts portal or FAPI

if (shouldForceHandshakeForCrossDomain) {
return handleMaybeHandshakeStatus(
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content
Merged
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
5 changes: 5 additions & 0 deletions .changeset/yellow-vans-walk.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
---
'@clerk/backend': patch
---

Fix logic for forcing a session sync on cross origin requests.
247 changes: 247 additions & 0 deletions packages/backend/src/tokens/__tests__/request.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -1520,6 +1520,31 @@ describe('tokens.authenticateRequest(options)', () => {
});
});

test('does not trigger handshake when referer is same origin', async () => {
const request = mockRequestWithCookies(
{
host: 'localhost:3000',
referer: 'http://localhost:3000',
'sec-fetch-dest': 'document',
},
{
__clerk_db_jwt: mockJwt,
__session: mockJwt,
__client_uat: '12345',
},
'http://localhost:3000',
);

const requestState = await authenticateRequest(request, {
...mockOptions(),
signInUrl: 'http://localhost:3000/sign-in',
});

expect(requestState).toBeSignedIn({
signInUrl: 'http://localhost:3000/sign-in',
});
});

test('does not trigger handshake when no referer header', async () => {
const request = mockRequestWithCookies(
{
Expand DownExpand Up@@ -1605,5 +1630,227 @@ describe('tokens.authenticateRequest(options)', () => {
signInUrl: 'https://primary.com/sign-in',
});
});

test('does not trigger handshake when referer is from production accounts portal', async () => {
const request = mockRequestWithCookies(
{
referer: 'https://accounts.example.com/sign-in',
'sec-fetch-dest': 'document',
'sec-fetch-site': 'cross-site',
},
{
__session: mockJwt,
__client_uat: '12345',
},
'https://primary.com/dashboard',
);

const requestState = await authenticateRequest(request, {
...mockOptions(),
publishableKey: PK_LIVE,
domain: 'primary.com',
isSatellite: false,
signInUrl: 'https://primary.com/sign-in',
});

expect(requestState).toBeSignedIn({
domain: 'primary.com',
isSatellite: false,
signInUrl: 'https://primary.com/sign-in',
});
});

Comment on lines +1633 to +1662

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Test currently passes due to permissive “accounts.*” fallback; align with real Clerk host.

This case uses https://accounts.example.com/..., which is not derivable from the publishable key in mockOptions(). It only passes because production code whitelists any accounts.* host. If we tighten matching (recommended), this test will fail.

Change the referer to the expected accounts origin derived from the PK used in tests (e.g., accounts.inspired.puma-74.lcl.dev) or compute it from the PK to avoid coupling to the permissive fallback.

- referer: 'https://accounts.example.com/sign-in',+ referer: 'https://accounts.inspired.puma-74.lcl.dev/sign-in',

Alternatively, if you want to exercise a truly “prod-style” hostname, derive it from buildAccountsBaseUrl(frontendApi) seeded by the parsed PK rather than hardcoding example.com.

📝 Committable suggestion

‼️IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
test('does not trigger handshake when referer is from production accounts portal',async()=>{
constrequest=mockRequestWithCookies(
{
referer: 'https://accounts.example.com/sign-in',
'sec-fetch-dest': 'document',
'sec-fetch-site': 'cross-site',
},
{
__session: mockJwt,
__client_uat: '12345',
},
'https://primary.com/dashboard',
);
constrequestState=awaitauthenticateRequest(request,{
...mockOptions(),
publishableKey: PK_LIVE,
domain: 'primary.com',
isSatellite: false,
signInUrl: 'https://primary.com/sign-in',
});
expect(requestState).toBeSignedIn({
domain: 'primary.com',
isSatellite: false,
signInUrl: 'https://primary.com/sign-in',
});
});
test('does not trigger handshake when referer is from production accounts portal',async()=>{
constrequest=mockRequestWithCookies(
{
referer: 'https://accounts.inspired.puma-74.lcl.dev/sign-in',
'sec-fetch-dest': 'document',
'sec-fetch-site': 'cross-site',
},
{
__session: mockJwt,
__client_uat: '12345',
},
'https://primary.com/dashboard',
);
constrequestState=awaitauthenticateRequest(request,{
...mockOptions(),
publishableKey: PK_LIVE,
domain: 'primary.com',
isSatellite: false,
signInUrl: 'https://primary.com/sign-in',
});
expect(requestState).toBeSignedIn({
domain: 'primary.com',
isSatellite: false,
signInUrl: 'https://primary.com/sign-in',
});
});
🤖 Prompt for AI Agents
In packages/backend/src/tokens/__tests__/request.test.ts around lines 1608 to
1637, the test hardcodes referer "https://accounts.example.com/..." which only
passes because production code currently allows any accounts.* host; update the
test to use the actual accounts origin derived from the publishable key in
mockOptions() (e.g., call the same helper used in production like
buildAccountsBaseUrl(frontendApi) or construct
"https://accounts.<frontendApi-derived-host>") so the referer matches the PK
used in the test instead of relying on the permissive accounts.* fallback.

test('does not trigger handshake when referer is from dev accounts portal (current format)', async () => {
const request = mockRequestWithCookies(
{
referer: 'https://foo-bar-13.accounts.dev/sign-in',
'sec-fetch-dest': 'document',
'sec-fetch-site': 'cross-site',
},
{
__session: mockJwt,
__client_uat: '12345',
},
'https://primary.com/dashboard',
);

const requestState = await authenticateRequest(request, {
...mockOptions(),
publishableKey: PK_LIVE,
domain: 'primary.com',
isSatellite: false,
signInUrl: 'https://primary.com/sign-in',
});

expect(requestState).toBeSignedIn({
domain: 'primary.com',
isSatellite: false,
signInUrl: 'https://primary.com/sign-in',
});
});

test('does not trigger handshake when referer is from dev accounts portal (legacy format)', async () => {
const request = mockRequestWithCookies(
{
referer: 'https://accounts.foo-bar-13.lcl.dev/sign-in',
'sec-fetch-dest': 'document',
'sec-fetch-site': 'cross-site',
},
{
__session: mockJwt,
__client_uat: '12345',
},
'https://primary.com/dashboard',
);

const requestState = await authenticateRequest(request, {
...mockOptions(),
publishableKey: PK_LIVE,
domain: 'primary.com',
isSatellite: false,
signInUrl: 'https://primary.com/sign-in',
});

expect(requestState).toBeSignedIn({
domain: 'primary.com',
isSatellite: false,
signInUrl: 'https://primary.com/sign-in',
});
});

test('does not trigger cross-origin handshake when referer is from expected accounts portal derived from frontend API', async () => {
const request = mockRequestWithCookies(
{
referer: 'https://accounts.inspired.puma-74.lcl.dev/sign-in',
'sec-fetch-dest': 'document',
'sec-fetch-site': 'cross-site',
},
{
__session: mockJwt,
__client_uat: '12345',
},
'https://primary.com/dashboard',
);

const requestState = await authenticateRequest(request, {
...mockOptions(),
domain: 'primary.com',
isSatellite: false,
signInUrl: 'https://primary.com/sign-in',
});

// Should not trigger the specific cross-origin sync handshake we're trying to prevent
expect(requestState.reason).not.toBe(AuthErrorReason.PrimaryDomainCrossOriginSync);
});
Comment on lines +1721 to +1744

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Add a negative test to prevent regressions: “accounts.attacker.com” must still trigger cross-origin handshake.

To guard against over-broad whitelisting, add a case where the referer is an unrelated accounts.* domain and assert that PrimaryDomainCrossOriginSync is triggered.

@@
describe('Cross-origin sync', () => {
@@
+ test('triggers handshake when referer is unrelated accounts.* domain', async () => {+ const request = mockRequestWithCookies(+ {+ referer: 'https://accounts.attacker.com/signin',+ 'sec-fetch-dest': 'document',+ 'sec-fetch-site': 'cross-site',+ },+ {+ __session: mockJwt,+ __client_uat: '12345',+ },+ 'https://primary.com/dashboard',+ );++ const requestState = await authenticateRequest(request, {+ ...mockOptions(),+ publishableKey: PK_LIVE,+ domain: 'primary.com',+ isSatellite: false,+ signInUrl: 'https://primary.com/sign-in',+ });++ expect(requestState).toMatchHandshake({+ reason: AuthErrorReason.PrimaryDomainCrossOriginSync,+ domain: 'primary.com',+ signInUrl: 'https://primary.com/sign-in',+ });+ });
📝 Committable suggestion

‼️IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
test('does not trigger cross-origin handshake when referer is from expected accounts portal derived from frontend API',async()=>{
constrequest=mockRequestWithCookies(
{
referer: 'https://accounts.inspired.puma-74.lcl.dev/sign-in',
'sec-fetch-dest': 'document',
'sec-fetch-site': 'cross-site',
},
{
__session: mockJwt,
__client_uat: '12345',
},
'https://primary.com/dashboard',
);
constrequestState=awaitauthenticateRequest(request,{
...mockOptions(),
domain: 'primary.com',
isSatellite: false,
signInUrl: 'https://primary.com/sign-in',
});
// Should not trigger the specific cross-origin sync handshake we're trying to prevent
expect(requestState.reason).not.toBe(AuthErrorReason.PrimaryDomainCrossOriginSync);
});
describe('Cross-origin sync',()=>{
test('does not trigger cross-origin handshake when referer is from expected accounts portal derived from frontend API',async()=>{
constrequest=mockRequestWithCookies(
{
referer: 'https://accounts.inspired.puma-74.lcl.dev/sign-in',
'sec-fetch-dest': 'document',
'sec-fetch-site': 'cross-site',
},
{
__session: mockJwt,
__client_uat: '12345',
},
'https://primary.com/dashboard',
);
constrequestState=awaitauthenticateRequest(request,{
...mockOptions(),
domain: 'primary.com',
isSatellite: false,
signInUrl: 'https://primary.com/sign-in',
});
// Should not trigger the specific cross-origin sync handshake we're trying to prevent
expect(requestState.reason).not.toBe(AuthErrorReason.PrimaryDomainCrossOriginSync);
});
test('triggers handshake when referer is unrelated accounts.* domain',async()=>{
constrequest=mockRequestWithCookies(
{
referer: 'https://accounts.attacker.com/signin',
'sec-fetch-dest': 'document',
'sec-fetch-site': 'cross-site',
},
{
__session: mockJwt,
__client_uat: '12345',
},
'https://primary.com/dashboard',
);
constrequestState=awaitauthenticateRequest(request,{
...mockOptions(),
publishableKey: PK_LIVE,
domain: 'primary.com',
isSatellite: false,
signInUrl: 'https://primary.com/sign-in',
});
expect(requestState).toMatchHandshake({
reason: AuthErrorReason.PrimaryDomainCrossOriginSync,
domain: 'primary.com',
signInUrl: 'https://primary.com/sign-in',
});
});
});
🤖 Prompt for AI Agents
In packages/backend/src/tokens/__tests__/request.test.ts around lines 1696 to
1719, add a negative test case that ensures an unrelated accounts.* origin still
triggers the cross-origin handshake: create a request similar to the existing
test but with referer 'https://accounts.attacker.com/sign-in' (keep
'sec-fetch-site': 'cross-site', cookies same, and origin
'https://primary.com/dashboard'), call authenticateRequest with the same
mockOptions (domain: 'primary.com', isSatellite: false, signInUrl:
'https://primary.com/sign-in'), and assert that requestState.reason ===
AuthErrorReason.PrimaryDomainCrossOriginSync to prevent over-broad whitelisting.


test('does not trigger handshake when referer is from FAPI domain (redirect-based auth)', async () => {
const request = mockRequestWithCookies(
{
referer: 'https://clerk.inspired.puma-74.lcl.dev/v1/client/sign_ins/12345/attempt_first_factor',
'sec-fetch-dest': 'document',
'sec-fetch-site': 'cross-site',
},
{
__session: mockJwt,
__client_uat: '12345',
},
'https://primary.com/dashboard',
);

const requestState = await authenticateRequest(request, {
...mockOptions(),
domain: 'primary.com',
isSatellite: false,
signInUrl: 'https://primary.com/sign-in',
});

// Should not trigger the specific cross-origin sync handshake we're trying to prevent
expect(requestState.reason).not.toBe(AuthErrorReason.PrimaryDomainCrossOriginSync);
});

test('does not trigger handshake when referer is from FAPI domain with https prefix', async () => {
const request = mockRequestWithCookies(
{
referer: 'https://clerk.inspired.puma-74.lcl.dev/sign-in',
'sec-fetch-dest': 'document',
'sec-fetch-site': 'cross-site',
},
{
__session: mockJwt,
__client_uat: '12345',
},
'https://primary.com/dashboard',
);

const requestState = await authenticateRequest(request, {
...mockOptions(),
domain: 'primary.com',
isSatellite: false,
signInUrl: 'https://primary.com/sign-in',
});

// Should not trigger the specific cross-origin sync handshake we're trying to prevent
expect(requestState.reason).not.toBe(AuthErrorReason.PrimaryDomainCrossOriginSync);
});

test('still triggers handshake for legitimate cross-origin requests from non-accounts domains', async () => {
const request = mockRequestWithCookies(
{
referer: 'https://satellite.com/sign-in',
'sec-fetch-dest': 'document',
'sec-fetch-site': 'cross-site',
},
{
__session: mockJwt,
__client_uat: '12345',
},
'https://primary.com/dashboard',
);

const requestState = await authenticateRequest(request, {
...mockOptions(),
publishableKey: PK_LIVE,
domain: 'primary.com',
isSatellite: false,
signInUrl: 'https://primary.com/sign-in',
});

expect(requestState).toMatchHandshake({
reason: AuthErrorReason.PrimaryDomainCrossOriginSync,
domain: 'primary.com',
signInUrl: 'https://primary.com/sign-in',
});
});

test('does not trigger handshake when referrer matches current origin despite sec-fetch-site cross-site (redirect chain)', async () => {
const request = mockRequestWithCookies(
{
host: 'primary.com',
referer: 'https://primary.com/some-page',
'sec-fetch-dest': 'document',
'sec-fetch-site': 'cross-site', // This can happen due to redirect chains through Clerk domains
},
{
__session: mockJwt,
__client_uat: '12345',
},
'https://primary.com/dashboard',
);

const requestState = await authenticateRequest(request, {
...mockOptions(),
publishableKey: PK_LIVE,
domain: 'primary.com',
isSatellite: false,
signInUrl: 'https://primary.com/sign-in',
});

// Should not trigger handshake because referrer origin matches current origin
expect(requestState).toBeSignedIn({
domain: 'primary.com',
isSatellite: false,
signInUrl: 'https://primary.com/sign-in',
});
});
});
});
56 changes: 52 additions & 4 deletions packages/backend/src/tokens/authenticateContext.ts
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
import { buildAccountsBaseUrl } from '@clerk/shared/buildAccountsBaseUrl';
import { isCurrentDevAccountPortalOrigin, isLegacyDevAccountPortalOrigin } from '@clerk/shared/url';
import type { Jwt } from '@clerk/types';

import { constants } from '../constants';
Expand DownExpand Up@@ -186,10 +188,6 @@ class AuthenticateContext implements AuthenticateContext {
}

try {
if (this.getHeader(constants.Headers.SecFetchSite) === 'cross-site') {
return true;
}

const referrerOrigin = new URL(this.referrer).origin;
return referrerOrigin !== this.clerkUrl.origin;
} catch {
Expand All@@ -198,6 +196,56 @@ class AuthenticateContext implements AuthenticateContext {
}
}

/**
* Determines if the referrer URL is from a Clerk domain (accounts portal or FAPI).
* This includes both development and production account portal domains, as well as FAPI domains
* used for redirect-based authentication flows.
*
* @returns {boolean} True if the referrer is from a Clerk accounts portal or FAPI domain, false otherwise
*/
public isKnownClerkReferrer(): boolean {
if (!this.referrer) {
return false;
}

try {
const referrerOrigin = new URL(this.referrer);
const referrerHost = referrerOrigin.hostname;

// Check if referrer is the FAPI domain itself (redirect-based auth flows)
if (this.frontendApi) {
const fapiHost = this.frontendApi.startsWith('http') ? new URL(this.frontendApi).hostname : this.frontendApi;
if (referrerHost === fapiHost) {
return true;
}
}

// Check for development account portal patterns
if (isLegacyDevAccountPortalOrigin(referrerHost) || isCurrentDevAccountPortalOrigin(referrerHost)) {
return true;
}

// Check for production account portal by comparing with expected accounts URL
const expectedAccountsUrl = buildAccountsBaseUrl(this.frontendApi);
if (expectedAccountsUrl) {
const expectedAccountsOrigin = new URL(expectedAccountsUrl).origin;
if (referrerOrigin.origin === expectedAccountsOrigin) {
return true;
}
}

// Check for generic production accounts patterns (accounts.*)
if (referrerHost.startsWith('accounts.')) {
return true;
}

return false;
} catch {
// Invalid URL format
return false;
}
}

private initPublishableKeyValues(options: AuthenticateRequestOptions) {
assertValidPublishableKey(options.publishableKey);
this.publishableKey = options.publishableKey;
Expand Down
3 changes: 2 additions & 1 deletion packages/backend/src/tokens/request.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -576,7 +576,8 @@ export const authenticateRequest: AuthenticateRequest = (async (
const shouldForceHandshakeForCrossDomain =
!authenticateContext.isSatellite && // We're on primary
authenticateContext.secFetchDest === 'document' && // Document navigation
authenticateContext.isCrossOriginReferrer(); // Came from different domain
authenticateContext.isCrossOriginReferrer() && // Came from different domain
!authenticateContext.isKnownClerkReferrer(); // Not from Clerk accounts portal or FAPI

if (shouldForceHandshakeForCrossDomain) {
return handleMaybeHandshakeStatus(
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
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
5 changes: 5 additions & 0 deletions .changeset/yellow-vans-walk.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
---
'@clerk/backend': patch
---

Fix logic for forcing a session sync on cross origin requests.
247 changes: 247 additions & 0 deletions packages/backend/src/tokens/__tests__/request.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -1520,6 +1520,31 @@ describe('tokens.authenticateRequest(options)', () => {
});
});

test('does not trigger handshake when referer is same origin', async () => {
const request = mockRequestWithCookies(
{
host: 'localhost:3000',
referer: 'http://localhost:3000',
'sec-fetch-dest': 'document',
},
{
__clerk_db_jwt: mockJwt,
__session: mockJwt,
__client_uat: '12345',
},
'http://localhost:3000',
);

const requestState = await authenticateRequest(request, {
...mockOptions(),
signInUrl: 'http://localhost:3000/sign-in',
});

expect(requestState).toBeSignedIn({
signInUrl: 'http://localhost:3000/sign-in',
});
});

test('does not trigger handshake when no referer header', async () => {
const request = mockRequestWithCookies(
{
Expand DownExpand Up@@ -1605,5 +1630,227 @@ describe('tokens.authenticateRequest(options)', () => {
signInUrl: 'https://primary.com/sign-in',
});
});

test('does not trigger handshake when referer is from production accounts portal', async () => {
const request = mockRequestWithCookies(
{
referer: 'https://accounts.example.com/sign-in',
'sec-fetch-dest': 'document',
'sec-fetch-site': 'cross-site',
},
{
__session: mockJwt,
__client_uat: '12345',
},
'https://primary.com/dashboard',
);

const requestState = await authenticateRequest(request, {
...mockOptions(),
publishableKey: PK_LIVE,
domain: 'primary.com',
isSatellite: false,
signInUrl: 'https://primary.com/sign-in',
});

expect(requestState).toBeSignedIn({
domain: 'primary.com',
isSatellite: false,
signInUrl: 'https://primary.com/sign-in',
});
});

Comment on lines +1633 to +1662

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Test currently passes due to permissive “accounts.*” fallback; align with real Clerk host.

This case uses https://accounts.example.com/..., which is not derivable from the publishable key in mockOptions(). It only passes because production code whitelists any accounts.* host. If we tighten matching (recommended), this test will fail.

Change the referer to the expected accounts origin derived from the PK used in tests (e.g., accounts.inspired.puma-74.lcl.dev) or compute it from the PK to avoid coupling to the permissive fallback.

- referer: 'https://accounts.example.com/sign-in',+ referer: 'https://accounts.inspired.puma-74.lcl.dev/sign-in',

Alternatively, if you want to exercise a truly “prod-style” hostname, derive it from buildAccountsBaseUrl(frontendApi) seeded by the parsed PK rather than hardcoding example.com.

📝 Committable suggestion

‼️IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
test('does not trigger handshake when referer is from production accounts portal',async()=>{
constrequest=mockRequestWithCookies(
{
referer: 'https://accounts.example.com/sign-in',
'sec-fetch-dest': 'document',
'sec-fetch-site': 'cross-site',
},
{
__session: mockJwt,
__client_uat: '12345',
},
'https://primary.com/dashboard',
);
constrequestState=awaitauthenticateRequest(request,{
...mockOptions(),
publishableKey: PK_LIVE,
domain: 'primary.com',
isSatellite: false,
signInUrl: 'https://primary.com/sign-in',
});
expect(requestState).toBeSignedIn({
domain: 'primary.com',
isSatellite: false,
signInUrl: 'https://primary.com/sign-in',
});
});
test('does not trigger handshake when referer is from production accounts portal',async()=>{
constrequest=mockRequestWithCookies(
{
referer: 'https://accounts.inspired.puma-74.lcl.dev/sign-in',
'sec-fetch-dest': 'document',
'sec-fetch-site': 'cross-site',
},
{
__session: mockJwt,
__client_uat: '12345',
},
'https://primary.com/dashboard',
);
constrequestState=awaitauthenticateRequest(request,{
...mockOptions(),
publishableKey: PK_LIVE,
domain: 'primary.com',
isSatellite: false,
signInUrl: 'https://primary.com/sign-in',
});
expect(requestState).toBeSignedIn({
domain: 'primary.com',
isSatellite: false,
signInUrl: 'https://primary.com/sign-in',
});
});
🤖 Prompt for AI Agents
In packages/backend/src/tokens/__tests__/request.test.ts around lines 1608 to
1637, the test hardcodes referer "https://accounts.example.com/..." which only
passes because production code currently allows any accounts.* host; update the
test to use the actual accounts origin derived from the publishable key in
mockOptions() (e.g., call the same helper used in production like
buildAccountsBaseUrl(frontendApi) or construct
"https://accounts.<frontendApi-derived-host>") so the referer matches the PK
used in the test instead of relying on the permissive accounts.* fallback.

test('does not trigger handshake when referer is from dev accounts portal (current format)', async () => {
const request = mockRequestWithCookies(
{
referer: 'https://foo-bar-13.accounts.dev/sign-in',
'sec-fetch-dest': 'document',
'sec-fetch-site': 'cross-site',
},
{
__session: mockJwt,
__client_uat: '12345',
},
'https://primary.com/dashboard',
);

const requestState = await authenticateRequest(request, {
...mockOptions(),
publishableKey: PK_LIVE,
domain: 'primary.com',
isSatellite: false,
signInUrl: 'https://primary.com/sign-in',
});

expect(requestState).toBeSignedIn({
domain: 'primary.com',
isSatellite: false,
signInUrl: 'https://primary.com/sign-in',
});
});

test('does not trigger handshake when referer is from dev accounts portal (legacy format)', async () => {
const request = mockRequestWithCookies(
{
referer: 'https://accounts.foo-bar-13.lcl.dev/sign-in',
'sec-fetch-dest': 'document',
'sec-fetch-site': 'cross-site',
},
{
__session: mockJwt,
__client_uat: '12345',
},
'https://primary.com/dashboard',
);

const requestState = await authenticateRequest(request, {
...mockOptions(),
publishableKey: PK_LIVE,
domain: 'primary.com',
isSatellite: false,
signInUrl: 'https://primary.com/sign-in',
});

expect(requestState).toBeSignedIn({
domain: 'primary.com',
isSatellite: false,
signInUrl: 'https://primary.com/sign-in',
});
});

test('does not trigger cross-origin handshake when referer is from expected accounts portal derived from frontend API', async () => {
const request = mockRequestWithCookies(
{
referer: 'https://accounts.inspired.puma-74.lcl.dev/sign-in',
'sec-fetch-dest': 'document',
'sec-fetch-site': 'cross-site',
},
{
__session: mockJwt,
__client_uat: '12345',
},
'https://primary.com/dashboard',
);

const requestState = await authenticateRequest(request, {
...mockOptions(),
domain: 'primary.com',
isSatellite: false,
signInUrl: 'https://primary.com/sign-in',
});

// Should not trigger the specific cross-origin sync handshake we're trying to prevent
expect(requestState.reason).not.toBe(AuthErrorReason.PrimaryDomainCrossOriginSync);
});
Comment on lines +1721 to +1744

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Add a negative test to prevent regressions: “accounts.attacker.com” must still trigger cross-origin handshake.

To guard against over-broad whitelisting, add a case where the referer is an unrelated accounts.* domain and assert that PrimaryDomainCrossOriginSync is triggered.

@@
describe('Cross-origin sync', () => {
@@
+ test('triggers handshake when referer is unrelated accounts.* domain', async () => {+ const request = mockRequestWithCookies(+ {+ referer: 'https://accounts.attacker.com/signin',+ 'sec-fetch-dest': 'document',+ 'sec-fetch-site': 'cross-site',+ },+ {+ __session: mockJwt,+ __client_uat: '12345',+ },+ 'https://primary.com/dashboard',+ );++ const requestState = await authenticateRequest(request, {+ ...mockOptions(),+ publishableKey: PK_LIVE,+ domain: 'primary.com',+ isSatellite: false,+ signInUrl: 'https://primary.com/sign-in',+ });++ expect(requestState).toMatchHandshake({+ reason: AuthErrorReason.PrimaryDomainCrossOriginSync,+ domain: 'primary.com',+ signInUrl: 'https://primary.com/sign-in',+ });+ });
📝 Committable suggestion

‼️IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
test('does not trigger cross-origin handshake when referer is from expected accounts portal derived from frontend API',async()=>{
constrequest=mockRequestWithCookies(
{
referer: 'https://accounts.inspired.puma-74.lcl.dev/sign-in',
'sec-fetch-dest': 'document',
'sec-fetch-site': 'cross-site',
},
{
__session: mockJwt,
__client_uat: '12345',
},
'https://primary.com/dashboard',
);
constrequestState=awaitauthenticateRequest(request,{
...mockOptions(),
domain: 'primary.com',
isSatellite: false,
signInUrl: 'https://primary.com/sign-in',
});
// Should not trigger the specific cross-origin sync handshake we're trying to prevent
expect(requestState.reason).not.toBe(AuthErrorReason.PrimaryDomainCrossOriginSync);
});
describe('Cross-origin sync',()=>{
test('does not trigger cross-origin handshake when referer is from expected accounts portal derived from frontend API',async()=>{
constrequest=mockRequestWithCookies(
{
referer: 'https://accounts.inspired.puma-74.lcl.dev/sign-in',
'sec-fetch-dest': 'document',
'sec-fetch-site': 'cross-site',
},
{
__session: mockJwt,
__client_uat: '12345',
},
'https://primary.com/dashboard',
);
constrequestState=awaitauthenticateRequest(request,{
...mockOptions(),
domain: 'primary.com',
isSatellite: false,
signInUrl: 'https://primary.com/sign-in',
});
// Should not trigger the specific cross-origin sync handshake we're trying to prevent
expect(requestState.reason).not.toBe(AuthErrorReason.PrimaryDomainCrossOriginSync);
});
test('triggers handshake when referer is unrelated accounts.* domain',async()=>{
constrequest=mockRequestWithCookies(
{
referer: 'https://accounts.attacker.com/signin',
'sec-fetch-dest': 'document',
'sec-fetch-site': 'cross-site',
},
{
__session: mockJwt,
__client_uat: '12345',
},
'https://primary.com/dashboard',
);
constrequestState=awaitauthenticateRequest(request,{
...mockOptions(),
publishableKey: PK_LIVE,
domain: 'primary.com',
isSatellite: false,
signInUrl: 'https://primary.com/sign-in',
});
expect(requestState).toMatchHandshake({
reason: AuthErrorReason.PrimaryDomainCrossOriginSync,
domain: 'primary.com',
signInUrl: 'https://primary.com/sign-in',
});
});
});
🤖 Prompt for AI Agents
In packages/backend/src/tokens/__tests__/request.test.ts around lines 1696 to
1719, add a negative test case that ensures an unrelated accounts.* origin still
triggers the cross-origin handshake: create a request similar to the existing
test but with referer 'https://accounts.attacker.com/sign-in' (keep
'sec-fetch-site': 'cross-site', cookies same, and origin
'https://primary.com/dashboard'), call authenticateRequest with the same
mockOptions (domain: 'primary.com', isSatellite: false, signInUrl:
'https://primary.com/sign-in'), and assert that requestState.reason ===
AuthErrorReason.PrimaryDomainCrossOriginSync to prevent over-broad whitelisting.


test('does not trigger handshake when referer is from FAPI domain (redirect-based auth)', async () => {
const request = mockRequestWithCookies(
{
referer: 'https://clerk.inspired.puma-74.lcl.dev/v1/client/sign_ins/12345/attempt_first_factor',
'sec-fetch-dest': 'document',
'sec-fetch-site': 'cross-site',
},
{
__session: mockJwt,
__client_uat: '12345',
},
'https://primary.com/dashboard',
);

const requestState = await authenticateRequest(request, {
...mockOptions(),
domain: 'primary.com',
isSatellite: false,
signInUrl: 'https://primary.com/sign-in',
});

// Should not trigger the specific cross-origin sync handshake we're trying to prevent
expect(requestState.reason).not.toBe(AuthErrorReason.PrimaryDomainCrossOriginSync);
});

test('does not trigger handshake when referer is from FAPI domain with https prefix', async () => {
const request = mockRequestWithCookies(
{
referer: 'https://clerk.inspired.puma-74.lcl.dev/sign-in',
'sec-fetch-dest': 'document',
'sec-fetch-site': 'cross-site',
},
{
__session: mockJwt,
__client_uat: '12345',
},
'https://primary.com/dashboard',
);

const requestState = await authenticateRequest(request, {
...mockOptions(),
domain: 'primary.com',
isSatellite: false,
signInUrl: 'https://primary.com/sign-in',
});

// Should not trigger the specific cross-origin sync handshake we're trying to prevent
expect(requestState.reason).not.toBe(AuthErrorReason.PrimaryDomainCrossOriginSync);
});

test('still triggers handshake for legitimate cross-origin requests from non-accounts domains', async () => {
const request = mockRequestWithCookies(
{
referer: 'https://satellite.com/sign-in',
'sec-fetch-dest': 'document',
'sec-fetch-site': 'cross-site',
},
{
__session: mockJwt,
__client_uat: '12345',
},
'https://primary.com/dashboard',
);

const requestState = await authenticateRequest(request, {
...mockOptions(),
publishableKey: PK_LIVE,
domain: 'primary.com',
isSatellite: false,
signInUrl: 'https://primary.com/sign-in',
});

expect(requestState).toMatchHandshake({
reason: AuthErrorReason.PrimaryDomainCrossOriginSync,
domain: 'primary.com',
signInUrl: 'https://primary.com/sign-in',
});
});

test('does not trigger handshake when referrer matches current origin despite sec-fetch-site cross-site (redirect chain)', async () => {
const request = mockRequestWithCookies(
{
host: 'primary.com',
referer: 'https://primary.com/some-page',
'sec-fetch-dest': 'document',
'sec-fetch-site': 'cross-site', // This can happen due to redirect chains through Clerk domains
},
{
__session: mockJwt,
__client_uat: '12345',
},
'https://primary.com/dashboard',
);

const requestState = await authenticateRequest(request, {
...mockOptions(),
publishableKey: PK_LIVE,
domain: 'primary.com',
isSatellite: false,
signInUrl: 'https://primary.com/sign-in',
});

// Should not trigger handshake because referrer origin matches current origin
expect(requestState).toBeSignedIn({
domain: 'primary.com',
isSatellite: false,
signInUrl: 'https://primary.com/sign-in',
});
});
});
});
56 changes: 52 additions & 4 deletions packages/backend/src/tokens/authenticateContext.ts
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
import { buildAccountsBaseUrl } from '@clerk/shared/buildAccountsBaseUrl';
import { isCurrentDevAccountPortalOrigin, isLegacyDevAccountPortalOrigin } from '@clerk/shared/url';
import type { Jwt } from '@clerk/types';

import { constants } from '../constants';
Expand DownExpand Up@@ -186,10 +188,6 @@ class AuthenticateContext implements AuthenticateContext {
}

try {
if (this.getHeader(constants.Headers.SecFetchSite) === 'cross-site') {
return true;
}

const referrerOrigin = new URL(this.referrer).origin;
return referrerOrigin !== this.clerkUrl.origin;
} catch {
Expand All@@ -198,6 +196,56 @@ class AuthenticateContext implements AuthenticateContext {
}
}

/**
* Determines if the referrer URL is from a Clerk domain (accounts portal or FAPI).
* This includes both development and production account portal domains, as well as FAPI domains
* used for redirect-based authentication flows.
*
* @returns {boolean} True if the referrer is from a Clerk accounts portal or FAPI domain, false otherwise
*/
public isKnownClerkReferrer(): boolean {
if (!this.referrer) {
return false;
}

try {
const referrerOrigin = new URL(this.referrer);
const referrerHost = referrerOrigin.hostname;

// Check if referrer is the FAPI domain itself (redirect-based auth flows)
if (this.frontendApi) {
const fapiHost = this.frontendApi.startsWith('http') ? new URL(this.frontendApi).hostname : this.frontendApi;
if (referrerHost === fapiHost) {
return true;
}
}

// Check for development account portal patterns
if (isLegacyDevAccountPortalOrigin(referrerHost) || isCurrentDevAccountPortalOrigin(referrerHost)) {
return true;
}

// Check for production account portal by comparing with expected accounts URL
const expectedAccountsUrl = buildAccountsBaseUrl(this.frontendApi);
if (expectedAccountsUrl) {
const expectedAccountsOrigin = new URL(expectedAccountsUrl).origin;
if (referrerOrigin.origin === expectedAccountsOrigin) {
return true;
}
}

// Check for generic production accounts patterns (accounts.*)
if (referrerHost.startsWith('accounts.')) {
return true;
}

return false;
} catch {
// Invalid URL format
return false;
}
}

private initPublishableKeyValues(options: AuthenticateRequestOptions) {
assertValidPublishableKey(options.publishableKey);
this.publishableKey = options.publishableKey;
Expand Down
3 changes: 2 additions & 1 deletion packages/backend/src/tokens/request.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -576,7 +576,8 @@ export const authenticateRequest: AuthenticateRequest = (async (
const shouldForceHandshakeForCrossDomain =
!authenticateContext.isSatellite && // We're on primary
authenticateContext.secFetchDest === 'document' && // Document navigation
authenticateContext.isCrossOriginReferrer(); // Came from different domain
authenticateContext.isCrossOriginReferrer() && // Came from different domain
!authenticateContext.isKnownClerkReferrer(); // Not from Clerk accounts portal or FAPI

if (shouldForceHandshakeForCrossDomain) {
return handleMaybeHandshakeStatus(
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
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
5 changes: 5 additions & 0 deletions .changeset/yellow-vans-walk.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
---
'@clerk/backend': patch
---

Fix logic for forcing a session sync on cross origin requests.
247 changes: 247 additions & 0 deletions packages/backend/src/tokens/__tests__/request.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -1520,6 +1520,31 @@ describe('tokens.authenticateRequest(options)', () => {
});
});

test('does not trigger handshake when referer is same origin', async () => {
const request = mockRequestWithCookies(
{
host: 'localhost:3000',
referer: 'http://localhost:3000',
'sec-fetch-dest': 'document',
},
{
__clerk_db_jwt: mockJwt,
__session: mockJwt,
__client_uat: '12345',
},
'http://localhost:3000',
);

const requestState = await authenticateRequest(request, {
...mockOptions(),
signInUrl: 'http://localhost:3000/sign-in',
});

expect(requestState).toBeSignedIn({
signInUrl: 'http://localhost:3000/sign-in',
});
});

test('does not trigger handshake when no referer header', async () => {
const request = mockRequestWithCookies(
{
Expand DownExpand Up@@ -1605,5 +1630,227 @@ describe('tokens.authenticateRequest(options)', () => {
signInUrl: 'https://primary.com/sign-in',
});
});

test('does not trigger handshake when referer is from production accounts portal', async () => {
const request = mockRequestWithCookies(
{
referer: 'https://accounts.example.com/sign-in',
'sec-fetch-dest': 'document',
'sec-fetch-site': 'cross-site',
},
{
__session: mockJwt,
__client_uat: '12345',
},
'https://primary.com/dashboard',
);

const requestState = await authenticateRequest(request, {
...mockOptions(),
publishableKey: PK_LIVE,
domain: 'primary.com',
isSatellite: false,
signInUrl: 'https://primary.com/sign-in',
});

expect(requestState).toBeSignedIn({
domain: 'primary.com',
isSatellite: false,
signInUrl: 'https://primary.com/sign-in',
});
});

Comment on lines +1633 to +1662

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Test currently passes due to permissive “accounts.*” fallback; align with real Clerk host.

This case uses https://accounts.example.com/..., which is not derivable from the publishable key in mockOptions(). It only passes because production code whitelists any accounts.* host. If we tighten matching (recommended), this test will fail.

Change the referer to the expected accounts origin derived from the PK used in tests (e.g., accounts.inspired.puma-74.lcl.dev) or compute it from the PK to avoid coupling to the permissive fallback.

- referer: 'https://accounts.example.com/sign-in',+ referer: 'https://accounts.inspired.puma-74.lcl.dev/sign-in',

Alternatively, if you want to exercise a truly “prod-style” hostname, derive it from buildAccountsBaseUrl(frontendApi) seeded by the parsed PK rather than hardcoding example.com.

📝 Committable suggestion

‼️IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
test('does not trigger handshake when referer is from production accounts portal',async()=>{
constrequest=mockRequestWithCookies(
{
referer: 'https://accounts.example.com/sign-in',
'sec-fetch-dest': 'document',
'sec-fetch-site': 'cross-site',
},
{
__session: mockJwt,
__client_uat: '12345',
},
'https://primary.com/dashboard',
);
constrequestState=awaitauthenticateRequest(request,{
...mockOptions(),
publishableKey: PK_LIVE,
domain: 'primary.com',
isSatellite: false,
signInUrl: 'https://primary.com/sign-in',
});
expect(requestState).toBeSignedIn({
domain: 'primary.com',
isSatellite: false,
signInUrl: 'https://primary.com/sign-in',
});
});
test('does not trigger handshake when referer is from production accounts portal',async()=>{
constrequest=mockRequestWithCookies(
{
referer: 'https://accounts.inspired.puma-74.lcl.dev/sign-in',
'sec-fetch-dest': 'document',
'sec-fetch-site': 'cross-site',
},
{
__session: mockJwt,
__client_uat: '12345',
},
'https://primary.com/dashboard',
);
constrequestState=awaitauthenticateRequest(request,{
...mockOptions(),
publishableKey: PK_LIVE,
domain: 'primary.com',
isSatellite: false,
signInUrl: 'https://primary.com/sign-in',
});
expect(requestState).toBeSignedIn({
domain: 'primary.com',
isSatellite: false,
signInUrl: 'https://primary.com/sign-in',
});
});
🤖 Prompt for AI Agents
In packages/backend/src/tokens/__tests__/request.test.ts around lines 1608 to
1637, the test hardcodes referer "https://accounts.example.com/..." which only
passes because production code currently allows any accounts.* host; update the
test to use the actual accounts origin derived from the publishable key in
mockOptions() (e.g., call the same helper used in production like
buildAccountsBaseUrl(frontendApi) or construct
"https://accounts.<frontendApi-derived-host>") so the referer matches the PK
used in the test instead of relying on the permissive accounts.* fallback.

test('does not trigger handshake when referer is from dev accounts portal (current format)', async () => {
const request = mockRequestWithCookies(
{
referer: 'https://foo-bar-13.accounts.dev/sign-in',
'sec-fetch-dest': 'document',
'sec-fetch-site': 'cross-site',
},
{
__session: mockJwt,
__client_uat: '12345',
},
'https://primary.com/dashboard',
);

const requestState = await authenticateRequest(request, {
...mockOptions(),
publishableKey: PK_LIVE,
domain: 'primary.com',
isSatellite: false,
signInUrl: 'https://primary.com/sign-in',
});

expect(requestState).toBeSignedIn({
domain: 'primary.com',
isSatellite: false,
signInUrl: 'https://primary.com/sign-in',
});
});

test('does not trigger handshake when referer is from dev accounts portal (legacy format)', async () => {
const request = mockRequestWithCookies(
{
referer: 'https://accounts.foo-bar-13.lcl.dev/sign-in',
'sec-fetch-dest': 'document',
'sec-fetch-site': 'cross-site',
},
{
__session: mockJwt,
__client_uat: '12345',
},
'https://primary.com/dashboard',
);

const requestState = await authenticateRequest(request, {
...mockOptions(),
publishableKey: PK_LIVE,
domain: 'primary.com',
isSatellite: false,
signInUrl: 'https://primary.com/sign-in',
});

expect(requestState).toBeSignedIn({
domain: 'primary.com',
isSatellite: false,
signInUrl: 'https://primary.com/sign-in',
});
});

test('does not trigger cross-origin handshake when referer is from expected accounts portal derived from frontend API', async () => {
const request = mockRequestWithCookies(
{
referer: 'https://accounts.inspired.puma-74.lcl.dev/sign-in',
'sec-fetch-dest': 'document',
'sec-fetch-site': 'cross-site',
},
{
__session: mockJwt,
__client_uat: '12345',
},
'https://primary.com/dashboard',
);

const requestState = await authenticateRequest(request, {
...mockOptions(),
domain: 'primary.com',
isSatellite: false,
signInUrl: 'https://primary.com/sign-in',
});

// Should not trigger the specific cross-origin sync handshake we're trying to prevent
expect(requestState.reason).not.toBe(AuthErrorReason.PrimaryDomainCrossOriginSync);
});
Comment on lines +1721 to +1744

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Add a negative test to prevent regressions: “accounts.attacker.com” must still trigger cross-origin handshake.

To guard against over-broad whitelisting, add a case where the referer is an unrelated accounts.* domain and assert that PrimaryDomainCrossOriginSync is triggered.

@@
describe('Cross-origin sync', () => {
@@
+ test('triggers handshake when referer is unrelated accounts.* domain', async () => {+ const request = mockRequestWithCookies(+ {+ referer: 'https://accounts.attacker.com/signin',+ 'sec-fetch-dest': 'document',+ 'sec-fetch-site': 'cross-site',+ },+ {+ __session: mockJwt,+ __client_uat: '12345',+ },+ 'https://primary.com/dashboard',+ );++ const requestState = await authenticateRequest(request, {+ ...mockOptions(),+ publishableKey: PK_LIVE,+ domain: 'primary.com',+ isSatellite: false,+ signInUrl: 'https://primary.com/sign-in',+ });++ expect(requestState).toMatchHandshake({+ reason: AuthErrorReason.PrimaryDomainCrossOriginSync,+ domain: 'primary.com',+ signInUrl: 'https://primary.com/sign-in',+ });+ });
📝 Committable suggestion

‼️IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
test('does not trigger cross-origin handshake when referer is from expected accounts portal derived from frontend API',async()=>{
constrequest=mockRequestWithCookies(
{
referer: 'https://accounts.inspired.puma-74.lcl.dev/sign-in',
'sec-fetch-dest': 'document',
'sec-fetch-site': 'cross-site',
},
{
__session: mockJwt,
__client_uat: '12345',
},
'https://primary.com/dashboard',
);
constrequestState=awaitauthenticateRequest(request,{
...mockOptions(),
domain: 'primary.com',
isSatellite: false,
signInUrl: 'https://primary.com/sign-in',
});
// Should not trigger the specific cross-origin sync handshake we're trying to prevent
expect(requestState.reason).not.toBe(AuthErrorReason.PrimaryDomainCrossOriginSync);
});
describe('Cross-origin sync',()=>{
test('does not trigger cross-origin handshake when referer is from expected accounts portal derived from frontend API',async()=>{
constrequest=mockRequestWithCookies(
{
referer: 'https://accounts.inspired.puma-74.lcl.dev/sign-in',
'sec-fetch-dest': 'document',
'sec-fetch-site': 'cross-site',
},
{
__session: mockJwt,
__client_uat: '12345',
},
'https://primary.com/dashboard',
);
constrequestState=awaitauthenticateRequest(request,{
...mockOptions(),
domain: 'primary.com',
isSatellite: false,
signInUrl: 'https://primary.com/sign-in',
});
// Should not trigger the specific cross-origin sync handshake we're trying to prevent
expect(requestState.reason).not.toBe(AuthErrorReason.PrimaryDomainCrossOriginSync);
});
test('triggers handshake when referer is unrelated accounts.* domain',async()=>{
constrequest=mockRequestWithCookies(
{
referer: 'https://accounts.attacker.com/signin',
'sec-fetch-dest': 'document',
'sec-fetch-site': 'cross-site',
},
{
__session: mockJwt,
__client_uat: '12345',
},
'https://primary.com/dashboard',
);
constrequestState=awaitauthenticateRequest(request,{
...mockOptions(),
publishableKey: PK_LIVE,
domain: 'primary.com',
isSatellite: false,
signInUrl: 'https://primary.com/sign-in',
});
expect(requestState).toMatchHandshake({
reason: AuthErrorReason.PrimaryDomainCrossOriginSync,
domain: 'primary.com',
signInUrl: 'https://primary.com/sign-in',
});
});
});
🤖 Prompt for AI Agents
In packages/backend/src/tokens/__tests__/request.test.ts around lines 1696 to
1719, add a negative test case that ensures an unrelated accounts.* origin still
triggers the cross-origin handshake: create a request similar to the existing
test but with referer 'https://accounts.attacker.com/sign-in' (keep
'sec-fetch-site': 'cross-site', cookies same, and origin
'https://primary.com/dashboard'), call authenticateRequest with the same
mockOptions (domain: 'primary.com', isSatellite: false, signInUrl:
'https://primary.com/sign-in'), and assert that requestState.reason ===
AuthErrorReason.PrimaryDomainCrossOriginSync to prevent over-broad whitelisting.


test('does not trigger handshake when referer is from FAPI domain (redirect-based auth)', async () => {
const request = mockRequestWithCookies(
{
referer: 'https://clerk.inspired.puma-74.lcl.dev/v1/client/sign_ins/12345/attempt_first_factor',
'sec-fetch-dest': 'document',
'sec-fetch-site': 'cross-site',
},
{
__session: mockJwt,
__client_uat: '12345',
},
'https://primary.com/dashboard',
);

const requestState = await authenticateRequest(request, {
...mockOptions(),
domain: 'primary.com',
isSatellite: false,
signInUrl: 'https://primary.com/sign-in',
});

// Should not trigger the specific cross-origin sync handshake we're trying to prevent
expect(requestState.reason).not.toBe(AuthErrorReason.PrimaryDomainCrossOriginSync);
});

test('does not trigger handshake when referer is from FAPI domain with https prefix', async () => {
const request = mockRequestWithCookies(
{
referer: 'https://clerk.inspired.puma-74.lcl.dev/sign-in',
'sec-fetch-dest': 'document',
'sec-fetch-site': 'cross-site',
},
{
__session: mockJwt,
__client_uat: '12345',
},
'https://primary.com/dashboard',
);

const requestState = await authenticateRequest(request, {
...mockOptions(),
domain: 'primary.com',
isSatellite: false,
signInUrl: 'https://primary.com/sign-in',
});

// Should not trigger the specific cross-origin sync handshake we're trying to prevent
expect(requestState.reason).not.toBe(AuthErrorReason.PrimaryDomainCrossOriginSync);
});

test('still triggers handshake for legitimate cross-origin requests from non-accounts domains', async () => {
const request = mockRequestWithCookies(
{
referer: 'https://satellite.com/sign-in',
'sec-fetch-dest': 'document',
'sec-fetch-site': 'cross-site',
},
{
__session: mockJwt,
__client_uat: '12345',
},
'https://primary.com/dashboard',
);

const requestState = await authenticateRequest(request, {
...mockOptions(),
publishableKey: PK_LIVE,
domain: 'primary.com',
isSatellite: false,
signInUrl: 'https://primary.com/sign-in',
});

expect(requestState).toMatchHandshake({
reason: AuthErrorReason.PrimaryDomainCrossOriginSync,
domain: 'primary.com',
signInUrl: 'https://primary.com/sign-in',
});
});

test('does not trigger handshake when referrer matches current origin despite sec-fetch-site cross-site (redirect chain)', async () => {
const request = mockRequestWithCookies(
{
host: 'primary.com',
referer: 'https://primary.com/some-page',
'sec-fetch-dest': 'document',
'sec-fetch-site': 'cross-site', // This can happen due to redirect chains through Clerk domains
},
{
__session: mockJwt,
__client_uat: '12345',
},
'https://primary.com/dashboard',
);

const requestState = await authenticateRequest(request, {
...mockOptions(),
publishableKey: PK_LIVE,
domain: 'primary.com',
isSatellite: false,
signInUrl: 'https://primary.com/sign-in',
});

// Should not trigger handshake because referrer origin matches current origin
expect(requestState).toBeSignedIn({
domain: 'primary.com',
isSatellite: false,
signInUrl: 'https://primary.com/sign-in',
});
});
});
});
56 changes: 52 additions & 4 deletions packages/backend/src/tokens/authenticateContext.ts
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
import { buildAccountsBaseUrl } from '@clerk/shared/buildAccountsBaseUrl';
import { isCurrentDevAccountPortalOrigin, isLegacyDevAccountPortalOrigin } from '@clerk/shared/url';
import type { Jwt } from '@clerk/types';

import { constants } from '../constants';
Expand DownExpand Up@@ -186,10 +188,6 @@ class AuthenticateContext implements AuthenticateContext {
}

try {
if (this.getHeader(constants.Headers.SecFetchSite) === 'cross-site') {
return true;
}

const referrerOrigin = new URL(this.referrer).origin;
return referrerOrigin !== this.clerkUrl.origin;
} catch {
Expand All@@ -198,6 +196,56 @@ class AuthenticateContext implements AuthenticateContext {
}
}

/**
* Determines if the referrer URL is from a Clerk domain (accounts portal or FAPI).
* This includes both development and production account portal domains, as well as FAPI domains
* used for redirect-based authentication flows.
*
* @returns {boolean} True if the referrer is from a Clerk accounts portal or FAPI domain, false otherwise
*/
public isKnownClerkReferrer(): boolean {
if (!this.referrer) {
return false;
}

try {
const referrerOrigin = new URL(this.referrer);
const referrerHost = referrerOrigin.hostname;

// Check if referrer is the FAPI domain itself (redirect-based auth flows)
if (this.frontendApi) {
const fapiHost = this.frontendApi.startsWith('http') ? new URL(this.frontendApi).hostname : this.frontendApi;
if (referrerHost === fapiHost) {
return true;
}
}

// Check for development account portal patterns
if (isLegacyDevAccountPortalOrigin(referrerHost) || isCurrentDevAccountPortalOrigin(referrerHost)) {
return true;
}

// Check for production account portal by comparing with expected accounts URL
const expectedAccountsUrl = buildAccountsBaseUrl(this.frontendApi);
if (expectedAccountsUrl) {
const expectedAccountsOrigin = new URL(expectedAccountsUrl).origin;
if (referrerOrigin.origin === expectedAccountsOrigin) {
return true;
}
}

// Check for generic production accounts patterns (accounts.*)
if (referrerHost.startsWith('accounts.')) {
return true;
}

return false;
} catch {
// Invalid URL format
return false;
}
}

private initPublishableKeyValues(options: AuthenticateRequestOptions) {
assertValidPublishableKey(options.publishableKey);
this.publishableKey = options.publishableKey;
Expand Down
3 changes: 2 additions & 1 deletion packages/backend/src/tokens/request.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -576,7 +576,8 @@ export const authenticateRequest: AuthenticateRequest = (async (
const shouldForceHandshakeForCrossDomain =
!authenticateContext.isSatellite && // We're on primary
authenticateContext.secFetchDest === 'document' && // Document navigation
authenticateContext.isCrossOriginReferrer(); // Came from different domain
authenticateContext.isCrossOriginReferrer() && // Came from different domain
!authenticateContext.isKnownClerkReferrer(); // Not from Clerk accounts portal or FAPI

if (shouldForceHandshakeForCrossDomain) {
return handleMaybeHandshakeStatus(
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content
Merged
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
5 changes: 5 additions & 0 deletions .changeset/yellow-vans-walk.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
---
'@clerk/backend': patch
---

Fix logic for forcing a session sync on cross origin requests.
247 changes: 247 additions & 0 deletions packages/backend/src/tokens/__tests__/request.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -1520,6 +1520,31 @@ describe('tokens.authenticateRequest(options)', () => {
});
});

test('does not trigger handshake when referer is same origin', async () => {
const request = mockRequestWithCookies(
{
host: 'localhost:3000',
referer: 'http://localhost:3000',
'sec-fetch-dest': 'document',
},
{
__clerk_db_jwt: mockJwt,
__session: mockJwt,
__client_uat: '12345',
},
'http://localhost:3000',
);

const requestState = await authenticateRequest(request, {
...mockOptions(),
signInUrl: 'http://localhost:3000/sign-in',
});

expect(requestState).toBeSignedIn({
signInUrl: 'http://localhost:3000/sign-in',
});
});

test('does not trigger handshake when no referer header', async () => {
const request = mockRequestWithCookies(
{
Expand DownExpand Up@@ -1605,5 +1630,227 @@ describe('tokens.authenticateRequest(options)', () => {
signInUrl: 'https://primary.com/sign-in',
});
});

test('does not trigger handshake when referer is from production accounts portal', async () => {
const request = mockRequestWithCookies(
{
referer: 'https://accounts.example.com/sign-in',
'sec-fetch-dest': 'document',
'sec-fetch-site': 'cross-site',
},
{
__session: mockJwt,
__client_uat: '12345',
},
'https://primary.com/dashboard',
);

const requestState = await authenticateRequest(request, {
...mockOptions(),
publishableKey: PK_LIVE,
domain: 'primary.com',
isSatellite: false,
signInUrl: 'https://primary.com/sign-in',
});

expect(requestState).toBeSignedIn({
domain: 'primary.com',
isSatellite: false,
signInUrl: 'https://primary.com/sign-in',
});
});

Comment on lines +1633 to +1662

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Test currently passes due to permissive “accounts.*” fallback; align with real Clerk host.

This case uses https://accounts.example.com/..., which is not derivable from the publishable key in mockOptions(). It only passes because production code whitelists any accounts.* host. If we tighten matching (recommended), this test will fail.

Change the referer to the expected accounts origin derived from the PK used in tests (e.g., accounts.inspired.puma-74.lcl.dev) or compute it from the PK to avoid coupling to the permissive fallback.

- referer: 'https://accounts.example.com/sign-in',+ referer: 'https://accounts.inspired.puma-74.lcl.dev/sign-in',

Alternatively, if you want to exercise a truly “prod-style” hostname, derive it from buildAccountsBaseUrl(frontendApi) seeded by the parsed PK rather than hardcoding example.com.

📝 Committable suggestion

‼️IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
test('does not trigger handshake when referer is from production accounts portal',async()=>{
constrequest=mockRequestWithCookies(
{
referer: 'https://accounts.example.com/sign-in',
'sec-fetch-dest': 'document',
'sec-fetch-site': 'cross-site',
},
{
__session: mockJwt,
__client_uat: '12345',
},
'https://primary.com/dashboard',
);
constrequestState=awaitauthenticateRequest(request,{
...mockOptions(),
publishableKey: PK_LIVE,
domain: 'primary.com',
isSatellite: false,
signInUrl: 'https://primary.com/sign-in',
});
expect(requestState).toBeSignedIn({
domain: 'primary.com',
isSatellite: false,
signInUrl: 'https://primary.com/sign-in',
});
});
test('does not trigger handshake when referer is from production accounts portal',async()=>{
constrequest=mockRequestWithCookies(
{
referer: 'https://accounts.inspired.puma-74.lcl.dev/sign-in',
'sec-fetch-dest': 'document',
'sec-fetch-site': 'cross-site',
},
{
__session: mockJwt,
__client_uat: '12345',
},
'https://primary.com/dashboard',
);
constrequestState=awaitauthenticateRequest(request,{
...mockOptions(),
publishableKey: PK_LIVE,
domain: 'primary.com',
isSatellite: false,
signInUrl: 'https://primary.com/sign-in',
});
expect(requestState).toBeSignedIn({
domain: 'primary.com',
isSatellite: false,
signInUrl: 'https://primary.com/sign-in',
});
});
🤖 Prompt for AI Agents
In packages/backend/src/tokens/__tests__/request.test.ts around lines 1608 to
1637, the test hardcodes referer "https://accounts.example.com/..." which only
passes because production code currently allows any accounts.* host; update the
test to use the actual accounts origin derived from the publishable key in
mockOptions() (e.g., call the same helper used in production like
buildAccountsBaseUrl(frontendApi) or construct
"https://accounts.<frontendApi-derived-host>") so the referer matches the PK
used in the test instead of relying on the permissive accounts.* fallback.

test('does not trigger handshake when referer is from dev accounts portal (current format)', async () => {
const request = mockRequestWithCookies(
{
referer: 'https://foo-bar-13.accounts.dev/sign-in',
'sec-fetch-dest': 'document',
'sec-fetch-site': 'cross-site',
},
{
__session: mockJwt,
__client_uat: '12345',
},
'https://primary.com/dashboard',
);

const requestState = await authenticateRequest(request, {
...mockOptions(),
publishableKey: PK_LIVE,
domain: 'primary.com',
isSatellite: false,
signInUrl: 'https://primary.com/sign-in',
});

expect(requestState).toBeSignedIn({
domain: 'primary.com',
isSatellite: false,
signInUrl: 'https://primary.com/sign-in',
});
});

test('does not trigger handshake when referer is from dev accounts portal (legacy format)', async () => {
const request = mockRequestWithCookies(
{
referer: 'https://accounts.foo-bar-13.lcl.dev/sign-in',
'sec-fetch-dest': 'document',
'sec-fetch-site': 'cross-site',
},
{
__session: mockJwt,
__client_uat: '12345',
},
'https://primary.com/dashboard',
);

const requestState = await authenticateRequest(request, {
...mockOptions(),
publishableKey: PK_LIVE,
domain: 'primary.com',
isSatellite: false,
signInUrl: 'https://primary.com/sign-in',
});

expect(requestState).toBeSignedIn({
domain: 'primary.com',
isSatellite: false,
signInUrl: 'https://primary.com/sign-in',
});
});

test('does not trigger cross-origin handshake when referer is from expected accounts portal derived from frontend API', async () => {
const request = mockRequestWithCookies(
{
referer: 'https://accounts.inspired.puma-74.lcl.dev/sign-in',
'sec-fetch-dest': 'document',
'sec-fetch-site': 'cross-site',
},
{
__session: mockJwt,
__client_uat: '12345',
},
'https://primary.com/dashboard',
);

const requestState = await authenticateRequest(request, {
...mockOptions(),
domain: 'primary.com',
isSatellite: false,
signInUrl: 'https://primary.com/sign-in',
});

// Should not trigger the specific cross-origin sync handshake we're trying to prevent
expect(requestState.reason).not.toBe(AuthErrorReason.PrimaryDomainCrossOriginSync);
});
Comment on lines +1721 to +1744

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Add a negative test to prevent regressions: “accounts.attacker.com” must still trigger cross-origin handshake.

To guard against over-broad whitelisting, add a case where the referer is an unrelated accounts.* domain and assert that PrimaryDomainCrossOriginSync is triggered.

@@
describe('Cross-origin sync', () => {
@@
+ test('triggers handshake when referer is unrelated accounts.* domain', async () => {+ const request = mockRequestWithCookies(+ {+ referer: 'https://accounts.attacker.com/signin',+ 'sec-fetch-dest': 'document',+ 'sec-fetch-site': 'cross-site',+ },+ {+ __session: mockJwt,+ __client_uat: '12345',+ },+ 'https://primary.com/dashboard',+ );++ const requestState = await authenticateRequest(request, {+ ...mockOptions(),+ publishableKey: PK_LIVE,+ domain: 'primary.com',+ isSatellite: false,+ signInUrl: 'https://primary.com/sign-in',+ });++ expect(requestState).toMatchHandshake({+ reason: AuthErrorReason.PrimaryDomainCrossOriginSync,+ domain: 'primary.com',+ signInUrl: 'https://primary.com/sign-in',+ });+ });
📝 Committable suggestion

‼️IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
test('does not trigger cross-origin handshake when referer is from expected accounts portal derived from frontend API',async()=>{
constrequest=mockRequestWithCookies(
{
referer: 'https://accounts.inspired.puma-74.lcl.dev/sign-in',
'sec-fetch-dest': 'document',
'sec-fetch-site': 'cross-site',
},
{
__session: mockJwt,
__client_uat: '12345',
},
'https://primary.com/dashboard',
);
constrequestState=awaitauthenticateRequest(request,{
...mockOptions(),
domain: 'primary.com',
isSatellite: false,
signInUrl: 'https://primary.com/sign-in',
});
// Should not trigger the specific cross-origin sync handshake we're trying to prevent
expect(requestState.reason).not.toBe(AuthErrorReason.PrimaryDomainCrossOriginSync);
});
describe('Cross-origin sync',()=>{
test('does not trigger cross-origin handshake when referer is from expected accounts portal derived from frontend API',async()=>{
constrequest=mockRequestWithCookies(
{
referer: 'https://accounts.inspired.puma-74.lcl.dev/sign-in',
'sec-fetch-dest': 'document',
'sec-fetch-site': 'cross-site',
},
{
__session: mockJwt,
__client_uat: '12345',
},
'https://primary.com/dashboard',
);
constrequestState=awaitauthenticateRequest(request,{
...mockOptions(),
domain: 'primary.com',
isSatellite: false,
signInUrl: 'https://primary.com/sign-in',
});
// Should not trigger the specific cross-origin sync handshake we're trying to prevent
expect(requestState.reason).not.toBe(AuthErrorReason.PrimaryDomainCrossOriginSync);
});
test('triggers handshake when referer is unrelated accounts.* domain',async()=>{
constrequest=mockRequestWithCookies(
{
referer: 'https://accounts.attacker.com/signin',
'sec-fetch-dest': 'document',
'sec-fetch-site': 'cross-site',
},
{
__session: mockJwt,
__client_uat: '12345',
},
'https://primary.com/dashboard',
);
constrequestState=awaitauthenticateRequest(request,{
...mockOptions(),
publishableKey: PK_LIVE,
domain: 'primary.com',
isSatellite: false,
signInUrl: 'https://primary.com/sign-in',
});
expect(requestState).toMatchHandshake({
reason: AuthErrorReason.PrimaryDomainCrossOriginSync,
domain: 'primary.com',
signInUrl: 'https://primary.com/sign-in',
});
});
});
🤖 Prompt for AI Agents
In packages/backend/src/tokens/__tests__/request.test.ts around lines 1696 to
1719, add a negative test case that ensures an unrelated accounts.* origin still
triggers the cross-origin handshake: create a request similar to the existing
test but with referer 'https://accounts.attacker.com/sign-in' (keep
'sec-fetch-site': 'cross-site', cookies same, and origin
'https://primary.com/dashboard'), call authenticateRequest with the same
mockOptions (domain: 'primary.com', isSatellite: false, signInUrl:
'https://primary.com/sign-in'), and assert that requestState.reason ===
AuthErrorReason.PrimaryDomainCrossOriginSync to prevent over-broad whitelisting.


test('does not trigger handshake when referer is from FAPI domain (redirect-based auth)', async () => {
const request = mockRequestWithCookies(
{
referer: 'https://clerk.inspired.puma-74.lcl.dev/v1/client/sign_ins/12345/attempt_first_factor',
'sec-fetch-dest': 'document',
'sec-fetch-site': 'cross-site',
},
{
__session: mockJwt,
__client_uat: '12345',
},
'https://primary.com/dashboard',
);

const requestState = await authenticateRequest(request, {
...mockOptions(),
domain: 'primary.com',
isSatellite: false,
signInUrl: 'https://primary.com/sign-in',
});

// Should not trigger the specific cross-origin sync handshake we're trying to prevent
expect(requestState.reason).not.toBe(AuthErrorReason.PrimaryDomainCrossOriginSync);
});

test('does not trigger handshake when referer is from FAPI domain with https prefix', async () => {
const request = mockRequestWithCookies(
{
referer: 'https://clerk.inspired.puma-74.lcl.dev/sign-in',
'sec-fetch-dest': 'document',
'sec-fetch-site': 'cross-site',
},
{
__session: mockJwt,
__client_uat: '12345',
},
'https://primary.com/dashboard',
);

const requestState = await authenticateRequest(request, {
...mockOptions(),
domain: 'primary.com',
isSatellite: false,
signInUrl: 'https://primary.com/sign-in',
});

// Should not trigger the specific cross-origin sync handshake we're trying to prevent
expect(requestState.reason).not.toBe(AuthErrorReason.PrimaryDomainCrossOriginSync);
});

test('still triggers handshake for legitimate cross-origin requests from non-accounts domains', async () => {
const request = mockRequestWithCookies(
{
referer: 'https://satellite.com/sign-in',
'sec-fetch-dest': 'document',
'sec-fetch-site': 'cross-site',
},
{
__session: mockJwt,
__client_uat: '12345',
},
'https://primary.com/dashboard',
);

const requestState = await authenticateRequest(request, {
...mockOptions(),
publishableKey: PK_LIVE,
domain: 'primary.com',
isSatellite: false,
signInUrl: 'https://primary.com/sign-in',
});

expect(requestState).toMatchHandshake({
reason: AuthErrorReason.PrimaryDomainCrossOriginSync,
domain: 'primary.com',
signInUrl: 'https://primary.com/sign-in',
});
});

test('does not trigger handshake when referrer matches current origin despite sec-fetch-site cross-site (redirect chain)', async () => {
const request = mockRequestWithCookies(
{
host: 'primary.com',
referer: 'https://primary.com/some-page',
'sec-fetch-dest': 'document',
'sec-fetch-site': 'cross-site', // This can happen due to redirect chains through Clerk domains
},
{
__session: mockJwt,
__client_uat: '12345',
},
'https://primary.com/dashboard',
);

const requestState = await authenticateRequest(request, {
...mockOptions(),
publishableKey: PK_LIVE,
domain: 'primary.com',
isSatellite: false,
signInUrl: 'https://primary.com/sign-in',
});

// Should not trigger handshake because referrer origin matches current origin
expect(requestState).toBeSignedIn({
domain: 'primary.com',
isSatellite: false,
signInUrl: 'https://primary.com/sign-in',
});
});
});
});
56 changes: 52 additions & 4 deletions packages/backend/src/tokens/authenticateContext.ts
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
import { buildAccountsBaseUrl } from '@clerk/shared/buildAccountsBaseUrl';
import { isCurrentDevAccountPortalOrigin, isLegacyDevAccountPortalOrigin } from '@clerk/shared/url';
import type { Jwt } from '@clerk/types';

import { constants } from '../constants';
Expand DownExpand Up@@ -186,10 +188,6 @@ class AuthenticateContext implements AuthenticateContext {
}

try {
if (this.getHeader(constants.Headers.SecFetchSite) === 'cross-site') {
return true;
}

const referrerOrigin = new URL(this.referrer).origin;
return referrerOrigin !== this.clerkUrl.origin;
} catch {
Expand All@@ -198,6 +196,56 @@ class AuthenticateContext implements AuthenticateContext {
}
}

/**
* Determines if the referrer URL is from a Clerk domain (accounts portal or FAPI).
* This includes both development and production account portal domains, as well as FAPI domains
* used for redirect-based authentication flows.
*
* @returns {boolean} True if the referrer is from a Clerk accounts portal or FAPI domain, false otherwise
*/
public isKnownClerkReferrer(): boolean {
if (!this.referrer) {
return false;
}

try {
const referrerOrigin = new URL(this.referrer);
const referrerHost = referrerOrigin.hostname;

// Check if referrer is the FAPI domain itself (redirect-based auth flows)
if (this.frontendApi) {
const fapiHost = this.frontendApi.startsWith('http') ? new URL(this.frontendApi).hostname : this.frontendApi;
if (referrerHost === fapiHost) {
return true;
}
}

// Check for development account portal patterns
if (isLegacyDevAccountPortalOrigin(referrerHost) || isCurrentDevAccountPortalOrigin(referrerHost)) {
return true;
}

// Check for production account portal by comparing with expected accounts URL
const expectedAccountsUrl = buildAccountsBaseUrl(this.frontendApi);
if (expectedAccountsUrl) {
const expectedAccountsOrigin = new URL(expectedAccountsUrl).origin;
if (referrerOrigin.origin === expectedAccountsOrigin) {
return true;
}
}

// Check for generic production accounts patterns (accounts.*)
if (referrerHost.startsWith('accounts.')) {
return true;
}

return false;
} catch {
// Invalid URL format
return false;
}
}

private initPublishableKeyValues(options: AuthenticateRequestOptions) {
assertValidPublishableKey(options.publishableKey);
this.publishableKey = options.publishableKey;
Expand Down
3 changes: 2 additions & 1 deletion packages/backend/src/tokens/request.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -576,7 +576,8 @@ export const authenticateRequest: AuthenticateRequest = (async (
const shouldForceHandshakeForCrossDomain =
!authenticateContext.isSatellite && // We're on primary
authenticateContext.secFetchDest === 'document' && // Document navigation
authenticateContext.isCrossOriginReferrer(); // Came from different domain
authenticateContext.isCrossOriginReferrer() && // Came from different domain
!authenticateContext.isKnownClerkReferrer(); // Not from Clerk accounts portal or FAPI

if (shouldForceHandshakeForCrossDomain) {
return handleMaybeHandshakeStatus(
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
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
5 changes: 5 additions & 0 deletions .changeset/yellow-vans-walk.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
---
'@clerk/backend': patch
---

Fix logic for forcing a session sync on cross origin requests.
247 changes: 247 additions & 0 deletions packages/backend/src/tokens/__tests__/request.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -1520,6 +1520,31 @@ describe('tokens.authenticateRequest(options)', () => {
});
});

test('does not trigger handshake when referer is same origin', async () => {
const request = mockRequestWithCookies(
{
host: 'localhost:3000',
referer: 'http://localhost:3000',
'sec-fetch-dest': 'document',
},
{
__clerk_db_jwt: mockJwt,
__session: mockJwt,
__client_uat: '12345',
},
'http://localhost:3000',
);

const requestState = await authenticateRequest(request, {
...mockOptions(),
signInUrl: 'http://localhost:3000/sign-in',
});

expect(requestState).toBeSignedIn({
signInUrl: 'http://localhost:3000/sign-in',
});
});

test('does not trigger handshake when no referer header', async () => {
const request = mockRequestWithCookies(
{
Expand DownExpand Up@@ -1605,5 +1630,227 @@ describe('tokens.authenticateRequest(options)', () => {
signInUrl: 'https://primary.com/sign-in',
});
});

test('does not trigger handshake when referer is from production accounts portal', async () => {
const request = mockRequestWithCookies(
{
referer: 'https://accounts.example.com/sign-in',
'sec-fetch-dest': 'document',
'sec-fetch-site': 'cross-site',
},
{
__session: mockJwt,
__client_uat: '12345',
},
'https://primary.com/dashboard',
);

const requestState = await authenticateRequest(request, {
...mockOptions(),
publishableKey: PK_LIVE,
domain: 'primary.com',
isSatellite: false,
signInUrl: 'https://primary.com/sign-in',
});

expect(requestState).toBeSignedIn({
domain: 'primary.com',
isSatellite: false,
signInUrl: 'https://primary.com/sign-in',
});
});

Comment on lines +1633 to +1662

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Test currently passes due to permissive “accounts.*” fallback; align with real Clerk host.

This case uses https://accounts.example.com/..., which is not derivable from the publishable key in mockOptions(). It only passes because production code whitelists any accounts.* host. If we tighten matching (recommended), this test will fail.

Change the referer to the expected accounts origin derived from the PK used in tests (e.g., accounts.inspired.puma-74.lcl.dev) or compute it from the PK to avoid coupling to the permissive fallback.

- referer: 'https://accounts.example.com/sign-in',+ referer: 'https://accounts.inspired.puma-74.lcl.dev/sign-in',

Alternatively, if you want to exercise a truly “prod-style” hostname, derive it from buildAccountsBaseUrl(frontendApi) seeded by the parsed PK rather than hardcoding example.com.

📝 Committable suggestion

‼️IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
test('does not trigger handshake when referer is from production accounts portal',async()=>{
constrequest=mockRequestWithCookies(
{
referer: 'https://accounts.example.com/sign-in',
'sec-fetch-dest': 'document',
'sec-fetch-site': 'cross-site',
},
{
__session: mockJwt,
__client_uat: '12345',
},
'https://primary.com/dashboard',
);
constrequestState=awaitauthenticateRequest(request,{
...mockOptions(),
publishableKey: PK_LIVE,
domain: 'primary.com',
isSatellite: false,
signInUrl: 'https://primary.com/sign-in',
});
expect(requestState).toBeSignedIn({
domain: 'primary.com',
isSatellite: false,
signInUrl: 'https://primary.com/sign-in',
});
});
test('does not trigger handshake when referer is from production accounts portal',async()=>{
constrequest=mockRequestWithCookies(
{
referer: 'https://accounts.inspired.puma-74.lcl.dev/sign-in',
'sec-fetch-dest': 'document',
'sec-fetch-site': 'cross-site',
},
{
__session: mockJwt,
__client_uat: '12345',
},
'https://primary.com/dashboard',
);
constrequestState=awaitauthenticateRequest(request,{
...mockOptions(),
publishableKey: PK_LIVE,
domain: 'primary.com',
isSatellite: false,
signInUrl: 'https://primary.com/sign-in',
});
expect(requestState).toBeSignedIn({
domain: 'primary.com',
isSatellite: false,
signInUrl: 'https://primary.com/sign-in',
});
});
🤖 Prompt for AI Agents
In packages/backend/src/tokens/__tests__/request.test.ts around lines 1608 to
1637, the test hardcodes referer "https://accounts.example.com/..." which only
passes because production code currently allows any accounts.* host; update the
test to use the actual accounts origin derived from the publishable key in
mockOptions() (e.g., call the same helper used in production like
buildAccountsBaseUrl(frontendApi) or construct
"https://accounts.<frontendApi-derived-host>") so the referer matches the PK
used in the test instead of relying on the permissive accounts.* fallback.

test('does not trigger handshake when referer is from dev accounts portal (current format)', async () => {
const request = mockRequestWithCookies(
{
referer: 'https://foo-bar-13.accounts.dev/sign-in',
'sec-fetch-dest': 'document',
'sec-fetch-site': 'cross-site',
},
{
__session: mockJwt,
__client_uat: '12345',
},
'https://primary.com/dashboard',
);

const requestState = await authenticateRequest(request, {
...mockOptions(),
publishableKey: PK_LIVE,
domain: 'primary.com',
isSatellite: false,
signInUrl: 'https://primary.com/sign-in',
});

expect(requestState).toBeSignedIn({
domain: 'primary.com',
isSatellite: false,
signInUrl: 'https://primary.com/sign-in',
});
});

test('does not trigger handshake when referer is from dev accounts portal (legacy format)', async () => {
const request = mockRequestWithCookies(
{
referer: 'https://accounts.foo-bar-13.lcl.dev/sign-in',
'sec-fetch-dest': 'document',
'sec-fetch-site': 'cross-site',
},
{
__session: mockJwt,
__client_uat: '12345',
},
'https://primary.com/dashboard',
);

const requestState = await authenticateRequest(request, {
...mockOptions(),
publishableKey: PK_LIVE,
domain: 'primary.com',
isSatellite: false,
signInUrl: 'https://primary.com/sign-in',
});

expect(requestState).toBeSignedIn({
domain: 'primary.com',
isSatellite: false,
signInUrl: 'https://primary.com/sign-in',
});
});

test('does not trigger cross-origin handshake when referer is from expected accounts portal derived from frontend API', async () => {
const request = mockRequestWithCookies(
{
referer: 'https://accounts.inspired.puma-74.lcl.dev/sign-in',
'sec-fetch-dest': 'document',
'sec-fetch-site': 'cross-site',
},
{
__session: mockJwt,
__client_uat: '12345',
},
'https://primary.com/dashboard',
);

const requestState = await authenticateRequest(request, {
...mockOptions(),
domain: 'primary.com',
isSatellite: false,
signInUrl: 'https://primary.com/sign-in',
});

// Should not trigger the specific cross-origin sync handshake we're trying to prevent
expect(requestState.reason).not.toBe(AuthErrorReason.PrimaryDomainCrossOriginSync);
});
Comment on lines +1721 to +1744

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Add a negative test to prevent regressions: “accounts.attacker.com” must still trigger cross-origin handshake.

To guard against over-broad whitelisting, add a case where the referer is an unrelated accounts.* domain and assert that PrimaryDomainCrossOriginSync is triggered.

@@
describe('Cross-origin sync', () => {
@@
+ test('triggers handshake when referer is unrelated accounts.* domain', async () => {+ const request = mockRequestWithCookies(+ {+ referer: 'https://accounts.attacker.com/signin',+ 'sec-fetch-dest': 'document',+ 'sec-fetch-site': 'cross-site',+ },+ {+ __session: mockJwt,+ __client_uat: '12345',+ },+ 'https://primary.com/dashboard',+ );++ const requestState = await authenticateRequest(request, {+ ...mockOptions(),+ publishableKey: PK_LIVE,+ domain: 'primary.com',+ isSatellite: false,+ signInUrl: 'https://primary.com/sign-in',+ });++ expect(requestState).toMatchHandshake({+ reason: AuthErrorReason.PrimaryDomainCrossOriginSync,+ domain: 'primary.com',+ signInUrl: 'https://primary.com/sign-in',+ });+ });
📝 Committable suggestion

‼️IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
test('does not trigger cross-origin handshake when referer is from expected accounts portal derived from frontend API',async()=>{
constrequest=mockRequestWithCookies(
{
referer: 'https://accounts.inspired.puma-74.lcl.dev/sign-in',
'sec-fetch-dest': 'document',
'sec-fetch-site': 'cross-site',
},
{
__session: mockJwt,
__client_uat: '12345',
},
'https://primary.com/dashboard',
);
constrequestState=awaitauthenticateRequest(request,{
...mockOptions(),
domain: 'primary.com',
isSatellite: false,
signInUrl: 'https://primary.com/sign-in',
});
// Should not trigger the specific cross-origin sync handshake we're trying to prevent
expect(requestState.reason).not.toBe(AuthErrorReason.PrimaryDomainCrossOriginSync);
});
describe('Cross-origin sync',()=>{
test('does not trigger cross-origin handshake when referer is from expected accounts portal derived from frontend API',async()=>{
constrequest=mockRequestWithCookies(
{
referer: 'https://accounts.inspired.puma-74.lcl.dev/sign-in',
'sec-fetch-dest': 'document',
'sec-fetch-site': 'cross-site',
},
{
__session: mockJwt,
__client_uat: '12345',
},
'https://primary.com/dashboard',
);
constrequestState=awaitauthenticateRequest(request,{
...mockOptions(),
domain: 'primary.com',
isSatellite: false,
signInUrl: 'https://primary.com/sign-in',
});
// Should not trigger the specific cross-origin sync handshake we're trying to prevent
expect(requestState.reason).not.toBe(AuthErrorReason.PrimaryDomainCrossOriginSync);
});
test('triggers handshake when referer is unrelated accounts.* domain',async()=>{
constrequest=mockRequestWithCookies(
{
referer: 'https://accounts.attacker.com/signin',
'sec-fetch-dest': 'document',
'sec-fetch-site': 'cross-site',
},
{
__session: mockJwt,
__client_uat: '12345',
},
'https://primary.com/dashboard',
);
constrequestState=awaitauthenticateRequest(request,{
...mockOptions(),
publishableKey: PK_LIVE,
domain: 'primary.com',
isSatellite: false,
signInUrl: 'https://primary.com/sign-in',
});
expect(requestState).toMatchHandshake({
reason: AuthErrorReason.PrimaryDomainCrossOriginSync,
domain: 'primary.com',
signInUrl: 'https://primary.com/sign-in',
});
});
});
🤖 Prompt for AI Agents
In packages/backend/src/tokens/__tests__/request.test.ts around lines 1696 to
1719, add a negative test case that ensures an unrelated accounts.* origin still
triggers the cross-origin handshake: create a request similar to the existing
test but with referer 'https://accounts.attacker.com/sign-in' (keep
'sec-fetch-site': 'cross-site', cookies same, and origin
'https://primary.com/dashboard'), call authenticateRequest with the same
mockOptions (domain: 'primary.com', isSatellite: false, signInUrl:
'https://primary.com/sign-in'), and assert that requestState.reason ===
AuthErrorReason.PrimaryDomainCrossOriginSync to prevent over-broad whitelisting.


test('does not trigger handshake when referer is from FAPI domain (redirect-based auth)', async () => {
const request = mockRequestWithCookies(
{
referer: 'https://clerk.inspired.puma-74.lcl.dev/v1/client/sign_ins/12345/attempt_first_factor',
'sec-fetch-dest': 'document',
'sec-fetch-site': 'cross-site',
},
{
__session: mockJwt,
__client_uat: '12345',
},
'https://primary.com/dashboard',
);

const requestState = await authenticateRequest(request, {
...mockOptions(),
domain: 'primary.com',
isSatellite: false,
signInUrl: 'https://primary.com/sign-in',
});

// Should not trigger the specific cross-origin sync handshake we're trying to prevent
expect(requestState.reason).not.toBe(AuthErrorReason.PrimaryDomainCrossOriginSync);
});

test('does not trigger handshake when referer is from FAPI domain with https prefix', async () => {
const request = mockRequestWithCookies(
{
referer: 'https://clerk.inspired.puma-74.lcl.dev/sign-in',
'sec-fetch-dest': 'document',
'sec-fetch-site': 'cross-site',
},
{
__session: mockJwt,
__client_uat: '12345',
},
'https://primary.com/dashboard',
);

const requestState = await authenticateRequest(request, {
...mockOptions(),
domain: 'primary.com',
isSatellite: false,
signInUrl: 'https://primary.com/sign-in',
});

// Should not trigger the specific cross-origin sync handshake we're trying to prevent
expect(requestState.reason).not.toBe(AuthErrorReason.PrimaryDomainCrossOriginSync);
});

test('still triggers handshake for legitimate cross-origin requests from non-accounts domains', async () => {
const request = mockRequestWithCookies(
{
referer: 'https://satellite.com/sign-in',
'sec-fetch-dest': 'document',
'sec-fetch-site': 'cross-site',
},
{
__session: mockJwt,
__client_uat: '12345',
},
'https://primary.com/dashboard',
);

const requestState = await authenticateRequest(request, {
...mockOptions(),
publishableKey: PK_LIVE,
domain: 'primary.com',
isSatellite: false,
signInUrl: 'https://primary.com/sign-in',
});

expect(requestState).toMatchHandshake({
reason: AuthErrorReason.PrimaryDomainCrossOriginSync,
domain: 'primary.com',
signInUrl: 'https://primary.com/sign-in',
});
});

test('does not trigger handshake when referrer matches current origin despite sec-fetch-site cross-site (redirect chain)', async () => {
const request = mockRequestWithCookies(
{
host: 'primary.com',
referer: 'https://primary.com/some-page',
'sec-fetch-dest': 'document',
'sec-fetch-site': 'cross-site', // This can happen due to redirect chains through Clerk domains
},
{
__session: mockJwt,
__client_uat: '12345',
},
'https://primary.com/dashboard',
);

const requestState = await authenticateRequest(request, {
...mockOptions(),
publishableKey: PK_LIVE,
domain: 'primary.com',
isSatellite: false,
signInUrl: 'https://primary.com/sign-in',
});

// Should not trigger handshake because referrer origin matches current origin
expect(requestState).toBeSignedIn({
domain: 'primary.com',
isSatellite: false,
signInUrl: 'https://primary.com/sign-in',
});
});
});
});
56 changes: 52 additions & 4 deletions packages/backend/src/tokens/authenticateContext.ts
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
import { buildAccountsBaseUrl } from '@clerk/shared/buildAccountsBaseUrl';
import { isCurrentDevAccountPortalOrigin, isLegacyDevAccountPortalOrigin } from '@clerk/shared/url';
import type { Jwt } from '@clerk/types';

import { constants } from '../constants';
Expand DownExpand Up@@ -186,10 +188,6 @@ class AuthenticateContext implements AuthenticateContext {
}

try {
if (this.getHeader(constants.Headers.SecFetchSite) === 'cross-site') {
return true;
}

const referrerOrigin = new URL(this.referrer).origin;
return referrerOrigin !== this.clerkUrl.origin;
} catch {
Expand All@@ -198,6 +196,56 @@ class AuthenticateContext implements AuthenticateContext {
}
}

/**
* Determines if the referrer URL is from a Clerk domain (accounts portal or FAPI).
* This includes both development and production account portal domains, as well as FAPI domains
* used for redirect-based authentication flows.
*
* @returns {boolean} True if the referrer is from a Clerk accounts portal or FAPI domain, false otherwise
*/
public isKnownClerkReferrer(): boolean {
if (!this.referrer) {
return false;
}

try {
const referrerOrigin = new URL(this.referrer);
const referrerHost = referrerOrigin.hostname;

// Check if referrer is the FAPI domain itself (redirect-based auth flows)
if (this.frontendApi) {
const fapiHost = this.frontendApi.startsWith('http') ? new URL(this.frontendApi).hostname : this.frontendApi;
if (referrerHost === fapiHost) {
return true;
}
}

// Check for development account portal patterns
if (isLegacyDevAccountPortalOrigin(referrerHost) || isCurrentDevAccountPortalOrigin(referrerHost)) {
return true;
}

// Check for production account portal by comparing with expected accounts URL
const expectedAccountsUrl = buildAccountsBaseUrl(this.frontendApi);
if (expectedAccountsUrl) {
const expectedAccountsOrigin = new URL(expectedAccountsUrl).origin;
if (referrerOrigin.origin === expectedAccountsOrigin) {
return true;
}
}

// Check for generic production accounts patterns (accounts.*)
if (referrerHost.startsWith('accounts.')) {
return true;
}

return false;
} catch {
// Invalid URL format
return false;
}
}

private initPublishableKeyValues(options: AuthenticateRequestOptions) {
assertValidPublishableKey(options.publishableKey);
this.publishableKey = options.publishableKey;
Expand Down
3 changes: 2 additions & 1 deletion packages/backend/src/tokens/request.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -576,7 +576,8 @@ export const authenticateRequest: AuthenticateRequest = (async (
const shouldForceHandshakeForCrossDomain =
!authenticateContext.isSatellite && // We're on primary
authenticateContext.secFetchDest === 'document' && // Document navigation
authenticateContext.isCrossOriginReferrer(); // Came from different domain
authenticateContext.isCrossOriginReferrer() && // Came from different domain
!authenticateContext.isKnownClerkReferrer(); // Not from Clerk accounts portal or FAPI

if (shouldForceHandshakeForCrossDomain) {
return handleMaybeHandshakeStatus(
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
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
5 changes: 5 additions & 0 deletions .changeset/yellow-vans-walk.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
---
'@clerk/backend': patch
---

Fix logic for forcing a session sync on cross origin requests.
247 changes: 247 additions & 0 deletions packages/backend/src/tokens/__tests__/request.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -1520,6 +1520,31 @@ describe('tokens.authenticateRequest(options)', () => {
});
});

test('does not trigger handshake when referer is same origin', async () => {
const request = mockRequestWithCookies(
{
host: 'localhost:3000',
referer: 'http://localhost:3000',
'sec-fetch-dest': 'document',
},
{
__clerk_db_jwt: mockJwt,
__session: mockJwt,
__client_uat: '12345',
},
'http://localhost:3000',
);

const requestState = await authenticateRequest(request, {
...mockOptions(),
signInUrl: 'http://localhost:3000/sign-in',
});

expect(requestState).toBeSignedIn({
signInUrl: 'http://localhost:3000/sign-in',
});
});

test('does not trigger handshake when no referer header', async () => {
const request = mockRequestWithCookies(
{
Expand DownExpand Up@@ -1605,5 +1630,227 @@ describe('tokens.authenticateRequest(options)', () => {
signInUrl: 'https://primary.com/sign-in',
});
});

test('does not trigger handshake when referer is from production accounts portal', async () => {
const request = mockRequestWithCookies(
{
referer: 'https://accounts.example.com/sign-in',
'sec-fetch-dest': 'document',
'sec-fetch-site': 'cross-site',
},
{
__session: mockJwt,
__client_uat: '12345',
},
'https://primary.com/dashboard',
);

const requestState = await authenticateRequest(request, {
...mockOptions(),
publishableKey: PK_LIVE,
domain: 'primary.com',
isSatellite: false,
signInUrl: 'https://primary.com/sign-in',
});

expect(requestState).toBeSignedIn({
domain: 'primary.com',
isSatellite: false,
signInUrl: 'https://primary.com/sign-in',
});
});

Comment on lines +1633 to +1662

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Test currently passes due to permissive “accounts.*” fallback; align with real Clerk host.

This case uses https://accounts.example.com/..., which is not derivable from the publishable key in mockOptions(). It only passes because production code whitelists any accounts.* host. If we tighten matching (recommended), this test will fail.

Change the referer to the expected accounts origin derived from the PK used in tests (e.g., accounts.inspired.puma-74.lcl.dev) or compute it from the PK to avoid coupling to the permissive fallback.

- referer: 'https://accounts.example.com/sign-in',+ referer: 'https://accounts.inspired.puma-74.lcl.dev/sign-in',

Alternatively, if you want to exercise a truly “prod-style” hostname, derive it from buildAccountsBaseUrl(frontendApi) seeded by the parsed PK rather than hardcoding example.com.

📝 Committable suggestion

‼️IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
test('does not trigger handshake when referer is from production accounts portal',async()=>{
constrequest=mockRequestWithCookies(
{
referer: 'https://accounts.example.com/sign-in',
'sec-fetch-dest': 'document',
'sec-fetch-site': 'cross-site',
},
{
__session: mockJwt,
__client_uat: '12345',
},
'https://primary.com/dashboard',
);
constrequestState=awaitauthenticateRequest(request,{
...mockOptions(),
publishableKey: PK_LIVE,
domain: 'primary.com',
isSatellite: false,
signInUrl: 'https://primary.com/sign-in',
});
expect(requestState).toBeSignedIn({
domain: 'primary.com',
isSatellite: false,
signInUrl: 'https://primary.com/sign-in',
});
});
test('does not trigger handshake when referer is from production accounts portal',async()=>{
constrequest=mockRequestWithCookies(
{
referer: 'https://accounts.inspired.puma-74.lcl.dev/sign-in',
'sec-fetch-dest': 'document',
'sec-fetch-site': 'cross-site',
},
{
__session: mockJwt,
__client_uat: '12345',
},
'https://primary.com/dashboard',
);
constrequestState=awaitauthenticateRequest(request,{
...mockOptions(),
publishableKey: PK_LIVE,
domain: 'primary.com',
isSatellite: false,
signInUrl: 'https://primary.com/sign-in',
});
expect(requestState).toBeSignedIn({
domain: 'primary.com',
isSatellite: false,
signInUrl: 'https://primary.com/sign-in',
});
});
🤖 Prompt for AI Agents
In packages/backend/src/tokens/__tests__/request.test.ts around lines 1608 to
1637, the test hardcodes referer "https://accounts.example.com/..." which only
passes because production code currently allows any accounts.* host; update the
test to use the actual accounts origin derived from the publishable key in
mockOptions() (e.g., call the same helper used in production like
buildAccountsBaseUrl(frontendApi) or construct
"https://accounts.<frontendApi-derived-host>") so the referer matches the PK
used in the test instead of relying on the permissive accounts.* fallback.

test('does not trigger handshake when referer is from dev accounts portal (current format)', async () => {
const request = mockRequestWithCookies(
{
referer: 'https://foo-bar-13.accounts.dev/sign-in',
'sec-fetch-dest': 'document',
'sec-fetch-site': 'cross-site',
},
{
__session: mockJwt,
__client_uat: '12345',
},
'https://primary.com/dashboard',
);

const requestState = await authenticateRequest(request, {
...mockOptions(),
publishableKey: PK_LIVE,
domain: 'primary.com',
isSatellite: false,
signInUrl: 'https://primary.com/sign-in',
});

expect(requestState).toBeSignedIn({
domain: 'primary.com',
isSatellite: false,
signInUrl: 'https://primary.com/sign-in',
});
});

test('does not trigger handshake when referer is from dev accounts portal (legacy format)', async () => {
const request = mockRequestWithCookies(
{
referer: 'https://accounts.foo-bar-13.lcl.dev/sign-in',
'sec-fetch-dest': 'document',
'sec-fetch-site': 'cross-site',
},
{
__session: mockJwt,
__client_uat: '12345',
},
'https://primary.com/dashboard',
);

const requestState = await authenticateRequest(request, {
...mockOptions(),
publishableKey: PK_LIVE,
domain: 'primary.com',
isSatellite: false,
signInUrl: 'https://primary.com/sign-in',
});

expect(requestState).toBeSignedIn({
domain: 'primary.com',
isSatellite: false,
signInUrl: 'https://primary.com/sign-in',
});
});

test('does not trigger cross-origin handshake when referer is from expected accounts portal derived from frontend API', async () => {
const request = mockRequestWithCookies(
{
referer: 'https://accounts.inspired.puma-74.lcl.dev/sign-in',
'sec-fetch-dest': 'document',
'sec-fetch-site': 'cross-site',
},
{
__session: mockJwt,
__client_uat: '12345',
},
'https://primary.com/dashboard',
);

const requestState = await authenticateRequest(request, {
...mockOptions(),
domain: 'primary.com',
isSatellite: false,
signInUrl: 'https://primary.com/sign-in',
});

// Should not trigger the specific cross-origin sync handshake we're trying to prevent
expect(requestState.reason).not.toBe(AuthErrorReason.PrimaryDomainCrossOriginSync);
});
Comment on lines +1721 to +1744

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Add a negative test to prevent regressions: “accounts.attacker.com” must still trigger cross-origin handshake.

To guard against over-broad whitelisting, add a case where the referer is an unrelated accounts.* domain and assert that PrimaryDomainCrossOriginSync is triggered.

@@
describe('Cross-origin sync', () => {
@@
+ test('triggers handshake when referer is unrelated accounts.* domain', async () => {+ const request = mockRequestWithCookies(+ {+ referer: 'https://accounts.attacker.com/signin',+ 'sec-fetch-dest': 'document',+ 'sec-fetch-site': 'cross-site',+ },+ {+ __session: mockJwt,+ __client_uat: '12345',+ },+ 'https://primary.com/dashboard',+ );++ const requestState = await authenticateRequest(request, {+ ...mockOptions(),+ publishableKey: PK_LIVE,+ domain: 'primary.com',+ isSatellite: false,+ signInUrl: 'https://primary.com/sign-in',+ });++ expect(requestState).toMatchHandshake({+ reason: AuthErrorReason.PrimaryDomainCrossOriginSync,+ domain: 'primary.com',+ signInUrl: 'https://primary.com/sign-in',+ });+ });
📝 Committable suggestion

‼️IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
test('does not trigger cross-origin handshake when referer is from expected accounts portal derived from frontend API',async()=>{
constrequest=mockRequestWithCookies(
{
referer: 'https://accounts.inspired.puma-74.lcl.dev/sign-in',
'sec-fetch-dest': 'document',
'sec-fetch-site': 'cross-site',
},
{
__session: mockJwt,
__client_uat: '12345',
},
'https://primary.com/dashboard',
);
constrequestState=awaitauthenticateRequest(request,{
...mockOptions(),
domain: 'primary.com',
isSatellite: false,
signInUrl: 'https://primary.com/sign-in',
});
// Should not trigger the specific cross-origin sync handshake we're trying to prevent
expect(requestState.reason).not.toBe(AuthErrorReason.PrimaryDomainCrossOriginSync);
});
describe('Cross-origin sync',()=>{
test('does not trigger cross-origin handshake when referer is from expected accounts portal derived from frontend API',async()=>{
constrequest=mockRequestWithCookies(
{
referer: 'https://accounts.inspired.puma-74.lcl.dev/sign-in',
'sec-fetch-dest': 'document',
'sec-fetch-site': 'cross-site',
},
{
__session: mockJwt,
__client_uat: '12345',
},
'https://primary.com/dashboard',
);
constrequestState=awaitauthenticateRequest(request,{
...mockOptions(),
domain: 'primary.com',
isSatellite: false,
signInUrl: 'https://primary.com/sign-in',
});
// Should not trigger the specific cross-origin sync handshake we're trying to prevent
expect(requestState.reason).not.toBe(AuthErrorReason.PrimaryDomainCrossOriginSync);
});
test('triggers handshake when referer is unrelated accounts.* domain',async()=>{
constrequest=mockRequestWithCookies(
{
referer: 'https://accounts.attacker.com/signin',
'sec-fetch-dest': 'document',
'sec-fetch-site': 'cross-site',
},
{
__session: mockJwt,
__client_uat: '12345',
},
'https://primary.com/dashboard',
);
constrequestState=awaitauthenticateRequest(request,{
...mockOptions(),
publishableKey: PK_LIVE,
domain: 'primary.com',
isSatellite: false,
signInUrl: 'https://primary.com/sign-in',
});
expect(requestState).toMatchHandshake({
reason: AuthErrorReason.PrimaryDomainCrossOriginSync,
domain: 'primary.com',
signInUrl: 'https://primary.com/sign-in',
});
});
});
🤖 Prompt for AI Agents
In packages/backend/src/tokens/__tests__/request.test.ts around lines 1696 to
1719, add a negative test case that ensures an unrelated accounts.* origin still
triggers the cross-origin handshake: create a request similar to the existing
test but with referer 'https://accounts.attacker.com/sign-in' (keep
'sec-fetch-site': 'cross-site', cookies same, and origin
'https://primary.com/dashboard'), call authenticateRequest with the same
mockOptions (domain: 'primary.com', isSatellite: false, signInUrl:
'https://primary.com/sign-in'), and assert that requestState.reason ===
AuthErrorReason.PrimaryDomainCrossOriginSync to prevent over-broad whitelisting.


test('does not trigger handshake when referer is from FAPI domain (redirect-based auth)', async () => {
const request = mockRequestWithCookies(
{
referer: 'https://clerk.inspired.puma-74.lcl.dev/v1/client/sign_ins/12345/attempt_first_factor',
'sec-fetch-dest': 'document',
'sec-fetch-site': 'cross-site',
},
{
__session: mockJwt,
__client_uat: '12345',
},
'https://primary.com/dashboard',
);

const requestState = await authenticateRequest(request, {
...mockOptions(),
domain: 'primary.com',
isSatellite: false,
signInUrl: 'https://primary.com/sign-in',
});

// Should not trigger the specific cross-origin sync handshake we're trying to prevent
expect(requestState.reason).not.toBe(AuthErrorReason.PrimaryDomainCrossOriginSync);
});

test('does not trigger handshake when referer is from FAPI domain with https prefix', async () => {
const request = mockRequestWithCookies(
{
referer: 'https://clerk.inspired.puma-74.lcl.dev/sign-in',
'sec-fetch-dest': 'document',
'sec-fetch-site': 'cross-site',
},
{
__session: mockJwt,
__client_uat: '12345',
},
'https://primary.com/dashboard',
);

const requestState = await authenticateRequest(request, {
...mockOptions(),
domain: 'primary.com',
isSatellite: false,
signInUrl: 'https://primary.com/sign-in',
});

// Should not trigger the specific cross-origin sync handshake we're trying to prevent
expect(requestState.reason).not.toBe(AuthErrorReason.PrimaryDomainCrossOriginSync);
});

test('still triggers handshake for legitimate cross-origin requests from non-accounts domains', async () => {
const request = mockRequestWithCookies(
{
referer: 'https://satellite.com/sign-in',
'sec-fetch-dest': 'document',
'sec-fetch-site': 'cross-site',
},
{
__session: mockJwt,
__client_uat: '12345',
},
'https://primary.com/dashboard',
);

const requestState = await authenticateRequest(request, {
...mockOptions(),
publishableKey: PK_LIVE,
domain: 'primary.com',
isSatellite: false,
signInUrl: 'https://primary.com/sign-in',
});

expect(requestState).toMatchHandshake({
reason: AuthErrorReason.PrimaryDomainCrossOriginSync,
domain: 'primary.com',
signInUrl: 'https://primary.com/sign-in',
});
});

test('does not trigger handshake when referrer matches current origin despite sec-fetch-site cross-site (redirect chain)', async () => {
const request = mockRequestWithCookies(
{
host: 'primary.com',
referer: 'https://primary.com/some-page',
'sec-fetch-dest': 'document',
'sec-fetch-site': 'cross-site', // This can happen due to redirect chains through Clerk domains
},
{
__session: mockJwt,
__client_uat: '12345',
},
'https://primary.com/dashboard',
);

const requestState = await authenticateRequest(request, {
...mockOptions(),
publishableKey: PK_LIVE,
domain: 'primary.com',
isSatellite: false,
signInUrl: 'https://primary.com/sign-in',
});

// Should not trigger handshake because referrer origin matches current origin
expect(requestState).toBeSignedIn({
domain: 'primary.com',
isSatellite: false,
signInUrl: 'https://primary.com/sign-in',
});
});
});
});
56 changes: 52 additions & 4 deletions packages/backend/src/tokens/authenticateContext.ts
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
import { buildAccountsBaseUrl } from '@clerk/shared/buildAccountsBaseUrl';
import { isCurrentDevAccountPortalOrigin, isLegacyDevAccountPortalOrigin } from '@clerk/shared/url';
import type { Jwt } from '@clerk/types';

import { constants } from '../constants';
Expand DownExpand Up@@ -186,10 +188,6 @@ class AuthenticateContext implements AuthenticateContext {
}

try {
if (this.getHeader(constants.Headers.SecFetchSite) === 'cross-site') {
return true;
}

const referrerOrigin = new URL(this.referrer).origin;
return referrerOrigin !== this.clerkUrl.origin;
} catch {
Expand All@@ -198,6 +196,56 @@ class AuthenticateContext implements AuthenticateContext {
}
}

/**
* Determines if the referrer URL is from a Clerk domain (accounts portal or FAPI).
* This includes both development and production account portal domains, as well as FAPI domains
* used for redirect-based authentication flows.
*
* @returns {boolean} True if the referrer is from a Clerk accounts portal or FAPI domain, false otherwise
*/
public isKnownClerkReferrer(): boolean {
if (!this.referrer) {
return false;
}

try {
const referrerOrigin = new URL(this.referrer);
const referrerHost = referrerOrigin.hostname;

// Check if referrer is the FAPI domain itself (redirect-based auth flows)
if (this.frontendApi) {
const fapiHost = this.frontendApi.startsWith('http') ? new URL(this.frontendApi).hostname : this.frontendApi;
if (referrerHost === fapiHost) {
return true;
}
}

// Check for development account portal patterns
if (isLegacyDevAccountPortalOrigin(referrerHost) || isCurrentDevAccountPortalOrigin(referrerHost)) {
return true;
}

// Check for production account portal by comparing with expected accounts URL
const expectedAccountsUrl = buildAccountsBaseUrl(this.frontendApi);
if (expectedAccountsUrl) {
const expectedAccountsOrigin = new URL(expectedAccountsUrl).origin;
if (referrerOrigin.origin === expectedAccountsOrigin) {
return true;
}
}

// Check for generic production accounts patterns (accounts.*)
if (referrerHost.startsWith('accounts.')) {
return true;
}

return false;
} catch {
// Invalid URL format
return false;
}
}

private initPublishableKeyValues(options: AuthenticateRequestOptions) {
assertValidPublishableKey(options.publishableKey);
this.publishableKey = options.publishableKey;
Expand Down
3 changes: 2 additions & 1 deletion packages/backend/src/tokens/request.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -576,7 +576,8 @@ export const authenticateRequest: AuthenticateRequest = (async (
const shouldForceHandshakeForCrossDomain =
!authenticateContext.isSatellite && // We're on primary
authenticateContext.secFetchDest === 'document' && // Document navigation
authenticateContext.isCrossOriginReferrer(); // Came from different domain
authenticateContext.isCrossOriginReferrer() && // Came from different domain
!authenticateContext.isKnownClerkReferrer(); // Not from Clerk accounts portal or FAPI

if (shouldForceHandshakeForCrossDomain) {
return handleMaybeHandshakeStatus(
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content
Merged
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
5 changes: 5 additions & 0 deletions .changeset/yellow-vans-walk.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
---
'@clerk/backend': patch
---

Fix logic for forcing a session sync on cross origin requests.
247 changes: 247 additions & 0 deletions packages/backend/src/tokens/__tests__/request.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -1520,6 +1520,31 @@ describe('tokens.authenticateRequest(options)', () => {
});
});

test('does not trigger handshake when referer is same origin', async () => {
const request = mockRequestWithCookies(
{
host: 'localhost:3000',
referer: 'http://localhost:3000',
'sec-fetch-dest': 'document',
},
{
__clerk_db_jwt: mockJwt,
__session: mockJwt,
__client_uat: '12345',
},
'http://localhost:3000',
);

const requestState = await authenticateRequest(request, {
...mockOptions(),
signInUrl: 'http://localhost:3000/sign-in',
});

expect(requestState).toBeSignedIn({
signInUrl: 'http://localhost:3000/sign-in',
});
});

test('does not trigger handshake when no referer header', async () => {
const request = mockRequestWithCookies(
{
Expand DownExpand Up@@ -1605,5 +1630,227 @@ describe('tokens.authenticateRequest(options)', () => {
signInUrl: 'https://primary.com/sign-in',
});
});

test('does not trigger handshake when referer is from production accounts portal', async () => {
const request = mockRequestWithCookies(
{
referer: 'https://accounts.example.com/sign-in',
'sec-fetch-dest': 'document',
'sec-fetch-site': 'cross-site',
},
{
__session: mockJwt,
__client_uat: '12345',
},
'https://primary.com/dashboard',
);

const requestState = await authenticateRequest(request, {
...mockOptions(),
publishableKey: PK_LIVE,
domain: 'primary.com',
isSatellite: false,
signInUrl: 'https://primary.com/sign-in',
});

expect(requestState).toBeSignedIn({
domain: 'primary.com',
isSatellite: false,
signInUrl: 'https://primary.com/sign-in',
});
});

Comment on lines +1633 to +1662

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Test currently passes due to permissive “accounts.*” fallback; align with real Clerk host.

This case uses https://accounts.example.com/..., which is not derivable from the publishable key in mockOptions(). It only passes because production code whitelists any accounts.* host. If we tighten matching (recommended), this test will fail.

Change the referer to the expected accounts origin derived from the PK used in tests (e.g., accounts.inspired.puma-74.lcl.dev) or compute it from the PK to avoid coupling to the permissive fallback.

- referer: 'https://accounts.example.com/sign-in',+ referer: 'https://accounts.inspired.puma-74.lcl.dev/sign-in',

Alternatively, if you want to exercise a truly “prod-style” hostname, derive it from buildAccountsBaseUrl(frontendApi) seeded by the parsed PK rather than hardcoding example.com.

📝 Committable suggestion

‼️IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
test('does not trigger handshake when referer is from production accounts portal',async()=>{
constrequest=mockRequestWithCookies(
{
referer: 'https://accounts.example.com/sign-in',
'sec-fetch-dest': 'document',
'sec-fetch-site': 'cross-site',
},
{
__session: mockJwt,
__client_uat: '12345',
},
'https://primary.com/dashboard',
);
constrequestState=awaitauthenticateRequest(request,{
...mockOptions(),
publishableKey: PK_LIVE,
domain: 'primary.com',
isSatellite: false,
signInUrl: 'https://primary.com/sign-in',
});
expect(requestState).toBeSignedIn({
domain: 'primary.com',
isSatellite: false,
signInUrl: 'https://primary.com/sign-in',
});
});
test('does not trigger handshake when referer is from production accounts portal',async()=>{
constrequest=mockRequestWithCookies(
{
referer: 'https://accounts.inspired.puma-74.lcl.dev/sign-in',
'sec-fetch-dest': 'document',
'sec-fetch-site': 'cross-site',
},
{
__session: mockJwt,
__client_uat: '12345',
},
'https://primary.com/dashboard',
);
constrequestState=awaitauthenticateRequest(request,{
...mockOptions(),
publishableKey: PK_LIVE,
domain: 'primary.com',
isSatellite: false,
signInUrl: 'https://primary.com/sign-in',
});
expect(requestState).toBeSignedIn({
domain: 'primary.com',
isSatellite: false,
signInUrl: 'https://primary.com/sign-in',
});
});
🤖 Prompt for AI Agents
In packages/backend/src/tokens/__tests__/request.test.ts around lines 1608 to
1637, the test hardcodes referer "https://accounts.example.com/..." which only
passes because production code currently allows any accounts.* host; update the
test to use the actual accounts origin derived from the publishable key in
mockOptions() (e.g., call the same helper used in production like
buildAccountsBaseUrl(frontendApi) or construct
"https://accounts.<frontendApi-derived-host>") so the referer matches the PK
used in the test instead of relying on the permissive accounts.* fallback.

test('does not trigger handshake when referer is from dev accounts portal (current format)', async () => {
const request = mockRequestWithCookies(
{
referer: 'https://foo-bar-13.accounts.dev/sign-in',
'sec-fetch-dest': 'document',
'sec-fetch-site': 'cross-site',
},
{
__session: mockJwt,
__client_uat: '12345',
},
'https://primary.com/dashboard',
);

const requestState = await authenticateRequest(request, {
...mockOptions(),
publishableKey: PK_LIVE,
domain: 'primary.com',
isSatellite: false,
signInUrl: 'https://primary.com/sign-in',
});

expect(requestState).toBeSignedIn({
domain: 'primary.com',
isSatellite: false,
signInUrl: 'https://primary.com/sign-in',
});
});

test('does not trigger handshake when referer is from dev accounts portal (legacy format)', async () => {
const request = mockRequestWithCookies(
{
referer: 'https://accounts.foo-bar-13.lcl.dev/sign-in',
'sec-fetch-dest': 'document',
'sec-fetch-site': 'cross-site',
},
{
__session: mockJwt,
__client_uat: '12345',
},
'https://primary.com/dashboard',
);

const requestState = await authenticateRequest(request, {
...mockOptions(),
publishableKey: PK_LIVE,
domain: 'primary.com',
isSatellite: false,
signInUrl: 'https://primary.com/sign-in',
});

expect(requestState).toBeSignedIn({
domain: 'primary.com',
isSatellite: false,
signInUrl: 'https://primary.com/sign-in',
});
});

test('does not trigger cross-origin handshake when referer is from expected accounts portal derived from frontend API', async () => {
const request = mockRequestWithCookies(
{
referer: 'https://accounts.inspired.puma-74.lcl.dev/sign-in',
'sec-fetch-dest': 'document',
'sec-fetch-site': 'cross-site',
},
{
__session: mockJwt,
__client_uat: '12345',
},
'https://primary.com/dashboard',
);

const requestState = await authenticateRequest(request, {
...mockOptions(),
domain: 'primary.com',
isSatellite: false,
signInUrl: 'https://primary.com/sign-in',
});

// Should not trigger the specific cross-origin sync handshake we're trying to prevent
expect(requestState.reason).not.toBe(AuthErrorReason.PrimaryDomainCrossOriginSync);
});
Comment on lines +1721 to +1744

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Add a negative test to prevent regressions: “accounts.attacker.com” must still trigger cross-origin handshake.

To guard against over-broad whitelisting, add a case where the referer is an unrelated accounts.* domain and assert that PrimaryDomainCrossOriginSync is triggered.

@@
describe('Cross-origin sync', () => {
@@
+ test('triggers handshake when referer is unrelated accounts.* domain', async () => {+ const request = mockRequestWithCookies(+ {+ referer: 'https://accounts.attacker.com/signin',+ 'sec-fetch-dest': 'document',+ 'sec-fetch-site': 'cross-site',+ },+ {+ __session: mockJwt,+ __client_uat: '12345',+ },+ 'https://primary.com/dashboard',+ );++ const requestState = await authenticateRequest(request, {+ ...mockOptions(),+ publishableKey: PK_LIVE,+ domain: 'primary.com',+ isSatellite: false,+ signInUrl: 'https://primary.com/sign-in',+ });++ expect(requestState).toMatchHandshake({+ reason: AuthErrorReason.PrimaryDomainCrossOriginSync,+ domain: 'primary.com',+ signInUrl: 'https://primary.com/sign-in',+ });+ });
📝 Committable suggestion

‼️IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
test('does not trigger cross-origin handshake when referer is from expected accounts portal derived from frontend API',async()=>{
constrequest=mockRequestWithCookies(
{
referer: 'https://accounts.inspired.puma-74.lcl.dev/sign-in',
'sec-fetch-dest': 'document',
'sec-fetch-site': 'cross-site',
},
{
__session: mockJwt,
__client_uat: '12345',
},
'https://primary.com/dashboard',
);
constrequestState=awaitauthenticateRequest(request,{
...mockOptions(),
domain: 'primary.com',
isSatellite: false,
signInUrl: 'https://primary.com/sign-in',
});
// Should not trigger the specific cross-origin sync handshake we're trying to prevent
expect(requestState.reason).not.toBe(AuthErrorReason.PrimaryDomainCrossOriginSync);
});
describe('Cross-origin sync',()=>{
test('does not trigger cross-origin handshake when referer is from expected accounts portal derived from frontend API',async()=>{
constrequest=mockRequestWithCookies(
{
referer: 'https://accounts.inspired.puma-74.lcl.dev/sign-in',
'sec-fetch-dest': 'document',
'sec-fetch-site': 'cross-site',
},
{
__session: mockJwt,
__client_uat: '12345',
},
'https://primary.com/dashboard',
);
constrequestState=awaitauthenticateRequest(request,{
...mockOptions(),
domain: 'primary.com',
isSatellite: false,
signInUrl: 'https://primary.com/sign-in',
});
// Should not trigger the specific cross-origin sync handshake we're trying to prevent
expect(requestState.reason).not.toBe(AuthErrorReason.PrimaryDomainCrossOriginSync);
});
test('triggers handshake when referer is unrelated accounts.* domain',async()=>{
constrequest=mockRequestWithCookies(
{
referer: 'https://accounts.attacker.com/signin',
'sec-fetch-dest': 'document',
'sec-fetch-site': 'cross-site',
},
{
__session: mockJwt,
__client_uat: '12345',
},
'https://primary.com/dashboard',
);
constrequestState=awaitauthenticateRequest(request,{
...mockOptions(),
publishableKey: PK_LIVE,
domain: 'primary.com',
isSatellite: false,
signInUrl: 'https://primary.com/sign-in',
});
expect(requestState).toMatchHandshake({
reason: AuthErrorReason.PrimaryDomainCrossOriginSync,
domain: 'primary.com',
signInUrl: 'https://primary.com/sign-in',
});
});
});
🤖 Prompt for AI Agents
In packages/backend/src/tokens/__tests__/request.test.ts around lines 1696 to
1719, add a negative test case that ensures an unrelated accounts.* origin still
triggers the cross-origin handshake: create a request similar to the existing
test but with referer 'https://accounts.attacker.com/sign-in' (keep
'sec-fetch-site': 'cross-site', cookies same, and origin
'https://primary.com/dashboard'), call authenticateRequest with the same
mockOptions (domain: 'primary.com', isSatellite: false, signInUrl:
'https://primary.com/sign-in'), and assert that requestState.reason ===
AuthErrorReason.PrimaryDomainCrossOriginSync to prevent over-broad whitelisting.


test('does not trigger handshake when referer is from FAPI domain (redirect-based auth)', async () => {
const request = mockRequestWithCookies(
{
referer: 'https://clerk.inspired.puma-74.lcl.dev/v1/client/sign_ins/12345/attempt_first_factor',
'sec-fetch-dest': 'document',
'sec-fetch-site': 'cross-site',
},
{
__session: mockJwt,
__client_uat: '12345',
},
'https://primary.com/dashboard',
);

const requestState = await authenticateRequest(request, {
...mockOptions(),
domain: 'primary.com',
isSatellite: false,
signInUrl: 'https://primary.com/sign-in',
});

// Should not trigger the specific cross-origin sync handshake we're trying to prevent
expect(requestState.reason).not.toBe(AuthErrorReason.PrimaryDomainCrossOriginSync);
});

test('does not trigger handshake when referer is from FAPI domain with https prefix', async () => {
const request = mockRequestWithCookies(
{
referer: 'https://clerk.inspired.puma-74.lcl.dev/sign-in',
'sec-fetch-dest': 'document',
'sec-fetch-site': 'cross-site',
},
{
__session: mockJwt,
__client_uat: '12345',
},
'https://primary.com/dashboard',
);

const requestState = await authenticateRequest(request, {
...mockOptions(),
domain: 'primary.com',
isSatellite: false,
signInUrl: 'https://primary.com/sign-in',
});

// Should not trigger the specific cross-origin sync handshake we're trying to prevent
expect(requestState.reason).not.toBe(AuthErrorReason.PrimaryDomainCrossOriginSync);
});

test('still triggers handshake for legitimate cross-origin requests from non-accounts domains', async () => {
const request = mockRequestWithCookies(
{
referer: 'https://satellite.com/sign-in',
'sec-fetch-dest': 'document',
'sec-fetch-site': 'cross-site',
},
{
__session: mockJwt,
__client_uat: '12345',
},
'https://primary.com/dashboard',
);

const requestState = await authenticateRequest(request, {
...mockOptions(),
publishableKey: PK_LIVE,
domain: 'primary.com',
isSatellite: false,
signInUrl: 'https://primary.com/sign-in',
});

expect(requestState).toMatchHandshake({
reason: AuthErrorReason.PrimaryDomainCrossOriginSync,
domain: 'primary.com',
signInUrl: 'https://primary.com/sign-in',
});
});

test('does not trigger handshake when referrer matches current origin despite sec-fetch-site cross-site (redirect chain)', async () => {
const request = mockRequestWithCookies(
{
host: 'primary.com',
referer: 'https://primary.com/some-page',
'sec-fetch-dest': 'document',
'sec-fetch-site': 'cross-site', // This can happen due to redirect chains through Clerk domains
},
{
__session: mockJwt,
__client_uat: '12345',
},
'https://primary.com/dashboard',
);

const requestState = await authenticateRequest(request, {
...mockOptions(),
publishableKey: PK_LIVE,
domain: 'primary.com',
isSatellite: false,
signInUrl: 'https://primary.com/sign-in',
});

// Should not trigger handshake because referrer origin matches current origin
expect(requestState).toBeSignedIn({
domain: 'primary.com',
isSatellite: false,
signInUrl: 'https://primary.com/sign-in',
});
});
});
});
56 changes: 52 additions & 4 deletions packages/backend/src/tokens/authenticateContext.ts
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
import { buildAccountsBaseUrl } from '@clerk/shared/buildAccountsBaseUrl';
import { isCurrentDevAccountPortalOrigin, isLegacyDevAccountPortalOrigin } from '@clerk/shared/url';
import type { Jwt } from '@clerk/types';

import { constants } from '../constants';
Expand DownExpand Up@@ -186,10 +188,6 @@ class AuthenticateContext implements AuthenticateContext {
}

try {
if (this.getHeader(constants.Headers.SecFetchSite) === 'cross-site') {
return true;
}

const referrerOrigin = new URL(this.referrer).origin;
return referrerOrigin !== this.clerkUrl.origin;
} catch {
Expand All@@ -198,6 +196,56 @@ class AuthenticateContext implements AuthenticateContext {
}
}

/**
* Determines if the referrer URL is from a Clerk domain (accounts portal or FAPI).
* This includes both development and production account portal domains, as well as FAPI domains
* used for redirect-based authentication flows.
*
* @returns {boolean} True if the referrer is from a Clerk accounts portal or FAPI domain, false otherwise
*/
public isKnownClerkReferrer(): boolean {
if (!this.referrer) {
return false;
}

try {
const referrerOrigin = new URL(this.referrer);
const referrerHost = referrerOrigin.hostname;

// Check if referrer is the FAPI domain itself (redirect-based auth flows)
if (this.frontendApi) {
const fapiHost = this.frontendApi.startsWith('http') ? new URL(this.frontendApi).hostname : this.frontendApi;
if (referrerHost === fapiHost) {
return true;
}
}

// Check for development account portal patterns
if (isLegacyDevAccountPortalOrigin(referrerHost) || isCurrentDevAccountPortalOrigin(referrerHost)) {
return true;
}

// Check for production account portal by comparing with expected accounts URL
const expectedAccountsUrl = buildAccountsBaseUrl(this.frontendApi);
if (expectedAccountsUrl) {
const expectedAccountsOrigin = new URL(expectedAccountsUrl).origin;
if (referrerOrigin.origin === expectedAccountsOrigin) {
return true;
}
}

// Check for generic production accounts patterns (accounts.*)
if (referrerHost.startsWith('accounts.')) {
return true;
}

return false;
} catch {
// Invalid URL format
return false;
}
}

private initPublishableKeyValues(options: AuthenticateRequestOptions) {
assertValidPublishableKey(options.publishableKey);
this.publishableKey = options.publishableKey;
Expand Down
3 changes: 2 additions & 1 deletion packages/backend/src/tokens/request.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -576,7 +576,8 @@ export const authenticateRequest: AuthenticateRequest = (async (
const shouldForceHandshakeForCrossDomain =
!authenticateContext.isSatellite && // We're on primary
authenticateContext.secFetchDest === 'document' && // Document navigation
authenticateContext.isCrossOriginReferrer(); // Came from different domain
authenticateContext.isCrossOriginReferrer() && // Came from different domain
!authenticateContext.isKnownClerkReferrer(); // Not from Clerk accounts portal or FAPI

if (shouldForceHandshakeForCrossDomain) {
return handleMaybeHandshakeStatus(
Expand Down
Loading