Skip to content

Sign-in token redemption is unusable in an in-app browser: session_exists, then sign_in_token_already_used #9451

Description

@scoobydrew83

Preliminary Checks

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:

  1. POST /v1/sign_in_tokens with { user_id, expires_in_seconds: 60 }
  2. Open https://app.example.com/sign-in-with-token?token=<TOKEN>&redirect_url=/settings/privacy
  3. On that page, call signIn.ticket({ ticket }) then signIn.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:

  1. clerk-js calls onBeforeSetActive() during sign-out deliberately without the
    'sign-out' intent (there is a comment explaining why).
  2. @clerk/nextjs skips its invalidateCacheAction() server action only when that intent is
    'sign-out'
    — so for signOut() the action fires.
  3. invalidateCacheAction() calls cookies().delete() inside a server action, which in
    Next.js 15+ re-renders the current page's RSC tree as part of the response.
  4. In Safari that RSC delivery fails (TypeError: Load failed), and Next.js falls back to a
    hard browser navigation.
  5. 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.signedInSessions is the only reliable signal here.
    useAuth().isSignedIn is seeded from server-rendered state and reads false until clerk-js
    has fetched /v1/client, so guarding on it means the clear never runs.
  • After a successful POST /v1/client/sign_ins the session is attached to
    clerk.client.signedInSessions, but clerk.session stays null until setActive runs — so
    if (clerk.session) is the wrong recovery check.

What we're asking for

  1. Ship PR fix(nextjs): skip invalidateCacheAction on Next.js 15+ for all auth transitions #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.

  2. Reopen or comment on signIn.create({ strategy: 'ticket' }) silently fails when user has an active session #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.

  3. Fix the accept-token docs 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.

  4. 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, unmerged clerk-ios PR
    (#357) adding prepareAuthenticatedWebURL()
    backed by POST /v1/client/prepare_webview, which is exactly this use case.

    Is /v1/client/prepare_webview live on production FAPI? If so we would use it and delete
    all of the above. If not, is a supported handoff on the roadmap?

  5. 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:

  1. Sign in normally in Safari so a session exists.
  2. Mint a sign-in token via POST /v1/sign_in_tokens.
  3. Visit /your-accept-page?token=<TOKEN> in the same browser.
  4. Call signIn.ticket({ ticket })session_exists.
  5. Call await signOut() first, then signIn.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) |

Metadata

Metadata

Assignees

No one assigned

    Labels

    needs-triageA ticket that needs to be triaged by a team member

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions