diff --git a/.changeset/safari-itp-sign-out-cookie-refresh.md b/.changeset/safari-itp-sign-out-cookie-refresh.md
new file mode 100644
index 00000000000..375c28825a3
--- /dev/null
+++ b/.changeset/safari-itp-sign-out-cookie-refresh.md
@@ -0,0 +1,11 @@
+---
+'@clerk/clerk-js': patch
+---
+
+Apply the Safari ITP cookie refresh on sign-out.
+
+Signing out re-issues the client cookie from a fetch, which Safari's ITP caps at 7 days. The client outlives sign-out, and it is what Client Trust uses to recognize a known device, so the cap meant a user who signed out and came back more than a week later was treated as being on a new device and challenged for a second factor.
+
+`signOut()` now routes its redirect through `/v1/client/touch` when the client cookie is close to expiring, the same workaround `setActive()` already applies after sign-in. The redirect is left unchanged when no refresh is needed.
+
+This covers `signOut()` calls that navigate to a redirect URL, including `` and ``. Passing your own callback to `signOut(callback)` still navigates on your behalf and is not decorated.
diff --git a/packages/clerk-js/src/core/__tests__/clerk.test.ts b/packages/clerk-js/src/core/__tests__/clerk.test.ts
index 98f3d3aac2a..66412e0a5e4 100644
--- a/packages/clerk-js/src/core/__tests__/clerk.test.ts
+++ b/packages/clerk-js/src/core/__tests__/clerk.test.ts
@@ -1169,6 +1169,13 @@ describe('Clerk singleton', () => {
const mockSession1 = { id: '1', remove: vi.fn(), status: 'active', user: {}, getToken: vi.fn() };
const mockSession2 = { id: '2', remove: vi.fn(), status: 'active', user: {}, getToken: vi.fn() };
const mockSession3 = { id: '3', remove: vi.fn(), status: 'pending', user: {}, getToken: vi.fn() };
+ // Sign-out consults these to decide whether to route the redirect through
+ // /v1/client/touch. Default to "no refresh needed" so the existing cases
+ // assert against the plain redirect URL.
+ const clientTouchDefaults = {
+ isEligibleForTouch: () => false,
+ buildTouchUrl: () => 'https://clerk.example.com/v1/client/touch',
+ };
beforeEach(() => {
mockClientDestroy.mockReset();
@@ -1198,6 +1205,7 @@ describe('Clerk singleton', () => {
it('signs out all sessions if no sessionId is passed and multiple sessions have authenticated status', async () => {
mockClientFetch.mockReturnValue(
Promise.resolve({
+ ...clientTouchDefaults,
signedInSessions: [mockSession1, mockSession2, mockSession3],
sessions: [mockSession1, mockSession2, mockSession3],
destroy: mockClientDestroy,
@@ -1221,6 +1229,7 @@ describe('Clerk singleton', () => {
async status => {
mockClientFetch.mockReturnValue(
Promise.resolve({
+ ...clientTouchDefaults,
signedInSessions: [{ ...mockSession1, status }],
sessions: [{ ...mockSession1, status }],
destroy: mockClientDestroy,
@@ -1244,6 +1253,7 @@ describe('Clerk singleton', () => {
it('only removes the session that corresponds to the passed sessionId if it is not the current', async () => {
mockClientFetch.mockReturnValue(
Promise.resolve({
+ ...clientTouchDefaults,
signedInSessions: [mockSession1, mockSession2, mockSession3],
sessions: [mockSession1, mockSession2, mockSession3],
destroy: mockClientDestroy,
@@ -1264,6 +1274,7 @@ describe('Clerk singleton', () => {
it('removes and signs out the session that corresponds to the passed sessionId if it is the current', async () => {
mockClientFetch.mockReturnValue(
Promise.resolve({
+ ...clientTouchDefaults,
signedInSessions: [mockSession1, mockSession2, mockSession3],
sessions: [mockSession1, mockSession2, mockSession3],
destroy: mockClientDestroy,
@@ -1284,6 +1295,7 @@ describe('Clerk singleton', () => {
it('removes and signs out the session and redirects to the provided redirectUrl ', async () => {
mockClientFetch.mockReturnValue(
Promise.resolve({
+ ...clientTouchDefaults,
signedInSessions: [mockSession1, mockSession2, mockSession3],
sessions: [mockSession1, mockSession2, mockSession3],
destroy: mockClientDestroy,
@@ -1300,6 +1312,40 @@ describe('Clerk singleton', () => {
expect(sut.navigate).toHaveBeenCalledWith('/after-sign-out');
});
});
+
+ it('routes the redirect through /v1/client/touch when the client cookie is close to expiring', async () => {
+ mockClientFetch.mockReturnValue(
+ Promise.resolve({
+ signedInSessions: [mockSession1],
+ sessions: [mockSession1],
+ destroy: mockClientDestroy,
+ removeSessions: mockClientRemoveSessions,
+ isEligibleForTouch: () => true,
+ // Encodes the nested URL the way the real Client.buildTouchUrl() does, so a
+ // destination carrying its own query or hash survives the round trip.
+ buildTouchUrl: ({ redirectUrl }: { redirectUrl: URL }) => {
+ const touchUrl = new URL('https://clerk.example.com/v1/client/touch');
+ touchUrl.searchParams.set('redirect_url', redirectUrl.toString());
+ return touchUrl.toString();
+ },
+ }),
+ );
+
+ const sut = new Clerk(productionPublishableKey);
+ sut.navigate = vi.fn();
+ await sut.load();
+ await sut.signOut({ redirectUrl: '/after-sign-out?tab=security#settings' });
+
+ await waitFor(() => {
+ expect(mockClientRemoveSessions).toHaveBeenCalled();
+ const navigatedTo = new URL((sut.navigate as ReturnType).mock.calls[0][0]);
+ expect(navigatedTo.pathname).toEqual('/v1/client/touch');
+ // The user still lands on the original destination, via the touch redirect.
+ expect(navigatedTo.searchParams.get('redirect_url')).toEqual(
+ new URL('/after-sign-out?tab=security#settings', mockWindowLocation.href).toString(),
+ );
+ });
+ });
});
describe('.navigate(to)', () => {
diff --git a/packages/clerk-js/src/core/clerk.ts b/packages/clerk-js/src/core/clerk.ts
index b19474cdc65..9eaa2d6732c 100644
--- a/packages/clerk-js/src/core/clerk.ts
+++ b/packages/clerk-js/src/core/clerk.ts
@@ -656,6 +656,25 @@ export class Clerk implements ClerkInterface {
return Boolean(!this.#options.signUpUrl && this.#options.signInUrl && !isAbsoluteUrl(this.#options.signInUrl));
}
+ /**
+ * Routes `url` through `/v1/client/touch` when the client cookie is close to expiring,
+ * and returns it unchanged otherwise.
+ *
+ * In practice this only adds a redirect on Safari. A cookie re-issued from a fetch is
+ * capped at 7 days by ITP; every other browser gets the full 400 days and is never
+ * eligible. Touch is a top-level navigation, which ITP does not cap, so it restores
+ * the full lifetime.
+ */
+ #decorateUrlWithTouch(url: string): string {
+ // In React Native, window exists but window.location does not, so the URL cannot be
+ // resolved to an absolute one. ITP does not apply there either.
+ if (!inBrowser() || typeof window.location === 'undefined' || !this.client?.isEligibleForTouch()) {
+ return url;
+ }
+
+ return this.buildUrlWithAuth(this.client.buildTouchUrl({ redirectUrl: new URL(url, window.location.href) }));
+ }
+
public signOut: SignOut = async (callbackOrOptions?: SignOutCallback | SignOutOptions, options?: SignOutOptions) => {
if (!this.client || this.client.sessions.length === 0) {
return;
@@ -696,7 +715,9 @@ export class Clerk implements ClerkInterface {
if (signOutCallback) {
await signOutCallback();
} else {
- await this.navigate(redirectUrl);
+ // The client outlives sign-out and is what Client Trust uses to recognize a
+ // returning device, so the re-issued cookie has to keep its full lifetime.
+ await this.navigate(this.#decorateUrlWithTouch(redirectUrl));
}
});
@@ -1826,21 +1847,9 @@ export class Clerk implements ClerkInterface {
// Track whether decorateUrl was called for dev-mode warning
let decorateUrlCalled = false;
- /**
- * Creates a URL that goes through the /v1/client/touch endpoint when Safari ITP fix is needed.
- * This allows the session cookie to be refreshed via a full page navigation, bypassing
- * Safari's 7-day cap on cookies set via fetch/XHR.
- */
const decorateUrl = (url: string): string => {
decorateUrlCalled = true;
-
- if (!this.client?.isEligibleForTouch()) {
- return url;
- }
-
- const absoluteUrl = new URL(url, window.location.href);
- const touchUrl = this.client.buildTouchUrl({ redirectUrl: absoluteUrl });
- return this.buildUrlWithAuth(touchUrl);
+ return this.#decorateUrlWithTouch(url);
};
await setActiveNavigate({ session: newSession, decorateUrl });