diff --git a/.changeset/tame-snakes-move.md b/.changeset/tame-snakes-move.md
new file mode 100644
index 00000000000..a845151cc84
--- /dev/null
+++ b/.changeset/tame-snakes-move.md
@@ -0,0 +1,2 @@
+---
+---
diff --git a/integration/presets/longRunningApps.ts b/integration/presets/longRunningApps.ts
index 876c3eaa9ca..0d2352b3e11 100644
--- a/integration/presets/longRunningApps.ts
+++ b/integration/presets/longRunningApps.ts
@@ -72,6 +72,8 @@ export const createLongRunningApps = () => {
* Tanstack apps - basic flows
*/
{ id: 'tanstack.react-start', config: tanstack.reactStart, env: envs.withEmailCodes },
+ { id: 'tanstack.react-start.withCustomRoles', config: tanstack.reactStart, env: envs.withCustomRoles },
+ { id: 'tanstack.react-start.withEmailCodesProxy', config: tanstack.reactStart, env: envs.withEmailCodesProxy },
/**
* Various apps - basic flows
diff --git a/integration/templates/tanstack-react-start/src/routes/index.tsx b/integration/templates/tanstack-react-start/src/routes/index.tsx
index a179e88ed71..758cbb3b954 100644
--- a/integration/templates/tanstack-react-start/src/routes/index.tsx
+++ b/integration/templates/tanstack-react-start/src/routes/index.tsx
@@ -1,4 +1,4 @@
-import { Show, SignIn, SignOutButton, UserButton } from '@clerk/tanstack-react-start';
+import { OrganizationSwitcher, Show, SignIn, SignOutButton, UserButton } from '@clerk/tanstack-react-start';
import { createFileRoute } from '@tanstack/react-router';
export const Route = createFileRoute('/')({
@@ -15,6 +15,9 @@ function Home() {
View your profile here
+
+
+
diff --git a/integration/templates/tanstack-react-start/src/routes/me.tsx b/integration/templates/tanstack-react-start/src/routes/me.tsx
new file mode 100644
index 00000000000..57fcfd375df
--- /dev/null
+++ b/integration/templates/tanstack-react-start/src/routes/me.tsx
@@ -0,0 +1,36 @@
+import { createFileRoute } from '@tanstack/react-router';
+import { createServerFn } from '@tanstack/react-start';
+import { auth } from '@clerk/tanstack-react-start/server';
+
+const fetchAuthData = createServerFn({ method: 'GET' }).handler(async () => {
+ const { userId, sessionId, orgId, orgRole, orgSlug } = await auth();
+ return { userId, sessionId, orgId, orgRole, orgSlug };
+});
+
+export const Route = createFileRoute('/me')({
+ component: MePage,
+ beforeLoad: async () => await fetchAuthData(),
+ loader: async ({ context }) => {
+ return {
+ userId: context.userId,
+ sessionId: context.sessionId,
+ orgId: context.orgId,
+ orgRole: context.orgRole,
+ orgSlug: context.orgSlug,
+ };
+ },
+});
+
+function MePage() {
+ const state = Route.useLoaderData();
+
+ return (
+
+
{state.userId ?? ''}
+
{state.sessionId ?? ''}
+
{state.orgId ?? ''}
+
{state.orgRole ?? ''}
+
{state.orgSlug ?? ''}
+
+ );
+}
diff --git a/integration/tests/tanstack-start/error-handling.test.ts b/integration/tests/tanstack-start/error-handling.test.ts
new file mode 100644
index 00000000000..1d3a6c3efaa
--- /dev/null
+++ b/integration/tests/tanstack-start/error-handling.test.ts
@@ -0,0 +1,35 @@
+import { expect, test } from '@playwright/test';
+
+import { appConfigs } from '../../presets';
+import { testAgainstRunningApps } from '../../testUtils';
+
+testAgainstRunningApps({ withEnv: [appConfigs.envs.withEmailCodes] })(
+ 'error handling tests for @tanstack-react-start',
+ ({ app }) => {
+ test.describe.configure({ mode: 'parallel' });
+
+ test('request with invalid Authorization header is handled gracefully', async () => {
+ const url = new URL('/me', app.serverUrl);
+ const res = await fetch(url.toString(), {
+ headers: {
+ Authorization: 'Bearer invalid_token_here',
+ },
+ });
+
+ // Clerk middleware treats an invalid bearer token as unauthenticated (not a crash)
+ expect(res.status).toBe(200);
+ });
+
+ test('request with malformed cookie is handled gracefully', async () => {
+ const url = new URL('/me', app.serverUrl);
+ const res = await fetch(url.toString(), {
+ headers: {
+ Cookie: '__session=malformed_jwt_value; __client_uat=0',
+ },
+ });
+
+ // Clerk middleware handles malformed cookies gracefully, treating the request as unauthenticated
+ expect(res.status).toBe(200);
+ });
+ },
+);
diff --git a/integration/tests/tanstack-start/organizations.test.ts b/integration/tests/tanstack-start/organizations.test.ts
new file mode 100644
index 00000000000..c18e50ac0b9
--- /dev/null
+++ b/integration/tests/tanstack-start/organizations.test.ts
@@ -0,0 +1,117 @@
+import type { OrganizationMembershipRole } from '@clerk/backend';
+import { expect, test } from '@playwright/test';
+
+import { appConfigs } from '../../presets';
+import type { FakeOrganization, FakeUser } from '../../testUtils';
+import { createTestUtils, testAgainstRunningApps } from '../../testUtils';
+
+testAgainstRunningApps({ withEnv: [appConfigs.envs.withCustomRoles] })(
+ 'organization auth tests for @tanstack-react-start',
+ ({ app }) => {
+ test.describe.configure({ mode: 'serial' });
+
+ let fakeAdmin: FakeUser;
+ let fakeViewer: FakeUser;
+ let fakeNonMember: FakeUser;
+ let fakeOrganization: FakeOrganization;
+
+ test.beforeAll(async () => {
+ const m = createTestUtils({ app });
+ fakeAdmin = m.services.users.createFakeUser();
+ const admin = await m.services.users.createBapiUser(fakeAdmin);
+ fakeOrganization = await m.services.users.createFakeOrganization(admin.id);
+ fakeViewer = m.services.users.createFakeUser();
+ const viewer = await m.services.users.createBapiUser(fakeViewer);
+ await m.services.clerk.organizations.createOrganizationMembership({
+ organizationId: fakeOrganization.organization.id,
+ role: 'org:viewer' as OrganizationMembershipRole,
+ userId: viewer.id,
+ });
+ fakeNonMember = m.services.users.createFakeUser();
+ await m.services.users.createBapiUser(fakeNonMember);
+ });
+
+ test.afterAll(async () => {
+ await fakeOrganization.delete();
+ await fakeNonMember.deleteIfExists();
+ await fakeViewer.deleteIfExists();
+ await fakeAdmin.deleteIfExists();
+ await app.teardown();
+ });
+
+ test('admin auth object includes orgId, orgRole, orgSlug after selecting org', async ({ page, context }) => {
+ const u = createTestUtils({ app, page, context });
+ await u.page.goToRelative('/');
+
+ await u.po.signIn.waitForMounted();
+ await u.po.signIn.signInWithEmailAndInstantPassword({
+ email: fakeAdmin.email,
+ password: fakeAdmin.password,
+ });
+
+ await u.po.userButton.waitForMounted();
+
+ await u.po.organizationSwitcher.waitForMounted();
+ await u.po.organizationSwitcher.waitForAnOrganizationToSelected();
+
+ await u.page.goToRelative('/me');
+
+ const userId = await u.page.getByTestId('userId').textContent();
+ const orgId = await u.page.getByTestId('orgId').textContent();
+ const orgRole = await u.page.getByTestId('orgRole').textContent();
+ const orgSlug = await u.page.getByTestId('orgSlug').textContent();
+
+ expect(userId).toBeTruthy();
+ expect(orgId).toBe(fakeOrganization.organization.id);
+ expect(orgRole).toBe('org:admin');
+ expect(orgSlug).toBeTruthy();
+ });
+
+ test('non-member auth object has null orgId', async ({ page, context }) => {
+ const u = createTestUtils({ app, page, context });
+ await u.page.goToRelative('/');
+
+ await u.po.signIn.waitForMounted();
+ await u.po.signIn.signInWithEmailAndInstantPassword({
+ email: fakeNonMember.email,
+ password: fakeNonMember.password,
+ });
+
+ await u.po.userButton.waitForMounted();
+
+ await u.page.goToRelative('/me');
+
+ const userId = await u.page.getByTestId('userId').textContent();
+ const orgId = await u.page.getByTestId('orgId').textContent();
+
+ expect(userId).toBeTruthy();
+ expect(orgId).toBe('');
+ });
+
+ test('viewer org role is correctly reflected in auth response', async ({ page, context }) => {
+ const u = createTestUtils({ app, page, context });
+ await u.page.goToRelative('/');
+
+ await u.po.signIn.waitForMounted();
+ await u.po.signIn.signInWithEmailAndInstantPassword({
+ email: fakeViewer.email,
+ password: fakeViewer.password,
+ });
+
+ await u.po.userButton.waitForMounted();
+
+ await u.po.organizationSwitcher.waitForMounted();
+ await u.po.organizationSwitcher.waitForAnOrganizationToSelected();
+
+ await u.page.goToRelative('/me');
+
+ const userId = await u.page.getByTestId('userId').textContent();
+ const orgId = await u.page.getByTestId('orgId').textContent();
+ const orgRole = await u.page.getByTestId('orgRole').textContent();
+
+ expect(userId).toBeTruthy();
+ expect(orgId).toBe(fakeOrganization.organization.id);
+ expect(orgRole).toBe('org:viewer');
+ });
+ },
+);
diff --git a/integration/tests/tanstack-start/proxy.test.ts b/integration/tests/tanstack-start/proxy.test.ts
new file mode 100644
index 00000000000..2b2134c91dc
--- /dev/null
+++ b/integration/tests/tanstack-start/proxy.test.ts
@@ -0,0 +1,74 @@
+import { expect, test } from '@playwright/test';
+
+import { appConfigs } from '../../presets';
+import type { FakeUser } from '../../testUtils';
+import { createTestUtils, testAgainstRunningApps } from '../../testUtils';
+
+testAgainstRunningApps({ withEnv: [appConfigs.envs.withEmailCodesProxy] })(
+ 'frontend API proxy tests for @tanstack-react-start',
+ ({ app }) => {
+ test.describe.configure({ mode: 'parallel' });
+
+ let fakeUser: FakeUser;
+
+ test.beforeAll(async () => {
+ const u = createTestUtils({ app });
+ fakeUser = u.services.users.createFakeUser();
+ await u.services.users.createBapiUser(fakeUser);
+ });
+
+ test.afterAll(async () => {
+ await fakeUser.deleteIfExists();
+ await app.teardown();
+ });
+
+ test('handshake redirect uses forwarded headers for proxyUrl, not localhost', async () => {
+ // This test proves that the SDK must derive proxyUrl from x-forwarded-* headers.
+ // When a reverse proxy sits in front of the app, the raw request URL is localhost,
+ // but the handshake redirect must point to the public origin.
+ //
+ // We simulate a behind-proxy scenario by sending x-forwarded-proto and x-forwarded-host
+ // headers, with a __client_uat cookie (non-zero) but no session cookie, which forces
+ // a handshake. The handshake redirect Location should use the forwarded origin.
+ const url = new URL('/me', app.serverUrl);
+ const res = await fetch(url.toString(), {
+ headers: {
+ 'x-forwarded-proto': 'https',
+ 'x-forwarded-host': 'myapp.example.com',
+ 'sec-fetch-dest': 'document',
+ Accept: 'text/html',
+ Cookie: '__clerk_db_jwt=needstobeset; __client_uat=1',
+ },
+ redirect: 'manual',
+ });
+
+ // The server should respond with a 307 handshake redirect
+ expect(res.status).toBe(307);
+ const location = res.headers.get('location') ?? '';
+ // The redirect must point to the public origin (from forwarded headers),
+ // NOT to http://localhost:PORT. If the SDK uses requestUrl.origin instead
+ // of forwarded headers, this assertion will fail.
+ const decoded = decodeURIComponent(location);
+ expect(decoded).toContain('https://myapp.example.com');
+ expect(decoded).not.toContain('localhost');
+ });
+
+ test('auth works correctly with proxy enabled', async ({ page, context }) => {
+ const u = createTestUtils({ app, page, context });
+ await u.page.goToRelative('/');
+
+ await u.po.signIn.waitForMounted();
+ await u.po.signIn.signInWithEmailAndInstantPassword({
+ email: fakeUser.email,
+ password: fakeUser.password,
+ });
+
+ await u.po.userButton.waitForMounted();
+
+ await u.page.goToRelative('/me');
+
+ const userId = await u.page.getByTestId('userId').textContent();
+ expect(userId).toBeTruthy();
+ });
+ },
+);
diff --git a/package.json b/package.json
index 920a4b4eae3..57229d041af 100644
--- a/package.json
+++ b/package.json
@@ -57,7 +57,7 @@
"test:integration:react-router": "E2E_APP_ID=react-router.* pnpm test:integration:base --grep @react-router",
"test:integration:sessions": "DISABLE_WEB_SECURITY=true E2E_SESSIONS_APP_1_ENV_KEY=sessions-prod-1 E2E_SESSIONS_APP_2_ENV_KEY=sessions-prod-2 E2E_SESSIONS_APP_1_HOST=multiple-apps-e2e.clerk.app pnpm test:integration:base --grep @sessions",
"test:integration:sessions:staging": "DISABLE_WEB_SECURITY=true E2E_SESSIONS_APP_1_ENV_KEY=clerkstage-sessions-prod-1 E2E_SESSIONS_APP_2_ENV_KEY=clerkstage-sessions-prod-2 E2E_SESSIONS_APP_1_HOST=clerkstage-sessions-prod-1-e2e.clerk.app pnpm test:integration:base --grep @sessions",
- "test:integration:tanstack-react-start": "E2E_APP_ID=tanstack.react-start pnpm test:integration:base --grep @tanstack-react-start",
+ "test:integration:tanstack-react-start": "E2E_APP_ID=tanstack.react-start* pnpm test:integration:base --grep @tanstack-react-start",
"test:integration:vue": "E2E_APP_ID=vue.vite pnpm test:integration:base --grep @vue",
"test:typedoc": "pnpm typedoc:generate && cd ./.typedoc && vitest run",
"turbo:clean": "turbo daemon clean",