三層模型確實有效。本系列第 1 部分是那個證明它的發票事件。第 2 部分是 proxy.ts 的 matcher 漏洞,會靜默失敗。第 3 部分是 mutation 層的驗證檢查,即使其他一切看起來都很穩固,它還是會被跳過。我的套件是把這套做在 Route Handlers 而不是 Server Actions,但原理完全相同:每個變更端點在碰資料之前,都會先驗證呼叫者。
我把這三層都正確地做完了。接著我想在上面做出一整套完整的驗證套件。
電子郵件與密碼登入。Google 和 GitHub OAuth。電子郵件驗證。密碼重設。依角色分流的路由。一個帶即時資料庫統計的管理員面板。以及一個真正的人會放心輸入真實密碼的 UI。
就在那時,整合 bug 出現了。不是架構 bug,而是接線 bug。這種 bug 只有在每個零件都接起來、真實流程真的跑完整條路徑時才會出現。
一共有五個。下面就是實際發生的情況。
登入回傳 200。回應裡有 token。客戶端寫入 cookie。下一個請求立刻又被導回登入頁。
我先從 JWT 下手。JWT 沒問題。proxy 也沒問題。只是下一次請求根本沒有 cookie。
實際發生的是這樣:成功登入之後,客戶端用 document.cookie = ... 寫 cookie。在 localhost 的某些瀏覽器與時序組合下,這個寫入會回報成功。document.cookie.includes('auth_tokens') 在賦值後立刻回傳 true。然後幾秒鐘後,就不見了。沒有錯誤,沒有警告。
問題不在時序,而是應該把寫入移到它本來就屬於的位置。
// app/api/auth/login/route.ts
const COOKIE_NAME = process.env.NEXT_PUBLIC_AUTH_COOKIE_NAME ?? "auth_tokens";
// ... after building responseBody and cookieTokens ...
const response = NextResponse.json(responseBody, { status: 200 });
const isSecure =
request.headers.get("x-forwarded-proto") === "https" ||
request.nextUrl.protocol === "https:";
const maxAge = rememberMe === true ? 60 * 60 * 24 * 30 : 60 * 60 * 24 * 7;
response.cookies.set(COOKIE_NAME, JSON.stringify(cookieTokens), {
httpOnly: true,
sameSite: "lax",
secure: isSecure,
path: "/",
maxAge,
});
return response;
httpOnly: true 的意思是頁面上的 JavaScript 完全無法讀取這個 cookie。就算有 XSS 漏洞注入的腳本,也無法竊取 session token,因為 token 對所有腳本都不可見。瀏覽器會從 Set-Cookie 回應標頭自動儲存它,不會再有那種悄悄消失的情況。
如同第 1 部分所說,proxy 讀的是 cookie 裡的 session,而不是 localStorage。httpOnly 一次解決兩個問題:proxy 看得到 cookie,客戶端腳本也碰不到它。
如果客戶端在寫一個本來就應該由伺服器來寫的東西,就別再除錯那段客戶端寫入了。把它移走。
Cookie 修好了,伺服器設的,httpOnly 也有。還是被導回登入頁。
登入 API 回傳 200 之後,表單原本是呼叫 window.location.href。但在找到這個修正之前,我先試過 router.push(redirectTo)。第二個 bug 就藏在那裡。
router.push 是客戶端導航,不會真的發出 HTTP 請求。瀏覽器沒有理由把剛剛設好的 cookie 附加到任何 outgoing 請求上,因為根本沒有經過網路。proxy.ts 只能看到真實的 HTTP 請求。留在 React router 裡的路由切換,對它來說是不可見的。這和第 2 部分解釋 matcher 漏洞為什麼在 proxy 層看不見是同一條邊界:proxy 無法檢查它從來沒看見的東西。
登入表單正是因為這個原因才使用 window.location.href:
// app/login/login-form.tsx
async function handleSubmit(e: FormEvent<HTMLFormElement>): Promise<void> {
e.preventDefault();
setError(null);
setIsSubmitting(true);
try {
await login(email.trim(), password, rememberMe);
window.location.href = redirectTo; // real HTTP GET, proxy sees the cookie
} catch (err) {
// ... error handling
setIsSubmitting(false);
}
}
window.location.href 會強制整頁重新載入,也就是一次真正的 HTTP GET。瀏覽器會附帶它持有的所有 cookie。proxy 看得到,因為這個請求真的有跨過網路。
sub 宣告裡放進了資料庫整數Cookie 正常送達。proxy 仍然拒絕 token。
除錯 log 顯示 JWT 解碼完全正常。每個 claim 都在。extractPayload 裡的型別守衛一直在 sub 上失敗。typeof sub !== 'string' 一直回傳 true,即使那個值看起來就是一個 user ID。
資料庫的 id 欄位是 SERIAL,也就是 Postgres 整數。當這個整數在沒有明確轉型的情況下傳給 jose 的 .setSubject() 時,它會被序列化成 token 裡的數字。extractPayload 函式會檢查 typeof sub !== "string",如果不符合就丟錯。它做得完全正確。問題是上游對整數主鍵的假設從來就不成立。
修正在 lib/auth/jwt.ts,signAccessToken 和 signRefreshToken 都要改:
// lib/auth/jwt.ts
export async function signAccessToken(
payload: SignablePayload,
): Promise<string> {
return (
new SignJWT({
role: payload.role,
email: payload.email,
permissions: payload.permissions,
})
.setProtectedHeader({ alg: "HS256" })
// Always cast to string here. Postgres ids that come through as
// numbers will otherwise get signed as numbers, and proxy.ts expects
// sub to always be a string when it verifies the token.
.setSubject(String(payload.sub))
.setIssuedAt()
.setExpirationTime(ACCESS_TOKEN_TTL)
.sign(ACCESS_SECRET)
);
}
在 signAccessToken 和 signRefreshToken 裡都加上 String(payload.sub)。就一個字。抓出這個問題的 extractPayload 型別守衛是對的。錯的是那個 claim 格式不正確,只是因為數字和數字字串在 console log 裡看起來一模一樣,所以不容易看出來。
如果測試資料用的是字串 ID,這個 bug 在開發環境裡不會出現。只有當真實的 SERIAL 欄位遇上會嚴格驗證 claim 型別的 JWT 函式庫時,它才會浮現。
電子郵件與密碼驗證已經端到端正常運作。現在可以加 Google 和 GitHub 了。
OAuth 流程本身文件寫得很清楚。真正要回答的問題是:如果某個原本已有密碼帳號的人,之後又用同一個 email 地址點了「Continue with Google」,會發生什麼事?
callback 路由處理三種不同情況:
// app/api/auth/google/callback/route.ts
// Case 1: they've signed in with Google before, google_id already set
let user = await db.queryOne<UserRow>(
`SELECT id, email, name, role, is_active, google_id
FROM authkit_test_users
WHERE google_id = $1`,
[googleUser.sub],
);
if (!user) {
// Case 2: no google_id match, but email matches an existing row
const existingByEmail = await db.queryOne<UserRow>(
`SELECT id, email, name, role, is_active, google_id
FROM authkit_test_users
WHERE email = $1`,
[normalizedEmail],
);
if (existingByEmail) {
// Linking is safe here because Google has already verified this
// email belongs to whoever is sitting at the browser right now.
// email_verified gets set true at the same time, in case they
// registered with a password earlier and never finished clicking
// their own verification link.
await db.query(
`UPDATE authkit_test_users
SET google_id = $1, email_verified = true
WHERE id = $2`,
[googleUser.sub, existingByEmail.id],
);
user = existingByEmail;
} else {
// Case 3: brand new person, create the row
// password_hash stays null, role always defaults to 'user'
const rows = await db.query<UserRow>(
`INSERT INTO authkit_test_users
(email, password_hash, name, role, is_active, email_verified, google_id)
VALUES ($1, NULL, $2, 'user', true, true, $3)
RETURNING id, email, name, role, is_active, google_id`,
[normalizedEmail, googleUser.name ?? null, googleUser.sub],
);
user = rows[0] ?? null;
}
}
第 2 種情況之所以安全,是因為 email 已經被對方驗證過了。Google 已經確認這個 email 屬於現在坐在瀏覽器前的人。這個保證比多數應用程式自己的驗證流程還強。這種模式不安全的版本,是只要有人在欄位裡輸入 email 就直接把帳號綁起來,完全沒有第三方驗證。
新帳號的角色一律預設為 'user'。沒有任何訪客能透過登入按鈕走到管理員權限。
GitHub 比 Google 多需要一步。Google 在基本 profile request 裡總會回傳已驗證 email,但 GitHub 不會。很多使用者會把 email 設成私人,所以 profile 回應裡會是 email: null。解法是再打一次 GitHub 的 emails endpoint,篩出同時標記為 primary 與 verified 的地址。沒有已驗證的 primary email,這個帳號就不能用這種方式登入。

同一個登入畫面上的兩個提供者。Bug 4 裡的三種帳號查詢方式,讓同一個 email 已經存在於密碼註冊時仍然安全。
這個問題只有在你同時做了 OAuth 和電子郵件/密碼登入,並把它們放進同一個產品時才會冒出來。
使用者先用 Google 註冊。幾個月後,他到忘記密碼頁面輸入 email,因為他真的忘了自己一直都是用 Google 按鈕進來,從來沒有設定過密碼。
如果沒有處理這種情況,對方就會收到一封針對「從未存在過的密碼」的密碼重設信。連結技術上能用,但整體體驗完全說不通,使用者只會比之前更困惑。
password_hash 欄位對每個純 OAuth 帳號都維持 null。判斷就是這個:
// app/api/auth/forgot-password/route.ts
if (!user.password_hash) {
// This account signed in through an OAuth provider and never set a
// password. Send a message pointing at whichever one they actually
// used rather than sending a reset link for a password that doesn't exist.
const provider = user.google_id
? "Google"
: user.github_id
? "GitHub"
: "Google or GitHub";
try {
await resend.emails.send({
from: process.env.RESEND_FROM_EMAIL ?? "[email protected]",
to: normalizedEmail,
subject: `Sign in with ${provider}`,
html: buildOAuthOnlyEmail({ name: user.name, provider }),
});
} catch {
// Email failed to send, the generic response goes out either way
}
return NextResponse.json(genericSuccessBody, { status: 200 });
}
buildOAuthOnlyEmail 函式也在同一個檔案裡。它會產生一封簡單的 HTML 電子郵件,告訴對方這個帳號是用那個提供者建立的,並把他們連回登入頁。沒有密碼重設連結,也沒有混亂。
不管走哪個分支,瀏覽器收到的回應都一樣:
const genericSuccessBody = {
message:
"If an account exists for this email, we've sent you instructions.",
};
同樣的訊息,同樣的 200 狀態,無論使用者有沒有密碼、是否使用 OAuth、或帳號根本不存在。這個端點絕對不該確認或否認你的系統裡有哪些 email 地址。

同一個頁面,對瀏覽器來說回應也一樣,無論提交的是哪個 email。對 password_hash 的分支判斷完全在伺服器端進行。
使用 bcrypt 雜湊的註冊、即時密碼強度條,以及條款勾選框。透過 Resend 的電子郵件驗證與密碼重設,並且各自有不同的 token 存活時間:驗證 24 小時、重設 1 小時。Google 和 GitHub OAuth,外加上面那套帳號綁定邏輯。依角色分流的路由。一個從資料庫拉即時統計數字的管理員面板。以及當有人直接打到自己角色不該看的路由時,顯示 403 頁面而不是直接崩潰。
第 1 部分提到的資料層擁有權模式,會跑在每一個查詢上。SQL 內直接寫 WHERE user_id = $1,而不是在結果外層包一層。第 3 部分提到的驗證檢查模式,會跑在每一個 mutation 端點上。先驗證呼叫者,再碰資料。這兩者都已經原封不動地放進這個套件,和本系列前面幾篇文章寫的一模一樣。

管理員面板中的即時資料庫統計。沒有 admin 角色的人如果直接打這個網址,會進到 403 頁面。
還有一個值得提的 Tailwind v4 問題,因為它完全不會報錯。Next.js 16 搭配 Turbopack 時,有個已知的檔案監看問題,會讓 Tailwind 的 class 偵測完全漏掉 .tsx 檔。建置正常、沒有警告,但 HTML 完全沒套樣式。修正方式如下:
/* app/globals.css — add after @import "tailwindcss" */
@source "../app/**/*.{ts,tsx}";
@source "../components/**/*.{ts,tsx}";
@source "../lib/**/*.{ts,tsx}";
三行,畫面就會正確渲染。
本系列第 1 到第 3 部分整理出了一份清單:那些在本機可用、到了正式環境就壞掉的東西。現在還要再加三項。
客戶端的 cookie 寫入可能會沒有任何錯誤訊息就直接消失。 把寫入移到伺服器端,用 response.cookies.set()。不管時序問題如何,這才是它該在的位置。
登入後使用 router.push 不會觸發 proxy。 如果 proxy 需要在下一個請求讀到新設定的 cookie,那個請求就必須是一次真正的 HTTP GET。請用 window.location.href。
在簽 JWT 前,把整數主鍵包成 String()。 用 .setSubject(String(payload.sub)),不要用 .setSubject(payload.sub)。SERIAL 欄位是整數。抓出這個問題的 JWT 型別守衛是正確的,錯的是 claim 格式不正確,只是不容易直接看出來。
本系列做出來的所有東西都整合成一個套件:三層安全、電子郵件與密碼、Google 和 GitHub OAuth、電子郵件驗證、密碼重設、依角色存取、管理員面板,以及部署指南。Next.js 16 Auth Kit at shubhra.dev
完整實作教學,包含所有程式碼檔案、資料庫 schema 與部署說明,請見 I Built a Next.js 16 Auth Kit. Every Edge Case I Found Is in Here
還有人在上線前碰過 OAuth 忘記密碼的邊界案例嗎?我是在這次實作之後才開始特別注意這件事。比我想像中常見。用 Google 註冊的人,真的會在之後跑去忘記密碼頁面,因為他們忘了自己最初是怎麼登入的。很好奇其他人是提早抓到,還是等使用者回報才發現。
註:圖片編修使用了 AI。