Hacktoberfest 2026:维护者为十月标记出来的 issue,仍然开放、适合新手。 浏览 Hacktoberfest issue

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

未关闭
#9,451 1 条评论 0 个 reaction 已指派 0 人 在 GitHub 查看

还没有人认领这个 Issue。

评估

难度
5/5
预计耗时
一周以上
新手友好度
35/100
Issue 类型
缺陷
描述清晰度
基本清楚
活跃度
活跃

调研方向

从 docs/guides/development/custom-flows/authentication/embedded-email-links.mdx 和 core/clerk.ts 开始,然后通过 @clerk/nextjs 的 invalidateCacheAction 和 PR #7873 跟踪 sign-out。使用列出的 Next.js 和 Clerk 设置复现已有会话和 Safari 重新加载的情况。当文档化的交接能够处理已有会话而不消耗 token,并且 sign-out 不再重放它时,就算完成。

由索引模型根据 Issue 内容生成。

描述

needs-triage
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 #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 #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) |
主要语言
TypeScript
星标
1.8k
派生
472
平均合并
2 天 12 小时
30 天内合并 PR
222

贡献指南

打开贡献指南

从这里开始

  1. 先读完整个 Issue,再读项目的贡献指南。
  2. 在 Issue 下留言说明你要接手 —— 这能避免两个人做同样的事。
  3. Fork 仓库,在一个分支上完成修改。
  4. 提交 Pull Request,并在描述里引用这个 Issue 编号。

clerk/javascript 的其他 Issue

查看 clerk/javascript 的全部 Issue

相似的 Issue

更多 TypeScript Issue

把新 issue 发到你的邮箱

精选适合新手参与的 GitHub issue 摘要。