Skip to content

fix(expo): Prevent stale native clients from replacing signed-in sess…#9222

Draft
wobsoriano wants to merge 1 commit into
mainfrom
rob/fix-js-issue-9217
Draft

fix(expo): Prevent stale native clients from replacing signed-in sess…#9222
wobsoriano wants to merge 1 commit into
mainfrom
rob/fix-js-issue-9217

Conversation

@wobsoriano

Copy link
Copy Markdown
Member

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:

@changeset-bot

changeset-bot Bot commented Jul 22, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: c9a13a1

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

This PR includes changesets to release 1 package
Name Type
@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 22, 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 22, 2026 10:25pm
swingset Ready Ready Preview, Comment Jul 22, 2026 10:25pm

Request Review

@pkg-pr-new

pkg-pr-new Bot commented Jul 22, 2026

Copy link
Copy Markdown

Open in StackBlitz

@clerk/astro

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

@clerk/backend

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

@clerk/chrome-extension

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

@clerk/clerk-js

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

@clerk/electron

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

@clerk/electron-passkeys

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

@clerk/eslint-plugin

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

@clerk/expo

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

@clerk/expo-google-signin

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

@clerk/expo-passkeys

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

@clerk/express

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

@clerk/fastify

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

@clerk/hono

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

@clerk/localizations

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

@clerk/nextjs

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

@clerk/nuxt

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

@clerk/react

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

@clerk/react-router

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

@clerk/shared

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

@clerk/tanstack-react-start

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

@clerk/testing

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

@clerk/ui

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

@clerk/upgrade

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

@clerk/vue

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

commit: c9a13a1

@mikepitre mikepitre 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.

Really nice work tracking this down — I went through the FAPI side to sanity-check the assumptions and the foreign-sessionless rejection holds up well against what the server actually does (a stale token on GET /v1/client re-mints a lazy client reusing the same id, so the validation fetch is deterministic). I found one interaction with the 401 recovery path that I think can still reproduce the original bug, plus two smaller things — details inline.

});
}

refreshedClient = await fetchRefreshedJsClient(clerkInstance);

@mikepitre mikepitre Jul 23, 2026

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.

I think there's a window here worth closing before we ship: the native token lands in the shared cache before the fetch resolves, and if a session token refresh 401s in that window, the intercepted handleUnauthenticated recovery

const nativeDeviceToken = await readNativeDeviceToken({ waitForToken: false });
// Native may have already moved the server-side client to a new
// active session. Refresh JS before allowing Clerk JS' stale-session
// 401 path to collapse the whole client to signed out.
const didRecover = await refreshJsClientFromNativeState({
clerkInstance,
nativeDeviceToken,
reloadInitialResources: false,
suppressTokenCacheNotificationsRef,
tokenCache,
});
runs refreshJsClientFromNativeState without the new guard — so it adopts the foreign sessionless client via updateClient. The outer validation then restores the token but not the client/session state, which leaves a good token in the cache with session null. I managed to reproduce it with a regression test, so it seems worth handling.

A couple of ideas, curious what you think:

  1. Share the in-flight validation promise and have the recovery path await it — once the good token is restored the 401 should mostly resolve itself.
  2. Or simply thread rejectForeignSessionlessClient + previous client/token into the recovery call too, so every path gets the same protection.

The fully airtight version (validating the candidate token without publishing it first) would need client.fetch to take an explicit token, which is clerk-js plumbing — totally fine as a follow-up.

return;
}

const previousDeviceToken = didChangeDeviceToken ? await getCachedDeviceToken(tokenCache) : undefined;

@mikepitre mikepitre Jul 23, 2026

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.

Small edge case: getCachedDeviceToken's 1s timeout resolves to null, same as "confirmed no token". If the cache read is just slow and we later hit the rejection/error path, the restore ends up calling clearToken on a token that actually exists. Maybe return undefined on timeout so the restore stays a no-op? Could also argue for skipping native adoption entirely in that case since we don't have a reliable snapshot — but I don't feel strongly about that part.

}
}

function getDeviceTokenIssuedAt(deviceToken: string | null): number | null {

@mikepitre mikepitre Jul 23, 2026

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.

One thing I noticed while checking the server side: client cookie JWTs are signed with exactly {id, rotating_token}
(https://github.com/clerk/clerk_go/blob/01fd589c0a2a283317ddfb89674bcd5d62b71e4f/model/client_cookie.go#L22-L25) and nothing injects iat, so I believe this branch can't fire with real tokens.

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.

2 participants