Sign-in token redemption is unusable in an in-app browser: `session_exists`, then `sign_in_token_already_used`
Nobody has claimed this yet.
- Dominant language
- TypeScript
- Stars
- 1.8k
- Forks
- 472
- Avg merge
- 2d 11h
- Merged PRs (30d)
- 189
Description
Preliminary Checks
-
I have reviewed the documentation: https://clerk.com/docs
-
I have searched for existing issues: https://github.com/clerk/javascript/issues
-
I have not already reached out to Clerk support via email or Discord (if you have, no need to open an issue here)
-
This issue is not a question, general help request, or anything other than a bug report directly related to Clerk. Please ask questions in our Discord community: https://clerk.com/discord.
Reproduction
https://github.com/scoobydrew83/worthsync
Publishable key
pk_live_Y2xlcmsud29ydGhzeW5jLmNvbSQ
Description
Summary
We implemented Clerk's documented native→web session handoff (mint a sign-in token on the
backend, open a URL in the app's in-app browser, redeem with signIn.ticket()). It cannot be
made to work as documented. Two distinct defects compound, and the fix for the second one is a
PR from a Clerk engineer that was closed without merging.
We have a working workaround. We are filing this because the workaround depends on undocumented
behaviour that could change under us, and because the documented path is broken for anyone who
tries it.
Environment
@clerk/nextjs |
7.5.1 (also reported by others on 7.6.3 and 7.7.4) |
clerk-js |
6.x, served from our custom FAPI domain |
@clerk/expo |
4.2.3 |
| Next.js | 16, App Router, deployed on Cloudflare Workers via OpenNext |
| Instance | Production, custom FAPI domain, single session mode |
| Native | Expo SDK 57, expo-web-browser → SFSafariViewController (iOS) / Custom Tabs (Android) |
What we're trying to do
The mobile app signs the user in natively with @clerk/expo. Several screens are web-only
(reports, household sharing/invites, policy documents, account deletion), and the app opens them
in an in-app browser. We want those to open already signed in.
Per the docs, we:
POST /v1/sign_in_tokenswith{ user_id, expires_in_seconds: 60 }- Open
https://app.example.com/sign-in-with-token?token=<TOKEN>&redirect_url=/settings/privacy - On that page, call
signIn.ticket({ ticket })thensignIn.finalize({ navigate })
Account deletion in particular is an App Store Review Guideline 5.1.1(v) requirement, so this
is not a nice-to-have — a reviewer meeting a sign-in wall is a rejection.
Defect 1 — session_exists makes the documented example a silent no-op
The in-app browser usually already holds a Clerk session (on iOS, one our own app created during
a previous handoff — SFSafariViewController has a persistent per-app store). In single-session
mode, signIn.ticket() then fails:
session_exists — "Session already exists"
"You're currently in single session mode. You can only be signed into one account at a time."
The official example does not handle this. From
docs/guides/development/custom-flows/authentication/embedded-email-links.mdx:
if (!signInToken || user || loading) {
return // <-- silently does nothing when a session exists
}
So the documented flow's behaviour in our scenario is "quietly do nothing," which is
indistinguishable from a bug in the caller.
setActive({ session: null }) does not clear it. Reading clerk-js (core/clerk.ts),
#touchCurrentSession early-returns when the session is null and client.sessions is never
mutated — it issues no FAPI request at all. The Frontend API still sees the session. This is not
obvious from the API surface, and "set the active session to nothing" is exactly what a reader
would reach for.
Only client.removeSessions() / signOut() actually issue DELETE /v1/client/sessions.
Prior report: clerk/javascript#8044 —
same defect, filed in March of this year, closed by a staleness bot with zero maintainer
replies, and re-reported by two more users on 2026-07-30 against @clerk/nextjs@7.6.3. It is
not fixed in any released version.
Defect 2 — signOut() triggers a Safari hard reload that respends the single-use token
Working around Defect 1 by signing out first produces the next failure:
sign_in_token_already_used_code — "Sign in token has already been used."
This reproduces on iOS and not on Android, with identical code.
The mechanism is documented in Clerk's own
PR #7873 by manovotny:
clerk-jscallsonBeforeSetActive()during sign-out deliberately without the
'sign-out'intent (there is a comment explaining why).@clerk/nextjsskips itsinvalidateCacheAction()server action only when that intent is
'sign-out'— so forsignOut()the action fires.invalidateCacheAction()callscookies().delete()inside a server action, which in
Next.js 15+ re-renders the current page's RSC tree as part of the response.- In Safari that RSC delivery fails (
TypeError: Load failed), and Next.js falls back to a
hard browser navigation. - The reload restores the URL from Next's internal router state — token included — and the
page redeems the already-consumed token.
That PR proposes the fix (drop the && intent === 'sign-out' condition). It is closed and
never merged; the condition is still present in main today.
Note this also means history.replaceState is not a sufficient mitigation, because Next's
router state is not updated by it — as the PR itself points out.
Possibly related and still open:
clerk/javascript#9405 (invalidateCacheAction
firing on Next 16 + @clerk/nextjs 7.5.x–7.7.x, different symptom, same machinery).
What we shipped, in case it's useful to others
// 1. Guard must survive a full page RELOAD — not a re-render, not a remount.
// A useRef or module-scope flag is reborn in the new document.
const attemptKey = `ticket_attempt:${token.slice(-24)}`;
const alreadyAttempted = sessionStorage.getItem(attemptKey) !== null;
sessionStorage.setItem(attemptKey, "1");
// 2. If a previous document already spent it, don't redeem — recover.
if (alreadyAttempted) {
const live = clerk.client?.signedInSessions?.[0];
if (live) { await clerk.setActive({ session: live.id, navigate: ... }); return; }
}
// 3. Skip redemption entirely when the browser already holds the right user.
const existing = clerk.client?.signedInSessions ?? [];
const mine = existing.find(s => s.user?.id === expectedUserId);
if (mine) { await clerk.setActive({ session: mine.id, navigate: ... }); return; }
// 4. Otherwise clear server-side. removeSessions(), NOT signOut() — signOut()
// routes through onBeforeSetActive and triggers Defect 2.
if (existing.length > 0) await clerk.client.removeSessions();
const { error } = await signIn.ticket({ ticket: token });
Two things worth calling out for the docs:
clerk.client.signedInSessionsis the only reliable signal here.
useAuth().isSignedInis seeded from server-rendered state and readsfalseuntilclerk-js
has fetched/v1/client, so guarding on it means the clear never runs.- After a successful
POST /v1/client/sign_insthe session is attached to
clerk.client.signedInSessions, butclerk.sessionstaysnulluntilsetActiveruns — so
if (clerk.session)is the wrong recovery check.
What we're asking for
-
Ship PR #7873, or an equivalent fix. Right now
signOut()on Safari + Next.js 15/16 can
trigger a hard reload that replays the current URL. That is a general hazard, not specific to
ticket flows — any page holding one-time state in its query string is exposed. -
Reopen or comment on #8044. It is a real, reproducible defect that was closed by a bot
without a maintainer ever looking at it, and it has since been re-reported against 7.6.3. -
Fix the
accept-tokendocs example. As written it silently no-ops when a session already
exists. It should show the existing-session path — that is the common case in any embedded
browser, not an edge case. -
Is there a supported way to do native→webview session handoff? We're aware of
clerk-docs#2483 ("Clerk does not support
Webviews environments") and
clerk/javascript#3880. We also found a
closed, unmergedclerk-iosPR
(#357) addingprepareAuthenticatedWebURL()
backed byPOST /v1/client/prepare_webview, which is exactly this use case.Is
/v1/client/prepare_webviewlive on production FAPI? If so we would use it and delete
all of the above. If not, is a supported handoff on the roadmap? -
Would you consider a
signIn.ticket()option that replaces an existing session rather
than erroring? Every consumer of this API in an embedded browser has to hand-roll the
sign-out dance, and getting it wrong burns a single-use token.
Reproduction
Any production Clerk instance in single-session mode, Next.js 15/16 App Router:
- Sign in normally in Safari so a session exists.
- Mint a sign-in token via
POST /v1/sign_in_tokens. - Visit
/your-accept-page?token=<TOKEN>in the same browser. - Call
signIn.ticket({ ticket })→session_exists. - Call
await signOut()first, thensignIn.ticket({ ticket })→ in Safari,
sign_in_token_already_used_code, because the page reloaded and redeemed twice.
Android/Chrome does not reproduce step 5.
Environment
| | |
|---|---|
| `@clerk/nextjs` | 7.5.1 (also reported by others on 7.6.3 and 7.7.4) |
| `clerk-js` | 6.x, served from our custom FAPI domain |
| `@clerk/expo` | 4.2.3 |
| Next.js | 16, App Router, deployed on Cloudflare Workers via OpenNext |
| Instance | **Production**, custom FAPI domain, **single session mode** |
| Native | Expo SDK 57, `expo-web-browser` → SFSafariViewController (iOS) / Custom Tabs (Android) |
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Research direction
Start with docs/guides/development/custom-flows/authentication/embedded-email-links.mdx and core/clerk.ts, then trace sign-out through @clerk/nextjs's invalidateCacheAction and PR #7873. Reproduce the existing-session and Safari reload cases in the listed Next.js and Clerk setup. Done means the documented handoff handles an existing session without burning the token and sign-out no longer replays it.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- next.js, react, typescript
- Domain
- authentication, mobile, web-dev
- Issue type
- Bug
- Difficulty
- 5/5
- Estimated time
- Over a week
- Activity status
- Active
- Clarity
- Mostly clear
- Newbie friendliness
- 35/100