chundev
日期:2026-07-16 環境:Next.js 16 + better-auth 1.6.23 + Prisma 7 + PostgreSQL 17
better-auth 的 session sliding window(updateAge)每隔 N 秒只刷新一次。Next.js App Router 中的 server 端呼叫(RSC layout、API route handler)會搶走這次刷新——DB expiresAt 被延展,但 Set-Cookie 在 server 端被丟棄、永遠到不了瀏覽器。瀏覽器的 session_token cookie 從登入開始就沒被續過期,恰在 1 倍 expiresIn(60 分鐘)被強制登出。越活躍的使用者越容易中,因為越頻繁的頁面導航讓 RSC 越常搶贏 client polling。解法是 server 端一律以 disableRefresh: true 唯讀呼叫,把延展權完整留給 client 的 HTTP GET。
一個鑑識分析系統使用 better-auth 做 session 管理,server 端配置如下:
session: {
expiresIn: 60 * 60, // 60 分鐘(prod)
updateAge: 5 * 60, // 每 5 分鐘延展一次
cookieCache: {
enabled: true,
maxAge: 5 * 60, // cookie cache 5 分鐘
},
}
Client 端用 useSession() 的 refetchInterval: 3 * 60(3 分鐘)做 polling。此配置組合出一個 sliding window:活躍使用者的 session 應在每次 updateAge 到期時自動延展,理論上可無限存活。
系統上線後收到「持續操作仍被登出」的回報。歷經 8 次修復(閒置逾時調整、updateAge 從 30 分改 5 分、better-auth 從 1.6.11 升到 1.6.23、多分頁 race 修復、tRPC 路徑 disableRefresh 等),問題始終存在。
症狀極度反直覺:
第三點是解題關鍵:「操作越多越容易死」意味著某種操作在消耗而非補充 session 壽命。
better-auth 的 GET /api/auth/get-session endpoint(dist/api/routes/session.mjs)有一個關鍵的 needsRefresh 判定:
shouldBeUpdated = session.expiresAt - expiresIn + updateAge <= now()
needsRefresh = shouldBeUpdated && !disableRefresh
白話翻譯:距離上次刷新超過 updateAge(5 分鐘)。一旦 needsRefresh 為真,這次 GET 會:
internalAdapter.updateSession(token, { expiresAt: now + expiresIn })——DB 延展setSessionCookie(ctx, session, false, { maxAge })——Set-Cookie 回瀏覽器兩件事綁在一起。做了 1 就會做 2;做了 1 之後 expiresAt 被推遠,下一次判定就是 false——也就是說在一個 updateAge 週期內,只有搶到第一次的呼叫者能同時延展 DB 和瀏覽器 cookie。
Next.js App Router 的 RSC(Server Component)和 API route handler 中呼叫 auth.api.getSession() 是一個函式呼叫,不是 HTTP 往返。better-auth 內部仍然會執行 setSessionCookie(ctx, ...),但這個 ctx 是 better-auth 自己構造的 internal context——Set-Cookie header 被設到一個沒人讀的 response 物件上,永遠到不了瀏覽器。
正常路徑(client HTTP GET):
瀏覽器 → HTTP GET /api/auth/get-session → better-auth → Set-Cookie → 瀏覽器 ✅
server 端直呼(RSC / route handler):
Next.js server → auth.api.getSession() → better-auth → Set-Cookie → 丟棄 ❌
↓
DB expiresAt 已延展 ✅(但沒用)
用 prod 參數(expiresIn=3600s, updateAge=300s, refetchInterval=180s)推演:
T+0: 登入。session_token cookie maxAge=3600s
T+180: client GET polling → cache hit(cookieCache 5 分鐘)→ 不延展
T+300: cookieCache 過期
T+301: 使用者換頁 → RSC layout.tsx 的裸 getSession → cache miss → DB 查 →
shouldBeUpdated=true → DB expiresAt 延展至 T+3901 → Set-Cookie 丟棄 ❌
T+360: client GET polling → cache miss → DB 查 → shouldBeUpdated=false
(T+3901 - 3600 + 300 = T+601 > T+360)→ 不延展 ❌
T+600: 下一輪 shouldBeUpdated 門檻到達
T+601: 使用者再次換頁 → RSC 又搶走 → DB 延展 → Set-Cookie 丟棄 ❌
...重複...
T+3600: 瀏覽器 session_token cookie maxAge 到期 → 瀏覽器自動刪除 → 強制登出
DB 裡 session 還活著(RSC 一直延 expiresAt),但瀏覽器的 cookie 已經被丟了。 越活躍(越常換頁)→ RSC 在每個 updateAge 邊界搶贏 client 的機率越高 → cookie 越不可能被續期。
NODE_ENV === "development" 時 expiresIn 設為 30 天、updateAge 設為 4 小時。30 天的 cookie 壽命足以撐過任何開發 session,RSC 搶不搶走延展根本無所謂——cookie 還有 29 天多可以活。
第一次定位到根因時,只在 tRPC 的 createTRPCContext 加了 disableRefresh: true:
const session = await auth.api.getSession({
headers: opts.headers,
query: { disableRefresh: true },
});
→ tRPC 路徑不再搶走延展。但效果有限——問題依然存在。
盤點全 codebase 的 auth.api.getSession 呼叫點:
| 呼叫位置 | 數量 | 已修 |
|---|---|---|
tRPC createTRPCContext |
1 | ✅ |
| RSC layout/page | 7 | ❌ |
API route handler(src/app/api/) |
43 | ❌ |
| 總計 | 51 | 1/51 |
最致命的是 (sidebar)/layout.tsx——每次 SPA 換頁的 RSC prefetch 都會經過,在每個 updateAge 邊界幾乎必搶贏 client 的 3 分鐘 polling。
建立一個統一入口,所有 server 端呼叫必須走它:
// src/server/auth/get-server-session.ts
export async function getServerSession(headersOverride?: Headers) {
const session = await auth.api.getSession({
headers: headersOverride ?? (await headers()),
query: { disableRefresh: true },
});
if (!session) return null;
// 停用帳號複查
const user = await db.user.findUnique({
where: { id: session.user.id },
select: { disabled: true },
});
if (!user || user.disabled) return null;
return session;
}
codemod 全部 51 個呼叫點,加 ESLint no-restricted-syntax 規則禁止在 wrapper 以外裸呼 auth.api.getSession。
better-auth 1.6.23 有一個看起來完美的官方選項 session.deferSessionRefresh: true:
| deferSessionRefresh | wrapper + disableRefresh | |
|---|---|---|
| 機制 | GET 唯讀回 needsRefresh 旗標,client 的 session-atom 自動補發 POST 做刷新 |
全部 server 端 GET 帶 disableRefresh,client 的 GET inline 刷新 |
| 覆蓋範圍 | 一行設定全域生效 | 51 個呼叫點 codemod + ESLint 守門 |
| 上游依賴 | 依賴 session-atom 的 POST 行為 | 零上游依賴(disableRefresh 從 v1 就有) |
| 未來新呼叫點 | 自動安全 | 需 ESLint 擋(CI lint job) |
第一直覺是選 deferSessionRefresh——改動量最小、未來免疫。但實測發現兩個上游 bug 擋路:
session-atom 補發的 POST 不帶 body 和 content-type。Next.js 對 bodyless POST 仍會給一個非 null 的 body stream(BaseNextRequest 的 this.body = <raw IncomingMessage>),better-auth router 的 allowedMediaTypes: ["application/json"] gate 看到有 body stream 卻沒有 content-type → 回 415 Unsupported Media Type。
curl -X POST /api/auth/get-session → 415 ❌
curl -X POST /api/auth/get-session -d {} -H "Content-Type: application/json" → 200 ✅
get-session handler 的 cookie cache early-return(session.mjs:85)不檢查 isPostRequest。前一刻的 GET 剛回寫新 session_data cookie cache,POST 進來讀到 cache、直接返回——DB 不延展、Set-Cookie 不發生。POST 200,但等於什麼都沒做。
可以在 fetchOptions.onRequest 攔截 POST /get-session 並補 {} body + content-type + disableCookieCache=true。但三鏡頭抗辯審查一致否決這個方案:
| 鏡頭 | 翻案理由 |
|---|---|
| skeptic | 壓縮時間測試的 cache < poll 比例與 prod 的 cache > poll 相反,綠燈未驗證生產主路徑 |
| red-team | deferSessionRefresh 開啟後 METHOD_NOT_ALLOWED guard 失效(isPostRequest && !deferSessionRefresh → throw),未來 server 端誤傳 method: "POST" 會靜默重現原 bug;repro E2E 不在 CI |
| simplifier | 專案兩週半前(tRPC 修復)已驗證 disableRefresh 機制有效,wrapper 方案零上游依賴且 ESLint 守門可 CI 化 |
1.7.0-rc 實查:兩個 bug 仍在(原始碼未修)。上游 PR #9874(auto-enable deferSessionRefresh in nextCookies)至查證日仍未 merge。
| 方案 | 適用場景 |
|---|---|
deferSessionRefresh |
上游修復兩個 bug 後、或不用 Next.js 的框架 |
getServerSession wrapper |
現在、Next.js App Router + better-auth 1.6.x |
session.deleteMany({ where: { userId } }) 撤銷了 DB 中的 session row,但 better-auth 的 session_data cookie cache(cookieCache.maxAge,預設 5 分鐘)仍可能讓 getSession 回有效 session。
量化暴露:被停用帳號在 cookie cache 命中期間(≤5 分鐘)仍有全功能操作能力,不是降級/唯讀。
緩解:在 wrapper 內查 user.disabled(每次呼叫多一次 SELECT disabled FROM User WHERE id = ?,<50 並發場景可接受),把暴露窗口從「cache TTL」壓縮到「下一個請求即拒」。tRPC 路徑因為本來就查 user(含 permissions、roles),Prisma query engine 在同一請求週期會重用結果,實際不增加查詢。
better-auth admin plugin 建 impersonation session 時設 expiresAt = createdAt + 24h,但刷新路徑的 updateSession 會把 expiresAt 覆蓋為 now + 全域 expiresIn(1h) 並從此無限滑動——24 小時上限被打穿。修復前 RSC 搶走延展時 Set-Cookie 會丟失,cookie 壽命等於 24h 的初始 maxAge,bug 意外守住了上限。修復後 client GET 可靠延展,impersonation session 可無限續命。
緩解:databaseHooks.session.update.before hook 中,對 impersonatedBy 非空的 session clamp expiresAt = min(newExpiresAt, createdAt + 24h)。
session 被撤銷後(DB row 刪除、cookie cache 過期),useSession() 的 data 變 null。若 render 樹是 PermissionGate > AppFrame,PermissionGate 因 session null 判定「無權限」→ 渲染「存取被拒」→ AppFrame 整棵 unmount → 持有登出導向邏輯的 hook 永遠跑不到 → 使用者卡在死路。
緩解:PermissionGate 對 !isPending && !session 直接 router.push("/login?reason=timeout"),在無權限分支之前攔截。
用每秒 page.goto 換頁模擬「活躍使用者」會重現不出 bug。因為每次 full page load 讓 useSession remount,在 RSC 呼叫後 ~0.7 秒再打一次 GET——client 每個 updateAge 邊界有近半機率先到並拿到 Set-Cookie。這是測試法造成的假續命(hard-nav 讓 useSession 重新初始化),不是 prod 實況(SPA 軟導航 + 3 分鐘 polling)。
正確的活動模型:停留頁面 + 每秒 fetch 一個裸 getSession 的 API route——模擬 server 端搶走延展的機制,client polling 走自然節奏。
60 分鐘等不起。壓縮時間旋鈕把 expiresIn 壓到 60 秒、updateAge 10 秒、cookieCache 10 秒、refetchInterval 30 秒,僅 NODE_ENV !== "production" 生效。
[diag] T0:session cookie 將於 58s 後到期
[diag] t=+0.4s GET get-session status=200 set-cookie(session_token)=none
[diag] t=+31.7s GET get-session status=200 set-cookie(session_token)=none
[diag] 結束:get-session 6 筆回應中帶 session_token Set-Cookie 的 = 0 筆
[diag] 被登出時刻:T0+59.7s(expiresIn=60s,存活目標 2.5× = 150s)
→ FAIL:恰在 1× expiresIn 死亡
活動 API 每秒呼叫一次(server 端裸 getSession),client polling 30 秒一次——每個 10 秒邊界都是 server 先到。0/6 GET 帶 Set-Cookie = cookie 從未被延展。
[diag] T0:session cookie 將於 58s 後到期
[diag] t=+31.6s GET get-session status=200 set-cookie(session_token)=YES
[diag] t=+61.6s GET get-session status=200 set-cookie(session_token)=YES
[diag] t=+91.6s GET get-session status=200 set-cookie(session_token)=YES
[diag] t=+121.6s GET get-session status=200 set-cookie(session_token)=YES
[diag] 結束:get-session 9 筆中帶 Set-Cookie 的 = 4 筆
→ PASS:存活滿 150s(2.5× expiresIn),4/4 邊界全延展
Server 端全唯讀後,client 的 GET 在每個 30 秒 poll 邊界搶回 needsRefresh、inline 帶 Set-Cookie。
壓縮參數的 cache(10) < poll(30) 與 prod 的 cache(300) > poll(180) 比例相反,為了驗證 cache-hit 主導 regime 也安全,用 120/20/20/12(cache(20) > poll(12))重跑:
刷新每 ~24s 一次(理論值 ceil(updateAge/poll)×poll = 24s 精確吻合)
get-session 29 筆中帶 Set-Cookie 的 = 12 筆
→ PASS:存活滿 300s(2.5×120)
prod 參數下(3600/300/300/180)推演:cache TTL(300) < 2×poll(360),保證每 360 秒至少一次 cache miss 落回 DB path,且此時 updateAge(300) 必已超過 → needsRefresh=true,實際延展發生。相對 3600 秒硬上限有約 10 倍安全餘裕。
auth.api.getSession() 的 Set-Cookie 是靜默丟棄的——better-auth 內部執行了 setSessionCookie,但 internal context 的 response 沒有人讀。DB 被延展、瀏覽器 cookie 不動,兩者的 lifetime 從此岔開needsRefresh 是 first-come-first-served——一個 updateAge 週期內只有一次機會,誰先到誰拿走。51 個 server 端呼叫點 vs 1 個 client 3 分鐘 polling,server 幾乎必搶贏disableRefresh: true 是最乾淨的解法——讓 server 端唯讀,把延展權完整留給 client。比 deferSessionRefresh(需繞過兩個上游 bug)更簡單、更穩定、零外部依賴cookieCache.enabled 在 stateful(DB-backed)部署下 refreshCache 會被 better-auth 強制關閉——cache 有界(TTL 到期即失效),不會自我續命。但這也意味著 cache 命中期間不會刷新 session 狀態,停用/撤銷要等 cache 過期才生效useSession、製造假續命,正確做法是 SPA 停留 + fetch API route 模擬 server 端搶奪better-auth(和類似的 cookie-based session 框架)在 Next.js App Router 時代面臨一個結構性矛盾:RSC 與 route handler 是 server 端函式呼叫,不是 HTTP 往返,但 session 續期依賴 HTTP 的 Set-Cookie 機制。Next.js 的 RSC 本身不能寫 cookie——HTTP 在 streaming 開始後不允許 Set-Cookie——這不是 better-auth 的 bug,是 RSC 架構的根本限制。
解題的核心原則:區分「驗證」與「延展」。Server 端只做驗證(唯讀),延展交給 client 的 HTTP 往返(能回 Set-Cookie 給瀏覽器)。任何在 server 端「順便」延展 session 的設計,在 RSC/server action 的世界裡都是一個定時炸彈。
expiresIn / updateAge / cookieCache / deferSessionRefresh 等選項的權威說明deferSessionRefresh 功能的需求起源:GET 不應做寫入disableRefresh、deferSessionRefresh、cookieCache 子選項)