Skip to content
Open
26 changes: 26 additions & 0 deletions .changeset/slimy-hotels-give.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
---
'@clerk/tanstack-react-start': minor
'@clerk/react-router': minor
'@clerk/clerk-js': minor
'@clerk/nextjs': minor
'@clerk/shared': minor
'@clerk/astro': minor
'@clerk/react': minor
'@clerk/nuxt': minor
'@clerk/vue': minor
---

Add standalone `getToken()` function for retrieving session tokens outside of framework component trees.

This function is safe to call from anywhere in the browser, such as API interceptors, data fetching layers (e.g., React Query, SWR), or vanilla JavaScript code. It automatically waits for Clerk to initialize before returning the token.

import { getToken } from '@clerk/nextjs'; // or any framework package

// Example: Axios interceptor
axios.interceptors.request.use(async (config) => {
const token = await getToken();
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
});
1 change: 1 addition & 0 deletions packages/astro/src/client/index.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
export { updateClerkOptions } from '../internal/create-clerk-instance';
export * from '../stores/external';
export { getToken } from '@clerk/shared/getToken';
22 changes: 22 additions & 0 deletions packages/clerk-js/src/core/clerk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@ import type {
InstanceType,
JoinWaitlistParams,
ListenerCallback,
LoadedClerk,
NavigateOptions,
OrganizationListProps,
OrganizationProfileProps,
Expand Down Expand Up @@ -437,6 +438,23 @@ export class Clerk implements ClerkInterface {
this.#publicEventBus.emit(clerkEvents.Status, 'loading');
this.#publicEventBus.prioritizedOn(clerkEvents.Status, s => (this.#status = s));

this.#publicEventBus.on(clerkEvents.Status, status => {
if (!inBrowser()) {
return;
}
if (status === 'ready' || status === 'degraded') {
if (window.__clerk_internal_ready?.__resolve && this.#isLoaded()) {
window.__clerk_internal_ready.__resolve(this);
}
} else if (status === 'error') {
if (window.__clerk_internal_ready?.__reject) {
window.__clerk_internal_ready.__reject(
new ClerkRuntimeError('Clerk failed to initialize.', { code: 'clerk_init_failed' }),
);
}
}
});

// This line is used for the piggy-backing mechanism
BaseResource.clerk = this;
this.#protect = new Protect();
Expand Down Expand Up @@ -3117,4 +3135,8 @@ export class Clerk implements ClerkInterface {

return allowedProtocols;
}

#isLoaded(): this is LoadedClerk {
return this.client !== undefined;
}
}
11 changes: 11 additions & 0 deletions packages/clerk-js/src/global.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,4 +19,15 @@ interface Window {
__internal_onAfterSetActive: () => Promise<void> | void;
// eslint-disable-next-line @typescript-eslint/consistent-type-imports
__internal_ClerkUiCtor?: import('@clerk/shared/types').ClerkUiConstructor;
/**
* Promise used for coordination between standalone getToken() from @clerk/shared and clerk-js.
* When getToken() is called before Clerk loads, it creates this promise with __resolve/__reject callbacks.
* When Clerk reaches ready/degraded/error status, it resolves/rejects this promise.
*/
// eslint-disable-next-line @typescript-eslint/consistent-type-imports
__clerk_internal_ready?: Promise<import('@clerk/shared/types').LoadedClerk> & {
// eslint-disable-next-line @typescript-eslint/consistent-type-imports
__resolve?: (clerk: import('@clerk/shared/types').LoadedClerk) => void;
__reject?: (error: Error) => void;
};
}
2 changes: 2 additions & 0 deletions packages/nextjs/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,8 @@ export {
useUser,
} from './client-boundary/hooks';

export { getToken } from '@clerk/shared/getToken';

/**
* Conditionally export components that exhibit different behavior
* when used in /app vs /pages.
Expand Down
1 change: 1 addition & 0 deletions packages/nuxt/src/runtime/client/index.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
export { createRouteMatcher } from './routeMatcher';
export { updateClerkOptions } from '@clerk/vue';
export { getToken } from '@clerk/shared/getToken';
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ exports[`root public exports > should not change unexpectedly 1`] = `
"__experimental_PaymentElementProvider",
"__experimental_useCheckout",
"__experimental_usePaymentElement",
"getToken",
"useAuth",
"useClerk",
"useEmailLink",
Expand Down
1 change: 1 addition & 0 deletions packages/react-router/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ if (typeof window !== 'undefined' && typeof (window as any).global === 'undefine
}

export * from './client';
export { getToken } from '@clerk/shared/getToken';

// Override Clerk React error thrower to show that errors come from @clerk/react-router
import { setErrorThrowerOptions } from '@clerk/react/internal';
Expand Down
1 change: 1 addition & 0 deletions packages/react/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ export * from './components';
export * from './contexts';

export * from './hooks';
export { getToken } from '@clerk/shared/getToken';
export type {
BrowserClerk,
BrowserClerkConstructor,
Expand Down
263 changes: 263 additions & 0 deletions packages/shared/src/__tests__/getToken.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,263 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';

import { ClerkRuntimeError } from '../errors/clerkRuntimeError';
import { getToken } from '../getToken';

describe('getToken', () => {
const originalWindow = global.window;

beforeEach(() => {
vi.useFakeTimers();
});

afterEach(() => {
vi.useRealTimers();
vi.restoreAllMocks();
global.window = originalWindow;
});

describe('when Clerk is already ready', () => {
it('should return token immediately', async () => {
const mockToken = 'mock-jwt-token';
const mockClerk = {
status: 'ready',
session: {
getToken: vi.fn().mockResolvedValue(mockToken),
},
};

global.window = { Clerk: mockClerk } as any;

const token = await getToken();
expect(token).toBe(mockToken);
expect(mockClerk.session.getToken).toHaveBeenCalledWith(undefined);
});

it('should pass options to session.getToken', async () => {
const mockClerk = {
status: 'ready',
session: {
getToken: vi.fn().mockResolvedValue('token'),
},
};

global.window = { Clerk: mockClerk } as any;

await getToken({ template: 'custom-template' });
expect(mockClerk.session.getToken).toHaveBeenCalledWith({ template: 'custom-template' });
});

it('should pass organizationId option to session.getToken', async () => {
const mockClerk = {
status: 'ready',
session: {
getToken: vi.fn().mockResolvedValue('token'),
},
};

global.window = { Clerk: mockClerk } as any;

await getToken({ organizationId: 'org_123' });
expect(mockClerk.session.getToken).toHaveBeenCalledWith({ organizationId: 'org_123' });
});
});

describe('when Clerk is not yet ready', () => {
it('should wait for promise resolution when clerk-js resolves the global promise', async () => {
const mockToken = 'delayed-token';
const mockClerk = {
status: 'ready',
session: {
getToken: vi.fn().mockResolvedValue(mockToken),
},
};

// Start with empty window (no Clerk)
global.window = {} as any;

const tokenPromise = getToken();

// Simulate clerk-js loading and resolving the promise
await vi.advanceTimersByTimeAsync(100);

// Resolve the promise that getToken created
const readyPromise = (global.window as any).__clerk_internal_ready;
expect(readyPromise).toBeDefined();
expect(readyPromise.__resolve).toBeDefined();

// Simulate clerk-js calling __resolve
readyPromise.__resolve(mockClerk);

const token = await tokenPromise;
expect(token).toBe(mockToken);
});

it('should resolve when clerk-js resolves with degraded status', async () => {
const mockToken = 'degraded-token';
const mockClerk = {
status: 'degraded',
session: {
getToken: vi.fn().mockResolvedValue(mockToken),
},
};

global.window = {} as any;

const tokenPromise = getToken();

await vi.advanceTimersByTimeAsync(100);

const readyPromise = (global.window as any).__clerk_internal_ready;
readyPromise.__resolve(mockClerk);

const token = await tokenPromise;
expect(token).toBe(mockToken);
});

it('should reject when clerk-js rejects the global promise', async () => {
global.window = {} as any;

const tokenPromise = getToken();

await vi.advanceTimersByTimeAsync(100);

const readyPromise = (global.window as any).__clerk_internal_ready;
readyPromise.__reject(new Error('Clerk failed to initialize'));

await expect(tokenPromise).rejects.toThrow('Clerk failed to initialize');
});

it('should throw ClerkRuntimeError if promise is never resolved (timeout)', async () => {
global.window = {} as any;

let caughtError: unknown;
const tokenPromise = getToken().catch(e => {
caughtError = e;
});

// Fast-forward past timeout (10 seconds)
await vi.advanceTimersByTimeAsync(15000);
await tokenPromise;

expect(caughtError).toBeInstanceOf(ClerkRuntimeError);
expect((caughtError as ClerkRuntimeError).code).toBe('clerk_runtime_load_timeout');
});
});

describe('multiple concurrent getToken calls', () => {
it('should share the same promise for concurrent calls', async () => {
const mockToken = 'shared-token';
const mockClerk = {
status: 'ready',
session: {
getToken: vi.fn().mockResolvedValue(mockToken),
},
};

global.window = {} as any;

const tokenPromise1 = getToken();
const tokenPromise2 = getToken();
const tokenPromise3 = getToken();

await vi.advanceTimersByTimeAsync(100);

const readyPromise = (global.window as any).__clerk_internal_ready;
readyPromise.__resolve(mockClerk);

const [token1, token2, token3] = await Promise.all([tokenPromise1, tokenPromise2, tokenPromise3]);

expect(token1).toBe(mockToken);
expect(token2).toBe(mockToken);
expect(token3).toBe(mockToken);
expect(mockClerk.session.getToken).toHaveBeenCalledTimes(3);
});
});

describe('when user is not signed in', () => {
it('should return null when session is null', async () => {
const mockClerk = {
status: 'ready',
session: null,
};

global.window = { Clerk: mockClerk } as any;

const token = await getToken();
expect(token).toBeNull();
});

it('should return null when session is undefined', async () => {
const mockClerk = {
status: 'ready',
session: undefined,
};

global.window = { Clerk: mockClerk } as any;

const token = await getToken();
expect(token).toBeNull();
});
});

describe('when Clerk status is degraded', () => {
it('should still return token', async () => {
const mockToken = 'degraded-token';
const mockClerk = {
status: 'degraded',
session: {
getToken: vi.fn().mockResolvedValue(mockToken),
},
};

global.window = { Clerk: mockClerk } as any;

const token = await getToken();
expect(token).toBe(mockToken);
});
});

describe('in non-browser environment', () => {
it('should throw ClerkRuntimeError when window is undefined', async () => {
global.window = undefined as any;

await expect(getToken()).rejects.toThrow(ClerkRuntimeError);
await expect(getToken()).rejects.toMatchObject({
code: 'clerk_runtime_not_browser',
});
});
});

describe('when session.getToken throws', () => {
it('should propagate the error', async () => {
const mockClerk = {
status: 'ready',
session: {
getToken: vi.fn().mockRejectedValue(new Error('Token fetch failed')),
},
};

global.window = { Clerk: mockClerk } as any;

await expect(getToken()).rejects.toThrow('Token fetch failed');
});
});

describe('fallback for older clerk-js versions', () => {
it('should resolve when clerk.loaded is true but status is undefined', async () => {
const mockToken = 'legacy-token';
const mockClerk = {
loaded: true,
status: undefined,
session: {
getToken: vi.fn().mockResolvedValue(mockToken),
},
};

global.window = { Clerk: mockClerk } as any;

const token = await getToken();
expect(token).toBe(mockToken);
});
});
});
Loading
Loading