Skip to content

fix(clerk-js): apply the Safari ITP cookie refresh on sign-out - #9254

Open
dmoerner wants to merge 1 commit into
mainfrom
daniel/user-5769-safari-itp-sign-out-cookie-refresh
Open

fix(clerk-js): apply the Safari ITP cookie refresh on sign-out#9254
dmoerner wants to merge 1 commit into
mainfrom
daniel/user-5769-safari-itp-sign-out-cookie-refresh

Conversation

@dmoerner

@dmoerner dmoerner commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

Signing out re-issues the client cookie from a fetch, which Safari's ITP caps at 7 days. The client outlives sign-out — persistClient defaults to true, so signOut() removes the sessions and keeps the client — and the client is what Client Trust uses to recognise a known device. Leaving the cap in place meant a user who signed out and returned more than a week later had lost the cookie, arrived as a new client, and was challenged for a second factor.

Route the sign-out redirect through /v1/client/touch when the cookie is close to expiring, which is the workaround setActive() already applies after sign-in: touch is reached by a top-level navigation, which ITP does not cap, so it restores the full lifetime. The redirect is returned unchanged when no refresh is needed, so this only adds a hop on Safari — every other browser gets a 400 day cookie and is never eligible.

This covers the signOut() paths that navigate to a redirect URL, including and . signOut(callback) navigates on the caller's behalf and is left alone.

Fixes USER-5769.

Depends on https://github.com/clerk/clerk_go/pull/20738.

Description

Checklist

  • pnpm test runs as expected.
  • pnpm build runs as expected.
  • (If applicable) JSDoc comments have been added or updated for any package exports
  • (If applicable) Documentation has been updated

Type of change

  • 🐛 Bug fix
  • 🌟 New feature
  • 🔨 Breaking change
  • 📖 Refactoring / dependency upgrade / documentation
  • other:

Signing out re-issues the client cookie from a fetch, which Safari's ITP
caps at 7 days. The client outlives sign-out — persistClient defaults to
true, so signOut() removes the sessions and keeps the client — and the
client is what Client Trust uses to recognise a known device. Leaving the
cap in place meant a user who signed out and returned more than a week
later had lost the cookie, arrived as a new client, and was challenged
for a second factor.

Route the sign-out redirect through /v1/client/touch when the cookie is
close to expiring, which is the workaround setActive() already applies
after sign-in: touch is reached by a top-level navigation, which ITP does
not cap, so it restores the full lifetime. The redirect is returned
unchanged when no refresh is needed, so this only adds a hop on Safari —
every other browser gets a 400 day cookie and is never eligible.

This covers the signOut() paths that navigate to a redirect URL,
including <UserButton /> and <SignOutButton />. signOut(callback)
navigates on the caller's behalf and is left alone.

The existing signOut() test clients gain the isEligibleForTouch and
buildTouchUrl members that the real ClientResource always exposes.

USER-5769
@changeset-bot

changeset-bot Bot commented Jul 27, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 0141915

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 4 packages
Name Type
@clerk/clerk-js Patch
@clerk/chrome-extension Patch
@clerk/electron Patch
@clerk/expo Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@vercel

vercel Bot commented Jul 27, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
clerk-js-sandbox Ready Ready Preview, Comment Jul 27, 2026 5:21pm
swingset Ready Ready Preview, Comment Jul 27, 2026 5:21pm

Request Review

@pkg-pr-new

pkg-pr-new Bot commented Jul 27, 2026

Copy link
Copy Markdown

Open in StackBlitz

@clerk/astro

npm i https://pkg.pr.new/@clerk/astro@9254

@clerk/backend

npm i https://pkg.pr.new/@clerk/backend@9254

@clerk/chrome-extension

npm i https://pkg.pr.new/@clerk/chrome-extension@9254

@clerk/clerk-js

npm i https://pkg.pr.new/@clerk/clerk-js@9254

@clerk/electron

npm i https://pkg.pr.new/@clerk/electron@9254

@clerk/electron-passkeys

npm i https://pkg.pr.new/@clerk/electron-passkeys@9254

@clerk/eslint-plugin

npm i https://pkg.pr.new/@clerk/eslint-plugin@9254

@clerk/expo

npm i https://pkg.pr.new/@clerk/expo@9254

@clerk/expo-google-signin

npm i https://pkg.pr.new/@clerk/expo-google-signin@9254

@clerk/expo-passkeys

npm i https://pkg.pr.new/@clerk/expo-passkeys@9254

@clerk/express

npm i https://pkg.pr.new/@clerk/express@9254

@clerk/fastify

npm i https://pkg.pr.new/@clerk/fastify@9254

@clerk/hono

npm i https://pkg.pr.new/@clerk/hono@9254

@clerk/localizations

npm i https://pkg.pr.new/@clerk/localizations@9254

@clerk/nextjs

npm i https://pkg.pr.new/@clerk/nextjs@9254

@clerk/nuxt

npm i https://pkg.pr.new/@clerk/nuxt@9254

@clerk/react

npm i https://pkg.pr.new/@clerk/react@9254

@clerk/react-router

npm i https://pkg.pr.new/@clerk/react-router@9254

@clerk/shared

npm i https://pkg.pr.new/@clerk/shared@9254

@clerk/tanstack-react-start

npm i https://pkg.pr.new/@clerk/tanstack-react-start@9254

@clerk/testing

npm i https://pkg.pr.new/@clerk/testing@9254

@clerk/ui

npm i https://pkg.pr.new/@clerk/ui@9254

@clerk/upgrade

npm i https://pkg.pr.new/@clerk/upgrade@9254

@clerk/vue

npm i https://pkg.pr.new/@clerk/vue@9254

commit: 0141915

@github-actions

Copy link
Copy Markdown
Contributor

API Changes Report

Generated by Break Check on 2026-07-27T17:24:04.369Z

Summary

Metric Count
Packages analyzed 19
Packages with changes 0
🔴 Breaking changes 0
🟡 Non-breaking changes 0
🟢 Additions 0

No API Changes Detected

All packages have stable APIs with no detected changes.


Report generated by Break Check

Last ran on 0141915.

@coderabbitai

coderabbitai Bot commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

signOut() now conditionally routes browser redirects through /v1/client/touch when the client is eligible for a cookie refresh. Existing sign-out tests include default touch metadata, and a new test verifies the generated touch URL preserves the requested redirect destination. A patch changeset documents the Safari ITP behavior and callback handling.

Estimated code review effort: 2 (Simple) | ~10 minutes

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly summarizes the main change: Safari ITP cookie refresh applied during sign-out.
Description check ✅ Passed The description accurately explains the sign-out redirect touch flow and matches the changeset.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🧹 Nitpick comments (1)
packages/clerk-js/src/core/clerk.ts (1)

659-668: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Keep the private-method comment terse and implementation-accurate.

This block restates the implementation and asserts Safari-only behavior, while isEligibleForTouch() is a browser-agnostic, time-based client contract. Reduce it to a single-line rationale to avoid stale guidance.

Suggested simplification
-  /**
-   * Routes the post-sign-out navigation through `/v1/client/touch` when the client
-   * cookie is close to expiring, and returns `redirectUrl` unchanged otherwise.
-   *
-   * In practice this only adds a redirect on Safari. Signing out re-issues the client
-   * cookie from a fetch, which ITP caps at 7 days; every other browser gets 400 days
-   * and is never eligible. The cap matters because the client outlives sign-out and is
-   * what Client Trust uses to recognize a known device, so a user returning a week
-   * later would be challenged for a second factor. Touch is a top-level navigation,
-   * which ITP does not cap, so it restores the full lifetime.
-   */
+  // Use top-level touch navigation when the client cookie needs refreshing.

As per coding guidelines, comments should explain only non-obvious rationale and remain minimal.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/clerk-js/src/core/clerk.ts` around lines 659 - 668, Shorten the
comment above the private method associated with isEligibleForTouch() to a
single-line rationale explaining only why post-sign-out navigation may use the
touch endpoint. Remove the detailed cookie, browser-specific, and lifetime
claims, while leaving the implementation unchanged.

Source: Coding guidelines

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@packages/clerk-js/src/core/__tests__/clerk.test.ts`:
- Around line 1324-1341: The buildTouchUrl mock in the Clerk sign-out test must
URL-encode the nested redirect URL, matching Client.buildTouchUrl(). Update the
mock around buildTouchUrl to encode redirectUrl.toString() before placing it in
the redirect_url query parameter, while preserving the existing navigation
assertions and destination value.

---

Nitpick comments:
In `@packages/clerk-js/src/core/clerk.ts`:
- Around line 659-668: Shorten the comment above the private method associated
with isEligibleForTouch() to a single-line rationale explaining only why
post-sign-out navigation may use the touch endpoint. Remove the detailed cookie,
browser-specific, and lifetime claims, while leaving the implementation
unchanged.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository YAML (base), Organization UI (inherited)

Review profile: CHILL

Plan: Pro Plus

Run ID: 7fccafcd-212c-473f-9c71-e394d1ded0c0

📥 Commits

Reviewing files that changed from the base of the PR and between 6f5fde9 and 0141915.

📒 Files selected for processing (3)
  • .changeset/safari-itp-sign-out-cookie-refresh.md
  • packages/clerk-js/src/core/__tests__/clerk.test.ts
  • packages/clerk-js/src/core/clerk.ts
🔗 Linked repositories identified

CodeRabbit considers these linked repositories for cross-repo context during reviews:

  • clerk/clerk_go (manual) → reviewed against open PR #20738 daniel/user-5769-client-cookie-expires-at-stale-on-sign-out instead of the default branch
  • clerk/dashboard (manual)
  • clerk/accounts (manual)
  • clerk/backoffice (manual)
  • clerk/clerk (manual)
  • clerk/clerk-docs (manual)
  • clerk/cloudflare-workers (manual)
  • clerk/clerk-ios (auto-detected)
  • clerk/clerk-android (auto-detected)
  • clerk/cli (auto-detected)

Comment on lines +1324 to +1341
buildTouchUrl: ({ redirectUrl }: { redirectUrl: URL }) =>
`https://clerk.example.com/v1/client/touch?redirect_url=${redirectUrl.toString()}`,
}),
);

const sut = new Clerk(productionPublishableKey);
sut.navigate = vi.fn();
await sut.load();
await sut.signOut({ redirectUrl: '/after-sign-out' });

await waitFor(() => {
expect(mockClientRemoveSessions).toHaveBeenCalled();
const navigatedTo = new URL((sut.navigate as ReturnType<typeof vi.fn>).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', mockWindowLocation.href).toString(),
);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Encode nested redirect URLs in the test mock.

The mock interpolates redirectUrl.toString() directly into the query, unlike Client.buildTouchUrl(). A destination containing ? or & would be parsed incorrectly, so this test does not cover the redirect-preservation contract.

Suggested test adjustment
-          buildTouchUrl: ({ redirectUrl }: { redirectUrl: URL }) =>
-            `https://clerk.example.com/v1/client/touch?redirect_url=${redirectUrl.toString()}`,
+          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();
+          },
...
-      await sut.signOut({ redirectUrl: '/after-sign-out' });
+      await sut.signOut({ redirectUrl: '/after-sign-out?tab=security#settings' });
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/clerk-js/src/core/__tests__/clerk.test.ts` around lines 1324 - 1341,
The buildTouchUrl mock in the Clerk sign-out test must URL-encode the nested
redirect URL, matching Client.buildTouchUrl(). Update the mock around
buildTouchUrl to encode redirectUrl.toString() before placing it in the
redirect_url query parameter, while preserving the existing navigation
assertions and destination value.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant