Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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';
19 changes: 19 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,20 @@ 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 (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' }),
);
}
}
});

Comment on lines +441 to +454
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Guard window usage in the constructor status listener to avoid non-browser ReferenceError.
The new handler directly references window (Lines 443-450). If Clerk is constructed where window is undefined, this will crash before any inBrowser() checks can help.

Proposed fix
 this.#publicEventBus.on(clerkEvents.Status, status => {
+  if (typeof window === 'undefined') {
+    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' }),
       );
     }
   }
 });
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
this.#publicEventBus.on(clerkEvents.Status, status => {
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.#publicEventBus.on(clerkEvents.Status, status => {
if (typeof window === 'undefined') {
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' }),
);
}
}
});
🤖 Prompt for AI Agents
In @packages/clerk-js/src/core/clerk.ts around lines 441 - 454, The status
listener registered on this.#publicEventBus for clerkEvents.Status directly
accesses global window (window.__clerk_internal_ready) which can throw
ReferenceError in non-browser environments; guard all window accesses by
wrapping them with an in-browser check or typeof window !== 'undefined' before
referencing window.__clerk_internal_ready, and only call
window.__clerk_internal_ready.__resolve/__reject when inBrowser (or
this.#isLoaded() as currently used) is true; update the listener in the
constructor (the this.#publicEventBus.on(...) callback) to perform that guard
around both the 'ready'/'degraded' and 'error' branches so server-side
construction won’t crash.

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

return allowedProtocols;
}

#isLoaded(): this is LoadedClerk {
return this.client !== undefined;
}
}
17 changes: 17 additions & 0 deletions packages/clerk-js/src/global.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,26 @@ const __BUILD_DISABLE_RHC__: string;
const __BUILD_VARIANT_CHANNEL__: boolean;
const __BUILD_VARIANT_CHIPS__: boolean;

/**
* A promise used for coordination between standalone getToken() and clerk-js initialization.
* The __resolve and __reject callbacks allow external resolution.
*/
// eslint-disable-next-line @typescript-eslint/consistent-type-imports
interface ClerkReadyPromise extends 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;
}
Comment on lines +17 to +26
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Avoid adding a new named global interface (ClerkReadyPromise)—this can break consumers via declaration/name collisions.

Since this is a .d.ts that lands in consumers’ global type space, prefer an inline intersection type on Window.__clerk_internal_ready instead of introducing a new global symbol.

Proposed change (keeps behavior, avoids new global name)
-/**
- * A promise used for coordination between standalone getToken() and clerk-js initialization.
- * The __resolve and __reject callbacks allow external resolution.
- */
-// eslint-disable-next-line @typescript-eslint/consistent-type-imports
-interface ClerkReadyPromise extends 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;
-}
-
 interface Window {
@@
   /**
@@
    */
-  __clerk_internal_ready?: ClerkReadyPromise;
+  __clerk_internal_ready?: Promise<import('@clerk/shared/types').LoadedClerk> & {
+    __resolve?: (clerk: import('@clerk/shared/types').LoadedClerk) => void;
+    __reject?: (error: Error) => void;
+  };
 }

Also applies to: 33-38

🤖 Prompt for AI Agents
In @packages/clerk-js/src/global.d.ts around lines 17 - 26, You added a new
global interface ClerkReadyPromise which can cause declaration/name collisions
for consumers; instead remove the global ClerkReadyPromise and type
Window.__clerk_internal_ready with an inline intersection type (e.g.
Promise<import('@clerk/shared/types').LoadedClerk> & { __resolve?: ...,
__reject?: ... }) wherever __clerk_internal_ready is declared (including the
other occurrence referenced around lines 33-38), and update any usages to rely
on that inline intersection type so no new global symbol is introduced.


interface Window {
__internal_onBeforeSetActive: (intent?: 'sign-out') => Promise<void> | void;
__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.
*/
__clerk_internal_ready?: ClerkReadyPromise;
}
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