diff --git a/.changeset/kind-paws-swim.md b/.changeset/kind-paws-swim.md
new file mode 100644
index 00000000000..95025976244
--- /dev/null
+++ b/.changeset/kind-paws-swim.md
@@ -0,0 +1,5 @@
+---
+'@clerk/clerk-js': patch
+---
+
+Detect when a user already has an active session in multi-session app and redirect to /choose subroute
diff --git a/integration/tests/session-tasks-multi-session.test.ts b/integration/tests/session-tasks-multi-session.test.ts
index 7e923f6f957..3235d99fec1 100644
--- a/integration/tests/session-tasks-multi-session.test.ts
+++ b/integration/tests/session-tasks-multi-session.test.ts
@@ -61,6 +61,11 @@ testAgainstRunningApps({ withEnv: [appConfigs.envs.withSessionTasks] })(
// Create second user, to initiate a pending session
// Don't resolve task and switch to active session afterwards
await u.po.signIn.goTo();
+ await u.page.waitForURL(/sign-in\/choose/);
+ await u.page.getByText('Add account').click();
+ await u.page.waitForURL(/sign-in$/);
+ await u.po.signIn.waitForMounted();
+ await u.po.signIn.getIdentifierInput().waitFor({ state: 'visible' });
await u.po.signIn.setIdentifier(user2.email);
await u.po.signIn.continue();
await u.po.signIn.setPassword(user2.password);
@@ -68,6 +73,11 @@ testAgainstRunningApps({ withEnv: [appConfigs.envs.withSessionTasks] })(
// Sign-in again back with active session
await u.po.signIn.goTo();
+ await u.page.waitForURL(/sign-in\/choose/);
+ await u.page.getByText('Add account').click();
+ await u.page.waitForURL(/sign-in$/);
+ await u.po.signIn.waitForMounted();
+ await u.po.signIn.getIdentifierInput().waitFor({ state: 'visible' });
await u.po.signIn.setIdentifier(user1.email);
await u.po.signIn.continue();
await u.po.signIn.setPassword(user1.password);
diff --git a/integration/tests/sign-in-active-session-redirect.test.ts b/integration/tests/sign-in-active-session-redirect.test.ts
new file mode 100644
index 00000000000..f65a7853a56
--- /dev/null
+++ b/integration/tests/sign-in-active-session-redirect.test.ts
@@ -0,0 +1,94 @@
+import { test } from '@playwright/test';
+
+import { appConfigs } from '../presets';
+import type { FakeUser } from '../testUtils';
+import { createTestUtils, testAgainstRunningApps } from '../testUtils';
+
+testAgainstRunningApps({ withEnv: [appConfigs.envs.withEmailCodes] })(
+ 'sign in redirect with active session @generic @nextjs',
+ ({ app }) => {
+ test.describe.configure({ mode: 'serial' });
+
+ let fakeUser: FakeUser;
+ let fakeUser2: FakeUser;
+
+ test.beforeAll(async () => {
+ const u = createTestUtils({ app });
+ fakeUser = u.services.users.createFakeUser({
+ withPhoneNumber: true,
+ withUsername: true,
+ });
+ await u.services.users.createBapiUser(fakeUser);
+
+ fakeUser2 = u.services.users.createFakeUser({
+ withPhoneNumber: true,
+ withUsername: true,
+ });
+ await u.services.users.createBapiUser(fakeUser2);
+ });
+
+ test.afterAll(async () => {
+ await fakeUser.deleteIfExists();
+ await fakeUser2.deleteIfExists();
+ });
+
+ test('redirects to /sign-in/choose when visiting /sign-in with active session in multi-session mode', async ({
+ page,
+ context,
+ }) => {
+ const u = createTestUtils({ app, page, context });
+
+ await u.po.signIn.goTo();
+ await u.po.signIn.signInWithEmailAndInstantPassword({ email: fakeUser.email, password: fakeUser.password });
+ await u.po.expect.toBeSignedIn();
+
+ await u.page.goToAppHome();
+ await u.po.signIn.goTo();
+ await u.page.waitForURL(/sign-in\/choose/);
+ await u.page.waitForSelector('text=Choose an account');
+ });
+
+ test('shows active session in account switcher with option to add account', async ({ page, context }) => {
+ const u = createTestUtils({ app, page, context });
+
+ await u.po.signIn.goTo();
+ await u.po.signIn.signInWithEmailAndInstantPassword({ email: fakeUser.email, password: fakeUser.password });
+ await u.po.expect.toBeSignedIn();
+
+ await u.po.signIn.goTo();
+ await u.page.waitForURL(/sign-in\/choose/);
+ await u.po.signIn.waitForMounted();
+ await u.page.getByText('Add account').waitFor({ state: 'visible' });
+ await u.page.getByText('Choose an account').waitFor({ state: 'visible' });
+ });
+
+ test('shows sign-in form when no active sessions exist', async ({ page, context }) => {
+ const u = createTestUtils({ app, page, context });
+
+ await u.page.context().clearCookies();
+ await u.po.signIn.goTo();
+ await u.po.signIn.waitForMounted();
+ await u.page.waitForSelector('text=/email address|username|phone/i');
+ await u.page.waitForURL(/sign-in$/);
+ });
+
+ test('can sign in to second account after clicking "Add account" from /choose', async ({ page, context }) => {
+ const u = createTestUtils({ app, page, context });
+
+ await u.po.signIn.goTo();
+ await u.po.signIn.signInWithEmailAndInstantPassword({ email: fakeUser.email, password: fakeUser.password });
+ await u.po.expect.toBeSignedIn();
+
+ await u.po.signIn.goTo();
+ await u.page.waitForURL(/sign-in\/choose/);
+ await u.po.signIn.waitForMounted();
+
+ await u.page.getByText('Add account').click();
+ await u.page.waitForURL(/sign-in$/);
+ await u.po.signIn.waitForMounted();
+
+ await u.po.signIn.signInWithEmailAndInstantPassword({ email: fakeUser2.email, password: fakeUser2.password });
+ await u.po.expect.toBeSignedIn();
+ });
+ },
+);
diff --git a/integration/tests/sign-in-single-session-mode.test.ts b/integration/tests/sign-in-single-session-mode.test.ts
new file mode 100644
index 00000000000..821027d021d
--- /dev/null
+++ b/integration/tests/sign-in-single-session-mode.test.ts
@@ -0,0 +1,79 @@
+import type { FakeUser } from '@clerk/testing/playwright';
+import { test } from '@playwright/test';
+
+import type { Application } from '../models/application';
+import { appConfigs } from '../presets';
+import { createTestUtils, testAgainstRunningApps } from '../testUtils';
+
+/**
+ * Tests for single-session mode behavior using the withBilling environment
+ * which is configured for single-session mode in the Clerk Dashboard.
+ */
+testAgainstRunningApps({ withEnv: [appConfigs.envs.withBilling] })(
+ 'sign in with active session in single-session mode @generic @nextjs',
+ ({ app }) => {
+ test.describe.configure({ mode: 'serial' });
+
+ let fakeUser: FakeUser;
+
+ test.beforeAll(async () => {
+ const u = createTestUtils({ app });
+ fakeUser = u.services.users.createFakeUser({
+ withPhoneNumber: true,
+ withUsername: true,
+ });
+ await u.services.users.createBapiUser(fakeUser);
+ });
+
+ test.afterAll(async () => {
+ await fakeUser.deleteIfExists();
+ });
+
+ test('redirects to afterSignIn URL when visiting /sign-in with active session in single-session mode', async ({
+ page,
+ context,
+ }) => {
+ const u = createTestUtils({ app, page, context });
+
+ await u.po.signIn.goTo();
+ await u.po.signIn.signInWithEmailAndInstantPassword({ email: fakeUser.email, password: fakeUser.password });
+ await u.po.expect.toBeSignedIn();
+ await u.page.waitForAppUrl('/');
+
+ await u.po.signIn.goTo();
+ await u.page.waitForAppUrl('/');
+ await u.po.expect.toBeSignedIn();
+ });
+
+ test('does NOT show account switcher in single-session mode', async ({ page, context }) => {
+ const u = createTestUtils({ app, page, context });
+
+ await u.po.signIn.goTo();
+ await u.po.signIn.signInWithEmailAndInstantPassword({ email: fakeUser.email, password: fakeUser.password });
+ await u.po.expect.toBeSignedIn();
+
+ await u.page.goToRelative('/sign-in/choose');
+ await u.page.waitForAppUrl('/');
+ });
+
+ test('shows sign-in form when no active session in single-session mode', async ({ page, context }) => {
+ const u = createTestUtils({ app, page, context });
+
+ await u.page.context().clearCookies();
+ await u.po.signIn.goTo();
+ await u.po.signIn.waitForMounted();
+ await u.page.waitForSelector('text=/email address|username|phone/i');
+ await u.page.waitForURL(/sign-in$/);
+ });
+
+ test('can sign in normally when not already authenticated in single-session mode', async ({ page, context }) => {
+ const u = createTestUtils({ app, page, context });
+
+ await u.page.context().clearCookies();
+ await u.po.signIn.goTo();
+ await u.po.signIn.signInWithEmailAndInstantPassword({ email: fakeUser.email, password: fakeUser.password });
+ await u.po.expect.toBeSignedIn();
+ await u.page.waitForAppUrl('/');
+ });
+ },
+);
diff --git a/packages/clerk-js/src/ui/components/SignIn/SignInStart.tsx b/packages/clerk-js/src/ui/components/SignIn/SignInStart.tsx
index 6b0dd0cc8a9..69f574829b1 100644
--- a/packages/clerk-js/src/ui/components/SignIn/SignInStart.tsx
+++ b/packages/clerk-js/src/ui/components/SignIn/SignInStart.tsx
@@ -109,6 +109,7 @@ function SignInStartInternal(): JSX.Element {
shouldStartWithPhoneNumberIdentifier ? 'phone_number' : identifierAttributes[0] || '',
);
const [hasSwitchedByAutofill, setHasSwitchedByAutofill] = useState(false);
+ const hasInitializedRef = useRef(false);
const organizationTicket = getClerkQueryParam('__clerk_ticket') || '';
const clerkStatus = getClerkQueryParam('__clerk_status') || '';
@@ -181,6 +182,35 @@ function SignInStartInternal(): JSX.Element {
setShouldAutofocus(true);
};
+ /**
+ * Redirect to account switcher if user already has active sessions in multi-session mode
+ */
+ useEffect(() => {
+ if (organizationTicket || hasInitializedRef.current) {
+ return;
+ }
+
+ hasInitializedRef.current = true;
+
+ const urlParams = new URLSearchParams(window.location.search);
+ const isAddingAccount = urlParams.has('__clerk_add_account');
+
+ if (isAddingAccount) {
+ urlParams.delete('__clerk_add_account');
+ const newSearch = urlParams.toString();
+ const newUrl = window.location.pathname + (newSearch ? `?${newSearch}` : '');
+ window.history.replaceState({}, '', newUrl);
+ return;
+ }
+
+ const hasActiveSessions = (clerk.client?.signedInSessions?.length ?? 0) > 0;
+ const isMultiSessionMode = !authConfig.singleSessionMode;
+
+ if (hasActiveSessions && isMultiSessionMode) {
+ void navigate('choose');
+ }
+ }, [clerk.client?.signedInSessions, authConfig.singleSessionMode, navigate, organizationTicket]);
+
// switch to the phone input (if available) if a "+" is entered
// (either by the browser or the user)
// this does not work in chrome as it does not fire the change event and the value is
diff --git a/packages/clerk-js/src/ui/components/SignIn/__tests__/SignInStart.test.tsx b/packages/clerk-js/src/ui/components/SignIn/__tests__/SignInStart.test.tsx
index 5d6aad7857f..dc438b5f1d9 100644
--- a/packages/clerk-js/src/ui/components/SignIn/__tests__/SignInStart.test.tsx
+++ b/packages/clerk-js/src/ui/components/SignIn/__tests__/SignInStart.test.tsx
@@ -590,4 +590,114 @@ describe('SignInStart', () => {
);
});
});
+
+ describe('Active session redirect', () => {
+ describe('multi-session mode', () => {
+ it('redirects to /choose when user has active sessions', async () => {
+ const { wrapper, fixtures } = await createFixtures(f => {
+ f.withEmailAddress();
+ f.withMultiSessionMode();
+ f.withUser({
+ email_addresses: ['user@clerk.com'],
+ });
+ });
+
+ // Mock active sessions using spyOn
+ vi.spyOn(fixtures.clerk.client, 'signedInSessions', 'get').mockReturnValue([
+ {
+ id: 'sess_123',
+ user: fixtures.clerk.user,
+ status: 'active',
+ } as any,
+ ]);
+
+ render(, { wrapper });
+
+ await waitFor(() => {
+ expect(fixtures.router.navigate).toHaveBeenCalledWith('choose');
+ });
+ });
+
+ it('redirects to /choose when user has multiple active sessions', async () => {
+ const { wrapper, fixtures } = await createFixtures(f => {
+ f.withEmailAddress();
+ f.withMultiSessionMode();
+ f.withUser({
+ email_addresses: ['user@clerk.com'],
+ });
+ });
+
+ // Mock multiple active sessions using spyOn
+ vi.spyOn(fixtures.clerk.client, 'signedInSessions', 'get').mockReturnValue([
+ {
+ id: 'sess_123',
+ user: fixtures.clerk.user,
+ status: 'active',
+ } as any,
+ {
+ id: 'sess_456',
+ user: { id: 'user_456' },
+ status: 'active',
+ } as any,
+ ]);
+
+ render(, { wrapper });
+
+ await waitFor(() => {
+ expect(fixtures.router.navigate).toHaveBeenCalledWith('choose');
+ });
+ });
+
+ it('does NOT redirect when user has no active sessions', async () => {
+ const { wrapper, fixtures } = await createFixtures(f => {
+ f.withEmailAddress();
+ f.withMultiSessionMode();
+ });
+
+ // No active sessions using spyOn
+ vi.spyOn(fixtures.clerk.client, 'signedInSessions', 'get').mockReturnValue([]);
+
+ render(, { wrapper });
+
+ await waitFor(
+ () => {
+ expect(fixtures.router.navigate).not.toHaveBeenCalledWith('choose');
+ },
+ { timeout: 100 },
+ );
+
+ screen.getByText(/email address/i);
+ });
+ });
+
+ describe('single-session mode', () => {
+ it('does NOT redirect to /choose when user has active session in single-session mode', async () => {
+ const { wrapper, fixtures } = await createFixtures(f => {
+ f.withEmailAddress();
+ f.withUser({
+ email_addresses: ['user@clerk.com'],
+ });
+ });
+
+ vi.spyOn(fixtures.clerk.client, 'signedInSessions', 'get').mockReturnValue([
+ {
+ id: 'sess_123',
+ user: fixtures.clerk.user,
+ status: 'active',
+ } as any,
+ ]);
+
+ fixtures.environment.authConfig.singleSessionMode = true;
+
+ render(, { wrapper });
+
+ await waitFor(
+ () => {
+ expect(fixtures.router.navigate).not.toHaveBeenCalledWith('choose');
+ },
+ { timeout: 100 },
+ );
+ });
+ });
+ });
});
diff --git a/packages/clerk-js/src/ui/components/UserButton/useMultisessionActions.tsx b/packages/clerk-js/src/ui/components/UserButton/useMultisessionActions.tsx
index dd3d37e9c1f..af63b10f6cd 100644
--- a/packages/clerk-js/src/ui/components/UserButton/useMultisessionActions.tsx
+++ b/packages/clerk-js/src/ui/components/UserButton/useMultisessionActions.tsx
@@ -99,7 +99,9 @@ export const useMultisessionActions = (opts: UseMultisessionActionsParams) => {
};
const handleAddAccountClicked = () => {
- windowNavigate(opts.signInUrl || window.location.href);
+ const url = new URL(opts.signInUrl || window.location.href, window.location.origin);
+ url.searchParams.set('__clerk_add_account', '1');
+ windowNavigate(url.toString());
return sleep(2000);
};