Skip to content
5 changes: 5 additions & 0 deletions .changeset/lucky-donkeys-brake.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@clerk/clerk-js': patch
---

Keep the freshest session token when a server response carries an older one. A slow response, or the client payload attached to one, could previously roll `lastActiveToken` back to a stale token, which is the token sent as the previous-token hint on the next token request.
9 changes: 9 additions & 0 deletions .changeset/tender-pugs-shave.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
'@clerk/expo': patch
---

Keep a cached environment when the app starts offline and only the client cache is empty. Previously both resources fell back to placeholder data, so instance settings were lost until the app was restarted with a working network.

Repeated unauthenticated responses now share one native recovery attempt within a few seconds of each other, instead of reading native state and refetching the client for every response.

Fix the `tokenCache` prop documentation: the cache stores the client JWT, not the session token.
16 changes: 15 additions & 1 deletion packages/clerk-js/src/core/resources/Client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { unixEpochToDate } from '../../utils/date';
import { eventBus } from '../events';
import type { FapiResponseJSON } from '../fapiClient';
import { SessionTokenCache } from '../tokenCache';
import { shouldKeepExistingLastActiveToken } from '../tokenFreshness';
import { BaseResource, Session, SignIn, SignUp } from './internal';

export function getClientResourceFromPayload<J>(responseJSON: FapiResponseJSON<J> | null): ClientResource | undefined {
Expand Down Expand Up @@ -141,7 +142,20 @@ export class Client extends BaseResource implements ClientResource {
fromJSON(data: ClientJSON | ClientJSONSnapshot | null): this {
if (data) {
this.id = data.id;
this.sessions = (data.sessions || []).map(s => new Session(s));
// Rebuilt session objects replace the live ones, so a stale piggybacked token must not win.
const previousTokens = new Map(this.sessions.map(session => [session.id, session.lastActiveToken]));
this.sessions = (data.sessions || []).map(s => {
const session = new Session(s);
const previousToken = previousTokens.get(session.id);
if (
previousToken &&
session.lastActiveToken &&
shouldKeepExistingLastActiveToken(previousToken, session.lastActiveToken)
) {
session.lastActiveToken = previousToken;
}
return session;
});

if (data.sign_up && this.signUp instanceof SignUp && this.signUp.id === data.sign_up.id) {
this.signUp.__internal_updateFromJSON(data.sign_up);
Expand Down
27 changes: 6 additions & 21 deletions packages/clerk-js/src/core/resources/Session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ import { clerkInvalidStrategy, clerkMissingWebAuthnPublicKeyOptions } from '../e
import { eventBus, events } from '../events';
import type { FapiResponseJSON } from '../fapiClient';
import { SessionTokenCache } from '../tokenCache';
import { normalizeOrgId, pickFreshestJwt, tokenOrgId, tokenSid } from '../tokenFreshness';
import { shouldKeepExistingLastActiveToken } from '../tokenFreshness';
import { BaseResource, getClientResourceFromPayload, PublicUserData, Token, User } from './internal';
import { SessionVerification } from './SessionVerification';

Expand Down Expand Up @@ -411,7 +411,10 @@ export class Session extends BaseResource implements SessionResource {
this.publicUserData = new PublicUserData(data.public_user_data);
}

this.lastActiveToken = data.last_active_token ? new Token(data.last_active_token) : null;
const incomingLastActiveToken = data.last_active_token ? new Token(data.last_active_token) : null;
if (!incomingLastActiveToken || !shouldKeepExistingLastActiveToken(this.lastActiveToken, incomingLastActiveToken)) {
this.lastActiveToken = incomingLastActiveToken;
}

return this;
}
Expand Down Expand Up @@ -528,30 +531,12 @@ export class Session extends BaseResource implements SessionResource {

eventBus.emit(events.TokenUpdate, { token });

if (token.jwt && !this.#shouldKeepExistingLastActiveToken(token)) {
if (token.jwt && !shouldKeepExistingLastActiveToken(this.lastActiveToken, token)) {
this.lastActiveToken = token;
eventBus.emit(events.SessionTokenResolved, null);
}
}

// Mirrors the cookie guard: only a same session+org lastActiveToken is a comparable
// freshness baseline, so a session or org switch always adopts the incoming token.
// Without this, an org-switch token minted by a stale edge (lower oiat) would lose
// to the previous org's token and pin lastActiveToken to the old org's claims.
#shouldKeepExistingLastActiveToken(incoming: TokenResource): boolean {
const current = this.lastActiveToken;
if (!current?.jwt) {
return false;
}
if (
tokenSid(current) !== tokenSid(incoming) ||
normalizeOrgId(tokenOrgId(current)) !== normalizeOrgId(tokenOrgId(incoming))
) {
return false;
}
return pickFreshestJwt(current, incoming) !== incoming;
}

#fetchToken(
template: string | undefined,
organizationId: string | undefined | null,
Expand Down
79 changes: 78 additions & 1 deletion packages/clerk-js/src/core/resources/__tests__/Session.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import { TokenId } from '@/utils/tokenId';
import { eventBus } from '../../events';
import { createFapiClient } from '../../fapiClient';
import { SessionTokenCache } from '../../tokenCache';
import { BaseResource, Organization, Session } from '../internal';
import { BaseResource, Client, Organization, Session } from '../internal';

const baseFapiClientOptions = {
frontendApi: 'clerk.example.com',
Expand Down Expand Up @@ -2269,6 +2269,7 @@ describe('Session', () => {
afterEach(() => {
dispatchSpy?.mockRestore();
fetchSpy?.mockRestore();
Client.clearInstance();
BaseResource.clerk = null as any;
SessionTokenCache.clear();
});
Expand Down Expand Up @@ -2444,5 +2445,81 @@ describe('Session', () => {
// wins even though a stale edge minted it with a lower oiat.
expect(session.lastActiveToken?.getRawString()).toBe(orgLow);
});

describe('fromJSON', () => {
const tokenJSON = (jwt: string) => ({ object: 'token' as const, id: 'tok_1', jwt });

const touchResponse = (lastActiveToken: ReturnType<typeof tokenJSON> | null) => ({
response: {
status: 'active',
id: 'session_1',
object: 'session',
user: createUser({}),
last_active_organization_id: null,
actor: null,
created_at: Date.now(),
updated_at: Date.now(),
last_active_token: lastActiveToken,
} as unknown as SessionJSON,
});

it('a stale touch response does not regress lastActiveToken', async () => {
const high = createJwtWithOiat(NOW, NOW + 30);
const low = createJwtWithOiat(NOW, NOW);
const session = makeSession({ last_active_token: tokenJSON(high) } as Partial<SessionJSON>);

fetchSpy.mockResolvedValueOnce(touchResponse(tokenJSON(low)) as any);
await session.touch();

expect(session.lastActiveToken?.getRawString()).toBe(high);
});

it('a fresher touch response replaces lastActiveToken', async () => {
const low = createJwtWithOiat(NOW, NOW);
const high = createJwtWithOiat(NOW, NOW + 30);
const session = makeSession({ last_active_token: tokenJSON(low) } as Partial<SessionJSON>);

fetchSpy.mockResolvedValueOnce(touchResponse(tokenJSON(high)) as any);
await session.touch();

expect(session.lastActiveToken?.getRawString()).toBe(high);
});

it('a touch response without a token still clears lastActiveToken', async () => {
const high = createJwtWithOiat(NOW, NOW + 30);
const session = makeSession({ last_active_token: tokenJSON(high) } as Partial<SessionJSON>);

fetchSpy.mockResolvedValueOnce(touchResponse(null) as any);
await session.touch();

expect(session.lastActiveToken).toBeNull();
});

it('a stale piggybacked client payload does not regress the rebuilt session token', () => {
const high = createJwtWithOiat(NOW, NOW + 30);
const low = createJwtWithOiat(NOW, NOW);
const higher = createJwtWithOiat(NOW, NOW + 60);

const clientJSON = (lastActiveToken: ReturnType<typeof tokenJSON> | null) =>
({
object: 'client',
id: 'client_1',
last_active_session_id: 'session_1',
sessions: [touchResponse(lastActiveToken).response],
}) as any;

const client = Client.getOrCreateInstance().fromJSON(clientJSON(tokenJSON(high)));
expect(client.sessions[0]?.lastActiveToken?.getRawString()).toBe(high);

client.fromJSON(clientJSON(tokenJSON(low)));
expect(client.sessions[0]?.lastActiveToken?.getRawString()).toBe(high);

client.fromJSON(clientJSON(tokenJSON(higher)));
expect(client.sessions[0]?.lastActiveToken?.getRawString()).toBe(higher);

client.fromJSON(clientJSON(null));
expect(client.sessions[0]?.lastActiveToken).toBeNull();
});
});
});
});
17 changes: 17 additions & 0 deletions packages/clerk-js/src/core/tokenFreshness.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,3 +72,20 @@ export function tokenOrgId(input: TokenResource | JWT): string {
export function normalizeOrgId(orgId?: string | null): string {
return orgId || '';
}

// Mirrors the cookie guard: only a same session+org lastActiveToken is a comparable
// freshness baseline, so a session or org switch always adopts the incoming token.
// Without this, an org-switch token minted by a stale edge (lower oiat) would lose
// to the previous org's token and pin lastActiveToken to the old org's claims.
export function shouldKeepExistingLastActiveToken(
current: TokenResource | null | undefined,
incoming: TokenResource,
): boolean {
if (!current?.jwt) {
return false;
}
if (tokenSid(current) !== tokenSid(incoming) || tokenOrgId(current) !== tokenOrgId(incoming)) {
return false;
}
return pickFreshestJwt(current, incoming) !== incoming;
}
1 change: 1 addition & 0 deletions packages/expo/src/cache/dummy-data/environment-resource.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ export const DUMMY_CLERK_ENVIRONMENT_RESOURCE = {
single_session_mode: true,
claimed_at: null,
reverification: true,
session_minter: false,
},
display_config: {
object: 'display_config',
Expand Down
18 changes: 16 additions & 2 deletions packages/expo/src/cache/dummy-data/index.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,16 @@
export { DUMMY_CLERK_CLIENT_RESOURCE } from './client-resource';
export { DUMMY_CLERK_ENVIRONMENT_RESOURCE } from './environment-resource';
import { DUMMY_CLERK_CLIENT_RESOURCE } from './client-resource';
import { DUMMY_CLERK_ENVIRONMENT_RESOURCE } from './environment-resource';

export { DUMMY_CLERK_CLIENT_RESOURCE, DUMMY_CLERK_ENVIRONMENT_RESOURCE };

export function isDummyClient(client: { id?: string | null } | null | undefined): boolean {
return client?.id === DUMMY_CLERK_CLIENT_RESOURCE.id;
}

// The dummy environment's own id is empty, so its display_config id is the reliable marker.
export function isDummyEnvironment(
environment: { display_config?: { id?: string } | null; displayConfig?: { id?: string } | null } | null | undefined,
): boolean {
const id = environment?.display_config?.id ?? environment?.displayConfig?.id;
return id === DUMMY_CLERK_ENVIRONMENT_RESOURCE.display_config.id;
}
7 changes: 6 additions & 1 deletion packages/expo/src/cache/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,9 @@ export type { TokenCache } from './types';

export { MemoryTokenCache } from './MemoryTokenCache';
export { ClientResourceCache, EnvironmentResourceCache, SessionJWTCache } from './ResourceCache';
export { DUMMY_CLERK_ENVIRONMENT_RESOURCE, DUMMY_CLERK_CLIENT_RESOURCE } from './dummy-data';
export {
DUMMY_CLERK_ENVIRONMENT_RESOURCE,
DUMMY_CLERK_CLIENT_RESOURCE,
isDummyClient,
isDummyEnvironment,
} from './dummy-data';
2 changes: 1 addition & 1 deletion packages/expo/src/provider/ClerkProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ export type ClerkProviderProps<TUi extends Ui = Ui> = Omit<ReactClerkProviderPro
*/
publishableKey: string;
/**
* The token cache is used to persist the active user's session token. Clerk stores this token in memory by default, however it is recommended to use a token cache for production applications.
* The token cache is used to persist the client JWT that identifies this device to Clerk. Clerk keeps it in memory by default, however it is recommended to use a token cache backed by secure storage for production applications.
* @see https://clerk.com/docs/quickstarts/expo#configure-the-token-cache-with-expo
*/
tokenCache?: TokenCache;
Expand Down
Loading
Loading