Sign-in token redemption is unusable in an in-app browser: `session_exists`, then `sign_in_token_already_used`
Nessuno ha ancora preso questa issue.
Valutazione
- Difficoltà
- 5/5
- Tempo stimato
- Più di una settimana
- Idoneità per principianti
- 35/100
- Tipo di issue
- Bug
- Chiarezza
- Abbastanza chiara
- Stato di attività
- Attiva
- Stack tecnologico
- next.js, react, typescript
- Ambito
- authentication, mobile, web-dev
Direzione di ricerca
Inizia da docs/guides/development/custom-flows/authentication/embedded-email-links.mdx e core/clerk.ts, quindi segui il sign-out attraverso invalidateCacheAction di @clerk/nextjs e PR #7873. Riproduci i casi di sessione esistente e di ricaricamento in Safari nella configurazione Next.js e Clerk indicata. Il lavoro è completato quando il passaggio documentato gestisce una sessione esistente senza consumare il token e il sign-out non lo riproduce più.
Scritto dal modello di indicizzazione a partire dal testo della issue.
Descrizione
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) |
- Lingua principale
- TypeScript
- Stelle
- 1.8k
- Fork
- 472
- Merge medio
- 2g 12h
- PR unite (30g)
- 222
Guida per i contributori
Apri la guida per i contributori
Come iniziare
- Leggi tutta la issue e poi la guida ai contributi del progetto.
- Commenta sulla issue per dire che te ne occupi tu — evita che due persone facciano lo stesso lavoro.
- Fai un fork del repository e lavora su un branch.
- Apri una pull request che faccia riferimento al numero della issue.
Altre issue di clerk/javascript
-
Difficoltà 2/5 1-3 ore Idoneità per principianti 74/100
clerk/javascript#9611 · 1 commento ·
-
clerk/javascript#9891 · 1 assegnatario ·
-
Difficoltà 4/5 3-5 giorni Idoneità per principianti 64/100
clerk/javascript#9775 · 1 commento ·
-
Difficoltà 4/5 3-5 giorni Idoneità per principianti 48/100
clerk/javascript#9770 · 3 commenti ·
-
Difficoltà 3/5 1-2 giorni Idoneità per principianti 68/100
clerk/javascript#9667 · 1 commento ·
Tutte le issue di clerk/javascript
Issue simili
-
Difficoltà 2/5 1-3 ore Idoneità per principianti 70/100
-
Difficoltà 2/5 1-3 ore Idoneità per principianti 75/100
mksglu/context-mode#1200 ·
-
Difficoltà 2/5 1-3 ore Idoneità per principianti 75/100
jaegertracing/jaeger-ui#4506 ·
-
area:desktop area:ui bug platform:macos
Difficoltà 2/5 1-3 ore Idoneità per principianti 75/100
anthropics/claude-code#96687 ·
-
good first issue
Difficoltà 1/5 Meno di un'ora Idoneità per principianti 95/100
AOSSIE-Org/DebateAI#582 · 2 commenti ·