From cbc83a019b7057ab52a7f409ebcba0ac716b94f0 Mon Sep 17 00:00:00 2001 From: Nikos Douvlis Date: Wed, 18 Mar 2026 15:23:02 +0200 Subject: [PATCH 1/6] feat(clerk-js): Send previous session token on /tokens requests Send the current session JWT as `token` in the POST body when requesting a token refresh. This lets the FAPI Proxy forward it to Session Minter for claim cloning without a DB read. Uses conditional spread so the key is absent (not `token=`) when there's no previous token (first mint). --- .changeset/session-minter-send-token.md | 5 +++++ packages/clerk-js/src/core/resources/Session.ts | 7 ++++++- 2 files changed, 11 insertions(+), 1 deletion(-) create mode 100644 .changeset/session-minter-send-token.md diff --git a/.changeset/session-minter-send-token.md b/.changeset/session-minter-send-token.md new file mode 100644 index 00000000000..0cdd7fbe70d --- /dev/null +++ b/.changeset/session-minter-send-token.md @@ -0,0 +1,5 @@ +--- +'@clerk/clerk-js': patch +--- + +Send previous session token on `/tokens` requests to support Session Minter edge token minting. diff --git a/packages/clerk-js/src/core/resources/Session.ts b/packages/clerk-js/src/core/resources/Session.ts index ea5e796dbb8..45204e08b5c 100644 --- a/packages/clerk-js/src/core/resources/Session.ts +++ b/packages/clerk-js/src/core/resources/Session.ts @@ -480,7 +480,12 @@ export class Session extends BaseResource implements SessionResource { ): Promise { const path = template ? `${this.path()}/tokens/${template}` : `${this.path()}/tokens`; // TODO: update template endpoint to accept organizationId - const params: Record = template ? {} : { organizationId: organizationId ?? null }; + const params: Record = template + ? {} + : { + organizationId: organizationId ?? null, + ...(this.lastActiveToken ? { token: this.lastActiveToken.getRawString() } : {}), + }; const lastActiveToken = this.lastActiveToken?.getRawString(); const tokenResolver = Token.create(path, params, skipCache ? { debug: 'skip_cache' } : undefined).catch(e => { From 388086f99c7ab3efd3373304918e4bb6399c7bd5 Mon Sep 17 00:00:00 2001 From: Nikos Douvlis Date: Wed, 18 Mar 2026 15:56:59 +0200 Subject: [PATCH 2/6] test(clerk-js): Add tests for sending previous token in /tokens body Unit tests verify the token param is present when lastActiveToken exists, absent on first mint, absent for template requests, and matches getRawString() exactly. E2e test verifies token refresh still works with the new param in the POST body. --- integration/tests/resiliency.test.ts | 41 ++++++ .../core/resources/__tests__/Session.test.ts | 120 ++++++++++++++++++ 2 files changed, 161 insertions(+) diff --git a/integration/tests/resiliency.test.ts b/integration/tests/resiliency.test.ts index dbb3dab9ceb..1e90a1d3196 100644 --- a/integration/tests/resiliency.test.ts +++ b/integration/tests/resiliency.test.ts @@ -518,4 +518,45 @@ testAgainstRunningApps({ withEnv: [appConfigs.envs.withEmailCodes] })('resilienc await u.po.clerk.toBeLoaded(); }); }); + + test.describe('token refresh with previous token in body', () => { + test('token refresh includes previous token in POST body and succeeds', async ({ page, context }) => { + const u = createTestUtils({ app, page, context }); + + // Sign in + await u.po.signIn.goTo(); + await u.po.signIn.signInWithEmailAndInstantPassword({ email: fakeUser.email, password: fakeUser.password }); + await u.po.expect.toBeSignedIn(); + + // Track token request bodies + const tokenRequestBodies: string[] = []; + await context.route('**/v1/client/sessions/*/tokens*', async route => { + const postData = route.request().postData(); + if (postData) { + tokenRequestBodies.push(postData); + } + await route.continue(); + }); + + // Force a fresh token fetch (cache miss -> hits /tokens endpoint) + const token = await page.evaluate(async () => { + const clerk = (window as any).Clerk; + await clerk.session?.clearCache(); + return await clerk.session?.getToken({ skipCache: true }); + }); + + // Token refresh should succeed (backend ignores the param for now) + expect(token).toBeTruthy(); + + // Verify token param is present in the POST body (form-urlencoded) + // fapiClient serializes body as form-urlencoded via qs.stringify(camelToSnake(body)) + // so "token" stays "token" (no case change) and the body looks like "organization_id=&token=" + expect(tokenRequestBodies.length).toBeGreaterThanOrEqual(1); + const lastBody = tokenRequestBodies[tokenRequestBodies.length - 1]; + expect(lastBody).toContain('token='); + + // User should still be signed in after refresh + await u.po.expect.toBeSignedIn(); + }); + }); }); 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 4ccae5510e2..1fa05a66012 100644 --- a/packages/clerk-js/src/core/resources/__tests__/Session.test.ts +++ b/packages/clerk-js/src/core/resources/__tests__/Session.test.ts @@ -1522,6 +1522,126 @@ describe('Session', () => { }); }); + describe('sends previous token in /tokens request body', () => { + let dispatchSpy: ReturnType; + let fetchSpy: ReturnType; + + beforeEach(() => { + dispatchSpy = vi.spyOn(eventBus, 'emit'); + fetchSpy = vi.spyOn(BaseResource, '_fetch' as any); + BaseResource.clerk = clerkMock() as any; + }); + + afterEach(() => { + dispatchSpy?.mockRestore(); + fetchSpy?.mockRestore(); + BaseResource.clerk = null as any; + }); + + it('includes token in request body when lastActiveToken exists', async () => { + const session = new Session({ + status: 'active', + id: 'session_1', + object: 'session', + user: createUser({}), + last_active_organization_id: null, + last_active_token: { object: 'token', jwt: mockJwt }, + actor: null, + created_at: new Date().getTime(), + updated_at: new Date().getTime(), + } as SessionJSON); + + SessionTokenCache.clear(); + + fetchSpy.mockResolvedValueOnce({ object: 'token', jwt: mockJwt }); + + await session.getToken(); + + expect(fetchSpy).toHaveBeenCalledTimes(1); + expect(fetchSpy.mock.calls[0][0]).toMatchObject({ + path: '/client/sessions/session_1/tokens', + method: 'POST', + body: { organizationId: null, token: mockJwt }, + }); + }); + + it('does not include token key in request body when lastActiveToken is null (first mint)', async () => { + const session = new Session({ + status: 'active', + id: 'session_1', + object: 'session', + user: createUser({}), + last_active_organization_id: null, + actor: null, + created_at: new Date().getTime(), + updated_at: new Date().getTime(), + } as unknown as SessionJSON); + + SessionTokenCache.clear(); + + fetchSpy.mockResolvedValueOnce({ object: 'token', jwt: mockJwt }); + + await session.getToken(); + + expect(fetchSpy).toHaveBeenCalledTimes(1); + expect(fetchSpy.mock.calls[0][0]).toMatchObject({ + path: '/client/sessions/session_1/tokens', + method: 'POST', + body: { organizationId: null }, + }); + expect(fetchSpy.mock.calls[0][0].body).not.toHaveProperty('token'); + }); + + it('does not include token in request body for template token requests', async () => { + const session = new Session({ + status: 'active', + id: 'session_1', + object: 'session', + user: createUser({}), + last_active_organization_id: null, + last_active_token: { object: 'token', jwt: mockJwt }, + actor: null, + created_at: new Date().getTime(), + updated_at: new Date().getTime(), + } as SessionJSON); + + SessionTokenCache.clear(); + + fetchSpy.mockResolvedValueOnce({ object: 'token', jwt: mockJwt }); + + await session.getToken({ template: 'my-template' }); + + expect(fetchSpy).toHaveBeenCalledTimes(1); + expect(fetchSpy.mock.calls[0][0]).toMatchObject({ + path: '/client/sessions/session_1/tokens/my-template', + method: 'POST', + }); + expect(fetchSpy.mock.calls[0][0].body).toEqual({}); + }); + + it('token value matches lastActiveToken.getRawString() exactly', async () => { + const session = new Session({ + status: 'active', + id: 'session_1', + object: 'session', + user: createUser({}), + last_active_organization_id: null, + last_active_token: { object: 'token', jwt: mockJwt }, + actor: null, + created_at: new Date().getTime(), + updated_at: new Date().getTime(), + } as SessionJSON); + + SessionTokenCache.clear(); + + fetchSpy.mockResolvedValueOnce({ object: 'token', jwt: mockJwt }); + + await session.getToken(); + + expect(fetchSpy.mock.calls[0][0].body.token).toBe(mockJwt); + }); + }); + describe('origin outage mode fallback', () => { let dispatchSpy: ReturnType; let fetchSpy: ReturnType; From 66d9de6d0638828d53ebc85f7dc8c2beadedac26 Mon Sep 17 00:00:00 2001 From: Nikos Douvlis Date: Thu, 19 Mar 2026 13:27:51 +0200 Subject: [PATCH 3/6] feat(clerk-js): Gate previous token in /tokens body behind session_minter flag Only send the previous session JWT in the POST body when the environment has session_minter enabled. This lets us roll out Session Minter incrementally via the environment config. --- packages/clerk-js/src/core/resources/Environment.ts | 3 +++ packages/clerk-js/src/core/resources/Session.ts | 3 ++- packages/shared/src/types/environment.ts | 1 + packages/shared/src/types/json.ts | 1 + 4 files changed, 7 insertions(+), 1 deletion(-) diff --git a/packages/clerk-js/src/core/resources/Environment.ts b/packages/clerk-js/src/core/resources/Environment.ts index e1bc3dff3c9..b42e835eda1 100644 --- a/packages/clerk-js/src/core/resources/Environment.ts +++ b/packages/clerk-js/src/core/resources/Environment.ts @@ -24,6 +24,7 @@ export class Environment extends BaseResource implements EnvironmentResource { maintenanceMode: boolean = false; clientDebugMode: boolean = false; partitionedCookies: boolean = false; + sessionMinter: boolean = false; pathRoot = '/environment'; userSettings: UserSettingsResource = new UserSettings(); organizationSettings: OrganizationSettingsResource = new OrganizationSettings(); @@ -55,6 +56,7 @@ export class Environment extends BaseResource implements EnvironmentResource { this.maintenanceMode = this.withDefault(data.maintenance_mode, this.maintenanceMode); this.clientDebugMode = this.withDefault(data.client_debug_mode, this.clientDebugMode); this.partitionedCookies = this.withDefault(data.partitioned_cookies, this.partitionedCookies); + this.sessionMinter = this.withDefault(data.session_minter, this.sessionMinter); this.organizationSettings = new OrganizationSettings(data.organization_settings); this.userSettings = new UserSettings(data.user_settings); this.commerceSettings = new CommerceSettings(data.commerce_settings); @@ -98,6 +100,7 @@ export class Environment extends BaseResource implements EnvironmentResource { maintenance_mode: this.maintenanceMode, client_debug_mode: this.clientDebugMode, partitioned_cookies: this.partitionedCookies, + session_minter: this.sessionMinter, organization_settings: this.organizationSettings.__internal_toSnapshot(), user_settings: this.userSettings.__internal_toSnapshot(), commerce_settings: this.commerceSettings.__internal_toSnapshot(), diff --git a/packages/clerk-js/src/core/resources/Session.ts b/packages/clerk-js/src/core/resources/Session.ts index 45204e08b5c..b87a217ee14 100644 --- a/packages/clerk-js/src/core/resources/Session.ts +++ b/packages/clerk-js/src/core/resources/Session.ts @@ -480,11 +480,12 @@ export class Session extends BaseResource implements SessionResource { ): Promise { const path = template ? `${this.path()}/tokens/${template}` : `${this.path()}/tokens`; // TODO: update template endpoint to accept organizationId + const sessionMinterEnabled = Session.clerk?.__internal_environment?.sessionMinter; const params: Record = template ? {} : { organizationId: organizationId ?? null, - ...(this.lastActiveToken ? { token: this.lastActiveToken.getRawString() } : {}), + ...(sessionMinterEnabled && this.lastActiveToken ? { token: this.lastActiveToken.getRawString() } : {}), }; const lastActiveToken = this.lastActiveToken?.getRawString(); diff --git a/packages/shared/src/types/environment.ts b/packages/shared/src/types/environment.ts index 4374f2d347f..1d85f40ac01 100644 --- a/packages/shared/src/types/environment.ts +++ b/packages/shared/src/types/environment.ts @@ -24,6 +24,7 @@ export interface EnvironmentResource extends ClerkResource { maintenanceMode: boolean; clientDebugMode: boolean; partitionedCookies: boolean; + sessionMinter: boolean; __internal_toSnapshot: () => EnvironmentJSONSnapshot; __internal_enableEnvironmentSetting: (params: EnableEnvironmentSettingParams) => Promise; } diff --git a/packages/shared/src/types/json.ts b/packages/shared/src/types/json.ts index c9bf7ddb2b6..5cbd4438b2f 100644 --- a/packages/shared/src/types/json.ts +++ b/packages/shared/src/types/json.ts @@ -90,6 +90,7 @@ export interface EnvironmentJSON extends ClerkResourceJSON { maintenance_mode: boolean; organization_settings: OrganizationSettingsJSON; partitioned_cookies?: boolean; + session_minter?: boolean; user_settings: UserSettingsJSON; protect_config: ProtectConfigJSON; } From 2b6ae786ad57ebc8f89a6322637fe3db92bcb8dd Mon Sep 17 00:00:00 2001 From: Nikos Douvlis Date: Thu, 19 Mar 2026 13:38:34 +0200 Subject: [PATCH 4/6] refactor(clerk-js): Move session_minter flag from Environment to AuthConfig AuthConfig is the right home for this since it controls auth behavior, not environment-level display/maintenance settings. --- packages/clerk-js/src/core/resources/AuthConfig.ts | 3 +++ packages/clerk-js/src/core/resources/Environment.ts | 3 --- packages/clerk-js/src/core/resources/Session.ts | 2 +- packages/shared/src/types/authConfig.ts | 1 + packages/shared/src/types/environment.ts | 1 - packages/shared/src/types/json.ts | 2 +- 6 files changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/clerk-js/src/core/resources/AuthConfig.ts b/packages/clerk-js/src/core/resources/AuthConfig.ts index b95dfaf5ba2..3bfc61dbacf 100644 --- a/packages/clerk-js/src/core/resources/AuthConfig.ts +++ b/packages/clerk-js/src/core/resources/AuthConfig.ts @@ -8,6 +8,7 @@ export class AuthConfig extends BaseResource implements AuthConfigResource { reverification: boolean = false; singleSessionMode: boolean = false; preferredChannels: Record | null = null; + sessionMinter: boolean = false; public constructor(data: Partial | null = null) { super(); @@ -23,6 +24,7 @@ export class AuthConfig extends BaseResource implements AuthConfigResource { this.reverification = this.withDefault(data.reverification, this.reverification); this.singleSessionMode = this.withDefault(data.single_session_mode, this.singleSessionMode); this.preferredChannels = this.withDefault(data.preferred_channels, this.preferredChannels); + this.sessionMinter = this.withDefault(data.session_minter, this.sessionMinter); return this; } @@ -33,6 +35,7 @@ export class AuthConfig extends BaseResource implements AuthConfigResource { object: 'auth_config', reverification: this.reverification, single_session_mode: this.singleSessionMode, + session_minter: this.sessionMinter, }; } } diff --git a/packages/clerk-js/src/core/resources/Environment.ts b/packages/clerk-js/src/core/resources/Environment.ts index b42e835eda1..e1bc3dff3c9 100644 --- a/packages/clerk-js/src/core/resources/Environment.ts +++ b/packages/clerk-js/src/core/resources/Environment.ts @@ -24,7 +24,6 @@ export class Environment extends BaseResource implements EnvironmentResource { maintenanceMode: boolean = false; clientDebugMode: boolean = false; partitionedCookies: boolean = false; - sessionMinter: boolean = false; pathRoot = '/environment'; userSettings: UserSettingsResource = new UserSettings(); organizationSettings: OrganizationSettingsResource = new OrganizationSettings(); @@ -56,7 +55,6 @@ export class Environment extends BaseResource implements EnvironmentResource { this.maintenanceMode = this.withDefault(data.maintenance_mode, this.maintenanceMode); this.clientDebugMode = this.withDefault(data.client_debug_mode, this.clientDebugMode); this.partitionedCookies = this.withDefault(data.partitioned_cookies, this.partitionedCookies); - this.sessionMinter = this.withDefault(data.session_minter, this.sessionMinter); this.organizationSettings = new OrganizationSettings(data.organization_settings); this.userSettings = new UserSettings(data.user_settings); this.commerceSettings = new CommerceSettings(data.commerce_settings); @@ -100,7 +98,6 @@ export class Environment extends BaseResource implements EnvironmentResource { maintenance_mode: this.maintenanceMode, client_debug_mode: this.clientDebugMode, partitioned_cookies: this.partitionedCookies, - session_minter: this.sessionMinter, organization_settings: this.organizationSettings.__internal_toSnapshot(), user_settings: this.userSettings.__internal_toSnapshot(), commerce_settings: this.commerceSettings.__internal_toSnapshot(), diff --git a/packages/clerk-js/src/core/resources/Session.ts b/packages/clerk-js/src/core/resources/Session.ts index b87a217ee14..2f1c55eddb4 100644 --- a/packages/clerk-js/src/core/resources/Session.ts +++ b/packages/clerk-js/src/core/resources/Session.ts @@ -480,7 +480,7 @@ export class Session extends BaseResource implements SessionResource { ): Promise { const path = template ? `${this.path()}/tokens/${template}` : `${this.path()}/tokens`; // TODO: update template endpoint to accept organizationId - const sessionMinterEnabled = Session.clerk?.__internal_environment?.sessionMinter; + const sessionMinterEnabled = Session.clerk?.__internal_environment?.authConfig?.sessionMinter; const params: Record = template ? {} : { diff --git a/packages/shared/src/types/authConfig.ts b/packages/shared/src/types/authConfig.ts index 9a1fd5479f0..e0919100434 100644 --- a/packages/shared/src/types/authConfig.ts +++ b/packages/shared/src/types/authConfig.ts @@ -21,5 +21,6 @@ export interface AuthConfigResource extends ClerkResource { * Preferred channels for phone code providers. */ preferredChannels: Record | null; + sessionMinter: boolean; __internal_toSnapshot: () => AuthConfigJSONSnapshot; } diff --git a/packages/shared/src/types/environment.ts b/packages/shared/src/types/environment.ts index 1d85f40ac01..4374f2d347f 100644 --- a/packages/shared/src/types/environment.ts +++ b/packages/shared/src/types/environment.ts @@ -24,7 +24,6 @@ export interface EnvironmentResource extends ClerkResource { maintenanceMode: boolean; clientDebugMode: boolean; partitionedCookies: boolean; - sessionMinter: boolean; __internal_toSnapshot: () => EnvironmentJSONSnapshot; __internal_enableEnvironmentSetting: (params: EnableEnvironmentSettingParams) => Promise; } diff --git a/packages/shared/src/types/json.ts b/packages/shared/src/types/json.ts index 5cbd4438b2f..31cd716f319 100644 --- a/packages/shared/src/types/json.ts +++ b/packages/shared/src/types/json.ts @@ -90,7 +90,6 @@ export interface EnvironmentJSON extends ClerkResourceJSON { maintenance_mode: boolean; organization_settings: OrganizationSettingsJSON; partitioned_cookies?: boolean; - session_minter?: boolean; user_settings: UserSettingsJSON; protect_config: ProtectConfigJSON; } @@ -333,6 +332,7 @@ export interface AuthConfigJSON extends ClerkResourceJSON { claimed_at: number | null; reverification: boolean; preferred_channels?: Record; + session_minter?: boolean; } export interface VerificationJSON extends ClerkResourceJSON { From 469fb9f9d189595a93f479a99990a4871faec46a Mon Sep 17 00:00:00 2001 From: Nikos Douvlis Date: Fri, 20 Mar 2026 01:45:22 +0200 Subject: [PATCH 5/6] fix(clerk-js): Add sessionMinter to test mock so feature gate passes The clerkMock() didn't provide __internal_environment.authConfig.sessionMinter, so the feature gate in #createTokenResolver was always falsy and token was never included in the request body, causing 3 test failures. --- .../clerk-js/src/core/resources/__tests__/Session.test.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) 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 1fa05a66012..386e25a9758 100644 --- a/packages/clerk-js/src/core/resources/__tests__/Session.test.ts +++ b/packages/clerk-js/src/core/resources/__tests__/Session.test.ts @@ -1529,7 +1529,11 @@ describe('Session', () => { beforeEach(() => { dispatchSpy = vi.spyOn(eventBus, 'emit'); fetchSpy = vi.spyOn(BaseResource, '_fetch' as any); - BaseResource.clerk = clerkMock() as any; + BaseResource.clerk = clerkMock({ + __internal_environment: { + authConfig: { sessionMinter: true }, + }, + }) as any; }); afterEach(() => { From ecdb80ac773e0847c4fc8951fb41bf666117f86b Mon Sep 17 00:00:00 2001 From: Nikos Douvlis Date: Fri, 20 Mar 2026 01:49:18 +0200 Subject: [PATCH 6/6] fix(clerk-js): Update AuthConfig snapshot test and bump bundlewatch limit Add session_minter to AuthConfig snapshot assertion. Bump clerk.browser.js bundlewatch limit from 66KB to 67KB to accommodate the new session minter code. --- packages/clerk-js/bundlewatch.config.json | 2 +- .../clerk-js/src/core/resources/__tests__/AuthConfig.test.ts | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/clerk-js/bundlewatch.config.json b/packages/clerk-js/bundlewatch.config.json index a268b1c02dd..45853fa48ba 100644 --- a/packages/clerk-js/bundlewatch.config.json +++ b/packages/clerk-js/bundlewatch.config.json @@ -1,7 +1,7 @@ { "files": [ { "path": "./dist/clerk.js", "maxSize": "540KB" }, - { "path": "./dist/clerk.browser.js", "maxSize": "66KB" }, + { "path": "./dist/clerk.browser.js", "maxSize": "67KB" }, { "path": "./dist/clerk.legacy.browser.js", "maxSize": "108KB" }, { "path": "./dist/clerk.no-rhc.js", "maxSize": "307KB" }, { "path": "./dist/clerk.native.js", "maxSize": "66KB" }, diff --git a/packages/clerk-js/src/core/resources/__tests__/AuthConfig.test.ts b/packages/clerk-js/src/core/resources/__tests__/AuthConfig.test.ts index bfce1d5c021..3d16c8d430b 100644 --- a/packages/clerk-js/src/core/resources/__tests__/AuthConfig.test.ts +++ b/packages/clerk-js/src/core/resources/__tests__/AuthConfig.test.ts @@ -46,6 +46,7 @@ describe('AuthConfig', () => { id: '', reverification: true, single_session_mode: true, + session_minter: false, }); }); });