From e4a7f7ace53b910996ea5333f1277469a5c05cba Mon Sep 17 00:00:00 2001 From: Muhammad Umer Aamir <93091750+umeraamir69@users.noreply.github.com> Date: Fri, 23 Oct 2026 13:22:55 -0400 Subject: [PATCH] Stop inactive sessions from writing the __session cookie. getToken() and hydrateCache were emitting TokenUpdate for every session, so a multi-session tab could authenticate as the wrong user. --- .changeset/gettoken-active-session-cookie.md | 5 +++ .../clerk-js/src/core/resources/Session.ts | 15 +++++-- .../core/resources/__tests__/Session.test.ts | 40 +++++++++++++++---- 3 files changed, 49 insertions(+), 11 deletions(-) create mode 100644 .changeset/gettoken-active-session-cookie.md diff --git a/.changeset/gettoken-active-session-cookie.md b/.changeset/gettoken-active-session-cookie.md new file mode 100644 index 00000000000..36f8d0a2f96 --- /dev/null +++ b/.changeset/gettoken-active-session-cookie.md @@ -0,0 +1,5 @@ +--- +'@clerk/clerk-js': patch +--- + +getToken() on a non-active session no longer writes that session's token into the __session cookie. diff --git a/packages/clerk-js/src/core/resources/Session.ts b/packages/clerk-js/src/core/resources/Session.ts index 0ac226923f8..40641d7037d 100644 --- a/packages/clerk-js/src/core/resources/Session.ts +++ b/packages/clerk-js/src/core/resources/Session.ts @@ -219,8 +219,8 @@ export class Session extends BaseResource implements SessionResource { #hydrateCache = (token: TokenResource | null) => { if (token) { const tokenId = this.#getCacheId(); - // Dispatch tokenUpdate for __session tokens with the session's active organization ID - const shouldDispatchTokenUpdate = true; + // Only the client's active session may write __session. Inactive sessions still cache. + const shouldDispatchTokenUpdate = this.#isActiveClientSession(); SessionTokenCache.set({ tokenId, tokenResolver: Promise.resolve(token), @@ -241,6 +241,12 @@ export class Session extends BaseResource implements SessionResource { return TokenId.build(this.id, template, resolvedOrganizationId); } + #isActiveClientSession(): boolean { + const clerk = BaseResource.clerk; + const activeId = clerk?.session?.id ?? clerk?.client?.lastActiveSessionId; + return Boolean(activeId && this.id === activeId); + } + startVerification = async ({ level }: SessionVerifyCreateParams): Promise => { const json = ( await BaseResource._fetch({ @@ -498,8 +504,9 @@ export class Session extends BaseResource implements SessionResource { const cacheResult = skipCache ? undefined : SessionTokenCache.get({ tokenId }); - // Dispatch tokenUpdate only for __session tokens with the session's active organization ID, and not JWT templates - const shouldDispatchTokenUpdate = !template && organizationId === this.lastActiveOrganizationId; + // Dispatch tokenUpdate only for the client's active session __session token, not JWT templates + const shouldDispatchTokenUpdate = + !template && organizationId === this.lastActiveOrganizationId && this.#isActiveClientSession(); let result: string | null; diff --git a/packages/clerk-js/src/core/resources/__tests__/Session.test.ts b/packages/clerk-js/src/core/resources/__tests__/Session.test.ts index 551ed54c2cc..82ee2d34a8f 100644 --- a/packages/clerk-js/src/core/resources/__tests__/Session.test.ts +++ b/packages/clerk-js/src/core/resources/__tests__/Session.test.ts @@ -84,7 +84,7 @@ describe('Session', () => { beforeEach(() => { dispatchSpy = vi.spyOn(eventBus, 'emit'); - BaseResource.clerk = clerkMock(); + BaseResource.clerk = clerkMock({ session: { id: 'session_1' } } as any); }); afterEach(() => { @@ -201,8 +201,9 @@ describe('Session', () => { it('hydrates token cache from lastActiveToken', async () => { BaseResource.clerk = clerkMock({ + session: { id: 'session_1' }, organization: new Organization({ id: 'activeOrganization' } as OrganizationJSON), - }); + } as any); const session = new Session({ status: 'active', @@ -228,8 +229,9 @@ describe('Session', () => { it('returns same token without API call when Session is reconstructed', async () => { BaseResource.clerk = clerkMock({ + session: { id: 'session_1' }, organization: new Organization({ id: 'activeOrganization' } as OrganizationJSON), - }); + } as any); SessionTokenCache.clear(); @@ -290,8 +292,9 @@ describe('Session', () => { it('dispatches token:update event on getToken with active organization', async () => { BaseResource.clerk = clerkMock({ + session: { id: 'session_1' }, organization: new Organization({ id: 'activeOrganization' } as OrganizationJSON), - }); + } as any); const session = new Session({ status: 'active', @@ -325,8 +328,9 @@ describe('Session', () => { it('does not dispatch token:update if template is provided', async () => { BaseResource.clerk = clerkMock({ + session: { id: 'session_1' }, organization: new Organization({ id: 'activeOrganization' } as OrganizationJSON), - }); + } as any); const session = new Session({ status: 'active', @@ -346,8 +350,9 @@ describe('Session', () => { it('dispatches token:update when provided organization ID matches current active organization', async () => { BaseResource.clerk = clerkMock({ + session: { id: 'session_1' }, organization: new Organization({ id: 'activeOrganization' } as OrganizationJSON), - }); + } as any); const session = new Session({ status: 'active', @@ -384,6 +389,26 @@ describe('Session', () => { expect(dispatchSpy).toHaveBeenCalledTimes(0); }); + it('does not dispatch token:update when getToken is called on a non-active session', async () => { + BaseResource.clerk = clerkMock({ session: { id: 'session_active' } } as any); + + const session = new Session({ + status: 'active', + id: 'session_other', + object: 'session', + user: createUser({}), + last_active_organization_id: null, + actor: null, + created_at: new Date().getTime(), + updated_at: new Date().getTime(), + } as SessionJSON); + + const token = await session.getToken(); + + expect(token).toEqual(mockJwt); + expect(dispatchSpy.mock.calls.filter(call => call[0] === 'token:update')).toHaveLength(0); + }); + describe('with offline browser and network failure', () => { beforeEach(() => { Object.defineProperty(window.navigator, 'onLine', { @@ -622,8 +647,9 @@ describe('Session', () => { it(`uses the current session's lastActiveOrganizationId by default, not clerk.organization.id`, async () => { BaseResource.clerk = clerkMock({ + session: { id: 'session_1' }, organization: new Organization({ id: 'oldActiveOrganization' } as OrganizationJSON), - }); + } as any); const session = new Session({ status: 'active',