Next.js 16 with Separate Backend
How to Handle Public / Private Routes Without Direct DB Access — Six Experts Debate
"The BFF pattern is not a workaround — it's the correct abstraction layer between your user's browser and your backend services." — Sam Lambert, CEO of PlanetScale
「BFF 模式不是權宜之計——它是使用者瀏覽器與後端服務之間正確的抽象層。」—— Sam Lambert,PlanetScale CEO
The Setup
Last time, we debated public/private routes in a "full-stack" Next.js 16 app — one where the frontend has direct database access, a lib/dal.ts that calls verifySession() with the JWT secret, and a lib/session.ts that signs and verifies tokens. The three-layer defense (proxy.ts → Layout → DAL) was unanimous.
But what if your Next.js 16 app is just the frontend?
- The backend is a separate service (Java, Go, Python, .NET — doesn't matter)
- Authentication is JWT-based: you POST credentials, get back an access token and a refresh token
- Every API call sends the JWT as
Authorization: Bearer <token> - When the token expires, you get a
401 - The frontend does not have the JWT secret. It cannot decrypt or verify the token.
In this architecture, lib/dal.ts with verifySession() is impossible. import 'server-only' and jwtVerify(token, secret) — you don't have the secret. The entire three-layer defense from the last article collapses.
Or does it?
We brought back five experts and added one more to debate how the three-layer defense adapts — or doesn't — to a frontend-backend separation architecture.
問題設定
上一次,我們辯論的是「全端」Next.js 16 應用的 public/private routes——前端可以直接存取資料庫,有一個用 JWT secret 呼叫 verifySession() 的 lib/dal.ts,有一個簽署和驗證 token 的 lib/session.ts。三層防禦(proxy.ts → Layout → DAL)獲得全票通過。
但如果你的 Next.js 16 應用只是前端呢?
- 後端是獨立服務(Java、Go、Python、.NET——不重要)
- 認證是 JWT 制:POST 憑證,拿回 access token 和 refresh token
- 每個 API 呼叫都把 JWT 當
Authorization: Bearer <token>送出 - Token 過期時,你會拿到
401 - 前端沒有 JWT secret。它無法解密或驗證 token。
在這個架構下,lib/dal.ts 搭配 verifySession() 是不可能的。import 'server-only' 和 jwtVerify(token, secret)——你沒有 secret。上一篇文章的整個三層防禦全部崩塌。
真的是這樣嗎?
我們找回了五位專家,再加入一位新成員,來辯論三層防禦在前後端分離架構下該怎麼適應——還是根本不適用。
Roundtable Participants
- Lina Torres — Staff Engineer at a fintech startup. Returning from the last roundtable. Still believes in three layers. Now faces the challenge: her company's new product uses a separate Go backend.
- Kevin Wu — Performance engineer, ex-Vercel. Returning. Still obsessed with TTFB. Wants to know the latency cost of the BFF hop.
- Priya Sharma — Full-stack lead at a multi-tenant SaaS. Returning. Her team recently migrated from monolithic Next.js to a separated architecture.
- Ryan O'Brien — Open source auth library maintainer. Returning. Has strong opinions about where tokens should live.
- Tomás García — DevRel engineer and educator. Returning. Has seen every wrong way to store a JWT.
- Mei Chen — New. Security engineer at a large e-commerce platform. Specializes in OAuth 2.0, token storage, and BFF security patterns. Thinks most frontend developers fundamentally misunderstand token security.
圓桌會議參與者
- Lina Torres — 金融科技新創 Staff Engineer。從上一場圓桌回歸。仍然相信三層防禦。現在面臨新挑戰:她公司的新產品使用獨立的 Go 後端。
- Kevin Wu — 效能工程師,前 Vercel 員工。回歸。仍然執著於 TTFB。想知道 BFF 中轉的延遲成本。
- Priya Sharma — 多租戶 SaaS 全端 lead。回歸。她的團隊最近從單體 Next.js 遷移到了分離架構。
- Ryan O'Brien — 開源認證庫維護者。回歸。對 token 該存在哪裡有強烈看法。
- Tomás García — DevRel 工程師兼教育者。回歸。見過所有存 JWT 的錯誤方式。
- Mei Chen — 新加入。 大型電商平台的安全工程師。專精 OAuth 2.0、token 儲存和 BFF 安全模式。認為大多數前端開發者從根本上就誤解了 token 安全。
Round 1: Where Should the JWT Live?
Moderator: Let's start with the most fundamental question. The backend gives you a JWT. Where does the frontend store it?
Tomás: I'll list the three options and let everyone fight:
Option A: localStorage
- Browser JavaScript can read/write it
- Persists across page reloads
- proxy.ts CANNOT access it (server-side, no browser APIs)
Option B: httpOnly Secure Cookie
- Browser JavaScript CANNOT read it
- Automatically sent with same-origin requests
- proxy.ts CAN access it via request.cookies
Option C: In-Memory (JavaScript variable / React state)
- Hardest for XSS to steal
- Lost on every page refresh
- proxy.ts CANNOT access itMei: Before anyone speaks. Let me be blunt. localStorage is a security defect, not a design choice. A single XSS vulnerability — one compromised npm package, one reflected input, one improperly sanitized CMS field — and the attacker has your JWT. They can exfiltrate it and use it from any machine, any country, for its entire lifetime. There is no mitigation. There is no "but we sanitize our inputs." Supply chain attacks are real. localStorage.getItem('token') is one line of code.
Kevin: That's strong. Are you saying no production app should ever use localStorage for tokens?
Mei: I'm saying that if you store a JWT in localStorage, you've made a deliberate decision to accept that any XSS vulnerability in your entire dependency tree — including every npm package you transitively depend on — is a complete account takeover. If your threat model accepts that, fine. Most threat models shouldn't.
Ryan: I largely agree, but let me push back on one thing. httpOnly cookies are not immune to attack. CSRF is still a vector if SameSite is misconfigured. And cookies have a 4KB size limit — some JWTs with extensive claims blow past that.
Mei: CSRF with SameSite: lax requires the attacker to trick the user into clicking a link that performs a state-changing GET request. If your app follows HTTP semantics (GET is safe, POST for mutations), SameSite: lax is sufficient. And if your JWT is over 4KB, you have an architectural problem — your token has too many claims. Trim it.
Lina: So the answer is httpOnly cookies. But here's the real question: who sets the cookie? My Go backend returns { "accessToken": "eyJ...", "refreshToken": "eyJ..." } in the JSON response body. It doesn't set cookies — it's a REST API designed for mobile apps, SPAs, and microservices. I can't change the backend to set cookies because mobile clients don't use cookies.
Priya: Exactly. This is the real-world constraint everyone ignores. The backend team says "we give you a JWT, how you store it is your problem." They're not wrong — they serve iOS, Android, and three different web frontends. They can't set cookies for just one client.
Tomás: And that's where the BFF pattern enters. But let's vote on the token storage first.
第一回合:JWT 該存在哪裡?
主持人: 先從最根本的問題開始。後端給你一個 JWT。前端要存在哪?
Tomás: 我列出三個選項,大家來吵:
選項 A:localStorage
- 瀏覽器 JavaScript 可以讀寫
- 跨頁面刷新持久化
- proxy.ts 無法存取(伺服器端,沒有瀏覽器 API)
選項 B:httpOnly Secure Cookie
- 瀏覽器 JavaScript 無法讀取
- 隨同源請求自動送出
- proxy.ts 可以透過 request.cookies 存取
選項 C:記憶體內(JavaScript 變數 / React state)
- 最難被 XSS 竊取
- 每次頁面刷新就遺失
- proxy.ts 無法存取Mei: 搶在所有人之前。 讓我直說。localStorage 是一個安全缺陷,不是設計選擇。 一個 XSS 漏洞——一個被入侵的 npm 套件、一個反射型輸入、一個沒有正確過濾的 CMS 欄位——攻擊者就拿到你的 JWT。他們可以把它外洩並從任何機器、任何國家使用,在它整個生命週期內都有效。沒有緩解措施。沒有「但我們有過濾輸入」這回事。供應鏈攻擊是真的。localStorage.getItem('token') 只需要一行程式碼。
Kevin: 這說得很重。你是說沒有任何生產應用應該用 localStorage 存 token?
Mei: 我是說,如果你把 JWT 存在 localStorage,你就做了一個刻意的決定:接受你整個依賴樹中的任何 XSS 漏洞——包括你間接依賴的每個 npm 套件——都是一次完整的帳號接管。如果你的威脅模型接受這個,那沒問題。大多數威脅模型不應該接受。
Ryan: 我大致同意,但讓我反駁一點。httpOnly cookie 也不是攻擊免疫。如果 SameSite 設定錯誤,CSRF 仍然是一個攻擊向量。而且 cookie 有 4KB 大小限制——某些帶有大量 claims 的 JWT 會超過。
Mei: 使用 SameSite: lax 的 CSRF 要求攻擊者誘騙使用者點擊一個執行狀態變更 GET 請求的連結。如果你的應用遵循 HTTP 語意(GET 是安全的,POST 用於變更),SameSite: lax 就足夠了。如果你的 JWT 超過 4KB,你有架構問題——你的 token 有太多 claims。精簡它。
Lina: 所以答案是 httpOnly cookie。但真正的問題是:誰來設定 cookie? 我的 Go 後端在 JSON response body 裡回傳 { "accessToken": "eyJ...", "refreshToken": "eyJ..." }。它不設定 cookie——它是一個為行動應用、SPA 和微服務設計的 REST API。我無法改後端來設定 cookie,因為行動端客戶不用 cookie。
Priya: 正是如此。 這就是所有人都忽略的真實世界限制。後端團隊說「我們給你 JWT,你怎麼存是你的問題。」他們沒有錯——他們服務 iOS、Android 和三個不同的 Web 前端。他們不能只為一個客戶端設定 cookie。
Tomás: 這就是 BFF 模式登場的地方。但讓我們先投票 token 存儲。
Vote: Where Should JWTs Be Stored in a Separated Architecture?
| Voter | Position | Reasoning |
|---|---|---|
| Mei | httpOnly cookie via BFF | "Non-negotiable for user-facing apps" |
| Lina | httpOnly cookie via BFF | "I've been burned by XSS before" |
| Ryan | httpOnly cookie via BFF | "Auth libraries should enforce this" |
| Kevin | httpOnly cookie via BFF | "Also enables proxy.ts to work" |
| Priya | httpOnly cookie via BFF | "Multi-tenant apps need server-side token access" |
| Tomás | httpOnly cookie via BFF | "But I understand why tutorials use localStorage — it's simpler to teach" |
Result: 6-0. Unanimous. httpOnly cookies via the BFF pattern. localStorage is rejected for production apps with real users.
投票:在前後端分離架構中 JWT 該存在哪裡?
| 投票者 | 立場 | 理由 |
|---|---|---|
| Mei | 透過 BFF 的 httpOnly cookie | 「對面向使用者的應用沒有商量餘地」 |
| Lina | 透過 BFF 的 httpOnly cookie | 「我被 XSS 燒過」 |
| Ryan | 透過 BFF 的 httpOnly cookie | 「認證庫應該強制這個模式」 |
| Kevin | 透過 BFF 的 httpOnly cookie | 「也讓 proxy.ts 能運作」 |
| Priya | 透過 BFF 的 httpOnly cookie | 「多租戶應用需要伺服器端的 token 存取」 |
| Tomás | 透過 BFF 的 httpOnly cookie | 「但我理解為什麼教學用 localStorage——教起來比較簡單」 |
結果:6-0。全票通過。透過 BFF 模式的 httpOnly cookie。localStorage 在有真實使用者的生產應用中被否決。
Round 2: The BFF Pattern — Next.js as a Token Proxy
Moderator: Everyone agreed on BFF. Show me the code. How does Next.js become a BFF?
Lina: The core idea: the browser never sees the JWT. Here's the flow:
Browser Next.js (BFF) Backend API
| | |
|-- POST /api/auth/login --> | |
| { email, password } |-- POST /auth/login --------> |
| | { email, password } |
| | |
| | <-- { accessToken, |
| | refreshToken } |
| | |
| <-- Set-Cookie: httpOnly | |
| session=<accessToken> | |
| 200 OK { user } | |
| | |
|-- GET /api/proxy/users --> | |
| (cookie sent auto) |-- GET /users --------------> |
| | Authorization: Bearer <T> |
| | |
| | <-- { users: [...] } |
| <-- { users: [...] } | |The login Route Handler:
// app/api/auth/login/route.ts
import { NextResponse } from 'next/server'
const BACKEND_URL = process.env.BACKEND_API_URL!
export async function POST(request: Request) {
const body = await request.json()
const backendRes = await fetch(`${BACKEND_URL}/auth/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
})
if (!backendRes.ok) {
const error = await backendRes.json()
return NextResponse.json(error, { status: backendRes.status })
}
const data = await backendRes.json()
// The browser NEVER sees these tokens
const response = NextResponse.json({ user: data.user })
response.cookies.set('access-token', data.accessToken, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
maxAge: 900, // 15 minutes
path: '/',
})
response.cookies.set('refresh-token', data.refreshToken, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
maxAge: 60 * 60 * 24 * 7, // 7 days
path: '/',
})
return response
}Kevin: Immediately. I see two problems. First, every API call now has an extra network hop: browser → Next.js → backend. That's additional latency. For a request that takes 50ms directly, it might take 70-80ms through the BFF. At scale, this adds up.
Lina: How much latency are we talking about?
Kevin: If Next.js and the backend are in the same data center or VPC — 1-5ms overhead per hop. If they're in different regions — 20-50ms. The BFF should be deployed as close to the backend as possible. Same VPC is ideal. Same machine (sidecar pattern) is even better.
Mei: The latency cost buys you something concrete: the JWT never reaches the browser's JavaScript runtime. Even if you have a full XSS compromise — the attacker owns your DOM, can inject any script — they cannot extract the token. The cookie is httpOnly. The worst they can do is make requests through the browser while the user's session is active. They cannot exfiltrate the token and use it from their own machine.
Priya: Here's the generic API proxy that forwards everything:
// app/api/proxy/[...path]/route.ts
import { cookies } from 'next/headers'
import { NextRequest, NextResponse } from 'next/server'
const BACKEND_URL = process.env.BACKEND_API_URL!
async function handler(request: NextRequest, { params }: { params: Promise<{ path: string[] }> }) {
const { path } = await params
const pathname = path.join('/')
const cookieStore = await cookies()
const accessToken = cookieStore.get('access-token')?.value
const url = new URL(`/${pathname}`, BACKEND_URL)
request.nextUrl.searchParams.forEach((value, key) => {
url.searchParams.set(key, value)
})
const headers: HeadersInit = {
'Content-Type': request.headers.get('Content-Type') || 'application/json',
}
if (accessToken) {
headers['Authorization'] = `Bearer ${accessToken}`
}
let body: string | null = null
if (request.method !== 'GET' && request.method !== 'HEAD') {
body = await request.text()
}
const backendRes = await fetch(url.toString(), {
method: request.method,
headers,
body,
})
// 401? Try refresh.
if (backendRes.status === 401) {
const refreshed = await attemptRefresh(cookieStore)
if (refreshed) {
headers['Authorization'] = `Bearer ${refreshed.accessToken}`
const retryRes = await fetch(url.toString(), {
method: request.method,
headers,
body,
})
const retryData = await retryRes.text()
const response = new NextResponse(retryData, {
status: retryRes.status,
headers: { 'Content-Type': retryRes.headers.get('Content-Type') || 'application/json' },
})
response.cookies.set('access-token', refreshed.accessToken, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
maxAge: 900,
path: '/',
})
return response
}
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const responseData = await backendRes.text()
return new NextResponse(responseData, {
status: backendRes.status,
headers: { 'Content-Type': backendRes.headers.get('Content-Type') || 'application/json' },
})
}
async function attemptRefresh(cookieStore: any) {
const refreshToken = cookieStore.get('refresh-token')?.value
if (!refreshToken) return null
try {
const res = await fetch(`${BACKEND_URL}/auth/refresh`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${refreshToken}`,
},
})
if (!res.ok) return null
return await res.json()
} catch {
return null
}
}
export const GET = handler
export const POST = handler
export const PUT = handler
export const DELETE = handler
export const PATCH = handlerRyan: That's the entire BFF in two files. Login handler + catch-all proxy. The browser calls /api/proxy/users, the Route Handler reads the httpOnly cookie, adds the Authorization header, and forwards to the backend. The browser never knows the JWT value.
Tomás: For beginners: this is like a hotel concierge. The guest (browser) says "I need something from the restaurant." The concierge (Next.js BFF) takes the request, adds the VIP credential, gets the response, and hands it back. The guest never sees the VIP credential.
第二回合:BFF 模式——Next.js 作為 Token 代理
主持人: 大家都同意 BFF。給我看程式碼。Next.js 怎麼變成 BFF?
Lina: 核心理念:瀏覽器永遠看不到 JWT。流程如下:
瀏覽器 Next.js(BFF) 後端 API
| | |
|-- POST /api/auth/login --> | |
| { email, password } |-- POST /auth/login --------> |
| | { email, password } |
| | |
| | <-- { accessToken, |
| | refreshToken } |
| | |
| <-- Set-Cookie: httpOnly | |
| session=<accessToken> | |
| 200 OK { user } | |
| | |
|-- GET /api/proxy/users --> | |
| (cookie 自動送出) |-- GET /users --------------> |
| | Authorization: Bearer <T> |
| | |
| | <-- { users: [...] } |
| <-- { users: [...] } | |登入 Route Handler:
// app/api/auth/login/route.ts
import { NextResponse } from 'next/server'
const BACKEND_URL = process.env.BACKEND_API_URL!
export async function POST(request: Request) {
const body = await request.json()
const backendRes = await fetch(`${BACKEND_URL}/auth/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
})
if (!backendRes.ok) {
const error = await backendRes.json()
return NextResponse.json(error, { status: backendRes.status })
}
const data = await backendRes.json()
// 瀏覽器永遠看不到這些 token
const response = NextResponse.json({ user: data.user })
response.cookies.set('access-token', data.accessToken, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
maxAge: 900, // 15 分鐘
path: '/',
})
response.cookies.set('refresh-token', data.refreshToken, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
maxAge: 60 * 60 * 24 * 7, // 7 天
path: '/',
})
return response
}Kevin: 立刻反應。 我看到兩個問題。第一,每個 API 呼叫現在多了一個網路跳轉:瀏覽器 → Next.js → 後端。那是額外延遲。一個直接請求 50ms 的,通過 BFF 可能要 70-80ms。在規模化時,這會累積。
Lina: 我們在討論多少延遲?
Kevin: 如果 Next.js 和後端在同一個資料中心或 VPC——每次跳轉 1-5ms 的開銷。如果在不同區域——20-50ms。BFF 應該盡量靠近後端部署。同 VPC 是理想的。同一台機器(sidecar 模式)更好。
Mei: 延遲成本買到了一個具體的東西:JWT 永遠不會到達瀏覽器的 JavaScript 運行環境。 即使你被完全 XSS 攻陷——攻擊者擁有你的 DOM,可以注入任何腳本——他們無法取得 token。Cookie 是 httpOnly 的。他們最多只能在使用者 session 活躍期間透過瀏覽器發出請求。他們無法外洩 token 然後從自己的機器使用。
Priya: 這是轉發所有請求的通用 API 代理:
// app/api/proxy/[...path]/route.ts
import { cookies } from 'next/headers'
import { NextRequest, NextResponse } from 'next/server'
const BACKEND_URL = process.env.BACKEND_API_URL!
async function handler(request: NextRequest, { params }: { params: Promise<{ path: string[] }> }) {
const { path } = await params
const pathname = path.join('/')
const cookieStore = await cookies()
const accessToken = cookieStore.get('access-token')?.value
const url = new URL(`/${pathname}`, BACKEND_URL)
request.nextUrl.searchParams.forEach((value, key) => {
url.searchParams.set(key, value)
})
const headers: HeadersInit = {
'Content-Type': request.headers.get('Content-Type') || 'application/json',
}
if (accessToken) {
headers['Authorization'] = `Bearer ${accessToken}`
}
let body: string | null = null
if (request.method !== 'GET' && request.method !== 'HEAD') {
body = await request.text()
}
const backendRes = await fetch(url.toString(), {
method: request.method,
headers,
body,
})
// 401?嘗試 refresh。
if (backendRes.status === 401) {
const refreshed = await attemptRefresh(cookieStore)
if (refreshed) {
headers['Authorization'] = `Bearer ${refreshed.accessToken}`
const retryRes = await fetch(url.toString(), {
method: request.method,
headers,
body,
})
const retryData = await retryRes.text()
const response = new NextResponse(retryData, {
status: retryRes.status,
headers: { 'Content-Type': retryRes.headers.get('Content-Type') || 'application/json' },
})
response.cookies.set('access-token', refreshed.accessToken, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
maxAge: 900,
path: '/',
})
return response
}
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const responseData = await backendRes.text()
return new NextResponse(responseData, {
status: backendRes.status,
headers: { 'Content-Type': backendRes.headers.get('Content-Type') || 'application/json' },
})
}
async function attemptRefresh(cookieStore: any) {
const refreshToken = cookieStore.get('refresh-token')?.value
if (!refreshToken) return null
try {
const res = await fetch(`${BACKEND_URL}/auth/refresh`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${refreshToken}`,
},
})
if (!res.ok) return null
return await res.json()
} catch {
return null
}
}
export const GET = handler
export const POST = handler
export const PUT = handler
export const DELETE = handler
export const PATCH = handlerRyan: 就這兩個檔案,BFF 就完成了。登入處理 + catch-all 代理。瀏覽器呼叫 /api/proxy/users,Route Handler 讀取 httpOnly cookie,加上 Authorization 標頭,轉發到後端。瀏覽器永遠不知道 JWT 的值。
Tomás: 給初學者打個比方:這就像飯店的禮賓服務。客人(瀏覽器)說「我需要餐廳的東西。」禮賓(Next.js BFF)接受請求、加上 VIP 憑證、拿到回應、交回給客人。客人永遠看不到 VIP 憑證。
Vote: Is the BFF Proxy Pattern Worth the Extra Hop?
| Voter | Position | Reasoning |
|---|---|---|
| Mei | Absolutely yes | "Security is not optional" |
| Lina | Yes | "1-5ms same-VPC overhead is nothing" |
| Ryan | Yes | "Enables the full Next.js Server Component model" |
| Priya | Yes | "We migrated to BFF and never looked back" |
| Tomás | Yes | "The alternative (localStorage + client-only auth) is worse in every way" |
| Kevin | Yes, conditionally | "Same VPC or sidecar only. Cross-region BFF is a latency disaster" |
Result: 6-0 (with Kevin's caveat). BFF is the recommended pattern. Deploy Next.js close to the backend to minimize the extra hop.
投票:BFF 代理模式值得多一次跳轉嗎?
| 投票者 | 立場 | 理由 |
|---|---|---|
| Mei | 絕對值得 | 「安全性不是可選的」 |
| Lina | 值得 | 「同 VPC 1-5ms 的開銷根本不算什麼」 |
| Ryan | 值得 | 「啟用完整的 Next.js Server Component 模型」 |
| Priya | 值得 | 「我們遷移到 BFF 後就再也沒回頭」 |
| Tomás | 值得 | 「替代方案(localStorage + 純客戶端認證)在每個方面都更糟」 |
| Kevin | 有條件地值得 | 「僅限同 VPC 或 sidecar。跨區域 BFF 是延遲災難」 |
結果:6-0(帶 Kevin 的附帶條件)。BFF 是建議的模式。將 Next.js 部署在靠近後端的位置以最小化額外跳轉。
Round 3: The Adapted Three-Layer Defense
Moderator: In the last roundtable, three-layer defense was unanimous: proxy.ts → Layout → DAL. How does it adapt when the frontend can't decrypt the JWT?
Lina: Let me draw the comparison:
FULL-STACK (Article 29):
Layer 1: proxy.ts → cookie exists? (1ms)
Layer 2: Layout → jwtVerify(token, secret) → valid? (5-10ms)
Layer 3: DAL → db.user.findUnique() → authorized? (20-50ms)
SEPARATED (This Architecture):
Layer 1: proxy.ts → cookie exists? (1ms)
Layer 2: Layout → fetch(backend/auth/me) → valid? (50-100ms)
Layer 3: ???Layer 3 is the interesting one. In the full-stack model, the DAL calls the database directly. In the separated model, every data fetch IS the DAL. When the page component calls fetch(backend/dashboard, { headers: { Authorization } }) and gets a 200, the backend has already verified the token and authorized the data access. The backend IS the DAL.
Kevin: So you're saying the three-layer defense still exists, but Layer 3 is the backend API itself?
Lina: Exactly. Let me rewrite it:
SEPARATED Three-Layer Defense:
Layer 1: proxy.ts → Does the cookie EXIST? (1ms, optimistic)
Layer 2: Server Comp → Call backend /auth/me → Is session valid? (50-100ms)
Layer 3: Backend API → Every endpoint verifies Bearer token → authorized? (0ms extra, it's built-in)Mei: I want to challenge Layer 2. Why call /auth/me separately? If the page component is already going to call /api/proxy/dashboard, and that call returns 401, you know the session is invalid. You redirect. Why waste a separate /auth/me call?
Priya: Because of UX. If you skip the /auth/me check and go straight to the dashboard API, you render the page skeleton (sidebar, header) and THEN realize the user isn't authenticated. That's a flash of authenticated UI before redirect. The /auth/me call in the Layout lets you redirect BEFORE rendering any authenticated UI.
Mei: Fair point. But here's the tension: that /auth/me call is 50-100ms. On every navigation? That's a lot.
Kevin: No. Remember Partial Rendering from the last debate. The Layout only runs on the FIRST page load. Client-side navigations don't re-execute the Layout. So the /auth/me call happens once when the user first enters the authenticated section, not on every page change.
Ryan: Which means, just like the full-stack model, the Layout auth check is a UX layer, not a security layer. The real security is that every API call through the BFF proxy includes the token, and the backend verifies it. If the token has expired between navigations, the next API call will return 401, and the frontend handles it.
Tomás: Let me show the adapted code. Here's the "DAL" for a separated architecture:
// lib/api.ts
import 'server-only'
import { cookies } from 'next/headers'
import { redirect } from 'next/navigation'
import { cache } from 'react'
const BACKEND_URL = process.env.BACKEND_API_URL!
// The "verifySession" equivalent — calls the backend instead of decrypting locally
export const verifySession = cache(async () => {
const cookieStore = await cookies()
const accessToken = cookieStore.get('access-token')?.value
if (!accessToken) redirect('/login')
try {
const res = await fetch(`${BACKEND_URL}/auth/me`, {
headers: { Authorization: `Bearer ${accessToken}` },
cache: 'no-store',
})
if (!res.ok) redirect('/login')
return await res.json()
} catch {
redirect('/login')
}
})
// Generic authenticated fetch — used by Server Components
export async function apiFetch(endpoint: string, options: RequestInit = {}) {
const cookieStore = await cookies()
const accessToken = cookieStore.get('access-token')?.value
if (!accessToken) redirect('/login')
const res = await fetch(`${BACKEND_URL}${endpoint}`, {
...options,
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${accessToken}`,
...options.headers,
},
cache: 'no-store',
})
if (res.status === 401) redirect('/login')
if (!res.ok) throw new Error(`API error: ${res.status}`)
return res.json()
}Lina: And the Layout:
// app/(authenticated)/layout.tsx
import { verifySession } from '@/lib/api'
export default async function AuthLayout({
children,
}: {
children: React.ReactNode
}) {
const user = await verifySession()
return (
<div className="app-shell">
<Sidebar user={user} />
<main>{children}</main>
</div>
)
}Kevin: Notice — cache() from React still works. If the Layout calls verifySession() and a child Server Component also calls verifySession(), the backend /auth/me is only called once per request. Same deduplication as the full-stack model.
第三回合:適配版三層防禦
主持人: 上一場圓桌會議中,三層防禦全票通過:proxy.ts → Layout → DAL。當前端無法解密 JWT 時,它怎麼適配?
Lina: 讓我畫一下比較:
全端模式(第 29 篇文章):
第一層:proxy.ts → cookie 存在嗎?(1ms)
第二層:Layout → jwtVerify(token, secret) → 有效嗎?(5-10ms)
第三層:DAL → db.user.findUnique() → 有權限嗎?(20-50ms)
分離模式(這個架構):
第一層:proxy.ts → cookie 存在嗎?(1ms)
第二層:Layout → fetch(backend/auth/me) → 有效嗎?(50-100ms)
第三層:???第三層是有趣的。在全端模式中,DAL 直接呼叫資料庫。在分離模式中,每次資料獲取本身就是 DAL。 當頁面元件呼叫 fetch(backend/dashboard, { headers: { Authorization } }) 並拿到 200,後端就已經驗證了 token 並授權了資料存取。後端本身就是 DAL。
Kevin: 所以你是說三層防禦仍然存在,但第三層是後端 API 本身?
Lina: 正是如此。讓我重寫:
分離模式三層防禦:
第一層:proxy.ts → cookie 存在嗎?(1ms,樂觀檢查)
第二層:Server Comp → 呼叫後端 /auth/me → session 有效嗎?(50-100ms)
第三層:後端 API → 每個端點都驗證 Bearer token → 有權限嗎?(0ms 額外成本,它是內建的)Mei: 我想質疑第二層。為什麼要單獨呼叫 /auth/me?如果頁面元件已經要呼叫 /api/proxy/dashboard,而那個呼叫回傳 401,你就知道 session 無效了。你重導向。為什麼要浪費一次單獨的 /auth/me 呼叫?
Priya: 因為 UX。如果你跳過 /auth/me 檢查直接打 dashboard API,你會渲染頁面骨架(側邊欄、標頭)然後才意識到使用者未認證。那是重導向前的認證 UI 閃爍。Layout 中的 /auth/me 呼叫讓你在渲染任何認證 UI 之前就重導向。
Mei: 合理。但這裡有一個矛盾:那個 /auth/me 呼叫是 50-100ms。每次導航都要?成本很高。
Kevin: 不。還記得上次辯論的 Partial Rendering。Layout 只在第一次頁面載入時執行。客戶端導航不會重新執行 Layout。所以 /auth/me 呼叫在使用者首次進入認證區塊時發生一次,不是每次頁面切換。
Ryan: 這意味著,就像全端模式一樣,Layout 的認證檢查是UX 層,不是安全層。 真正的安全在於每個通過 BFF 代理的 API 呼叫都包含 token,而後端會驗證它。如果 token 在導航之間過期了,下一個 API 呼叫會回傳 401,前端處理它。
Tomás: 讓我展示適配後的程式碼。這是分離架構的「DAL」:
// lib/api.ts
import 'server-only'
import { cookies } from 'next/headers'
import { redirect } from 'next/navigation'
import { cache } from 'react'
const BACKEND_URL = process.env.BACKEND_API_URL!
// 「verifySession」的等效實作——呼叫後端而非本地解密
export const verifySession = cache(async () => {
const cookieStore = await cookies()
const accessToken = cookieStore.get('access-token')?.value
if (!accessToken) redirect('/login')
try {
const res = await fetch(`${BACKEND_URL}/auth/me`, {
headers: { Authorization: `Bearer ${accessToken}` },
cache: 'no-store',
})
if (!res.ok) redirect('/login')
return await res.json()
} catch {
redirect('/login')
}
})
// 通用認證 fetch——Server Components 使用
export async function apiFetch(endpoint: string, options: RequestInit = {}) {
const cookieStore = await cookies()
const accessToken = cookieStore.get('access-token')?.value
if (!accessToken) redirect('/login')
const res = await fetch(`${BACKEND_URL}${endpoint}`, {
...options,
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${accessToken}`,
...options.headers,
},
cache: 'no-store',
})
if (res.status === 401) redirect('/login')
if (!res.ok) throw new Error(`API error: ${res.status}`)
return res.json()
}Lina: 然後是 Layout:
// app/(authenticated)/layout.tsx
import { verifySession } from '@/lib/api'
export default async function AuthLayout({
children,
}: {
children: React.ReactNode
}) {
const user = await verifySession()
return (
<div className="app-shell">
<Sidebar user={user} />
<main>{children}</main>
</div>
)
}Kevin: 注意——React 的 cache() 仍然有效。如果 Layout 呼叫 verifySession(),而一個子 Server Component 也呼叫 verifySession(),後端的 /auth/me 只會被呼叫一次。跟全端模式一樣的去重複化。
Vote: Does the Three-Layer Defense Still Apply in Separated Architecture?
| Voter | Position | Reasoning |
|---|---|---|
| Lina | Yes, adapted | "proxy.ts → Layout → Backend API. Same spirit, different implementation" |
| Kevin | Yes | "The layers still answer different questions at different speeds" |
| Mei | Yes | "The backend IS the DAL. Every endpoint must verify the token" |
| Ryan | Yes | "The cache() + server-only + cookies() pattern still works" |
| Priya | Yes | "We use exactly this pattern in production" |
| Tomás | Yes | "The mental model is transferable — that's the whole point" |
Result: 6-0. The three-layer defense adapts to separated architectures. Layer 3 shifts from "local DAL" to "backend API," but the principle — every data access point verifies authorization — is unchanged.
投票:三層防禦在分離架構中仍然適用嗎?
| 投票者 | 立場 | 理由 |
|---|---|---|
| Lina | 是,已適配 | 「proxy.ts → Layout → 後端 API。相同精神,不同實作」 |
| Kevin | 是 | 「各層仍然以不同速度回答不同問題」 |
| Mei | 是 | 「後端就是 DAL。每個端點都必須驗證 token」 |
| Ryan | 是 | 「cache() + server-only + cookies() 模式仍然有效」 |
| Priya | 是 | 「我們在生產環境就是用這個模式」 |
| Tomás | 是 | 「心智模型是可遷移的——這才是重點」 |
結果:6-0。三層防禦適配到分離架構。第三層從「本地 DAL」轉移到「後端 API」,但原則——每個資料存取點都驗證授權——不變。
Interlude: Show Me Exactly What Happens — The Complete Route Protection Walkthrough
Moderator: We've been debating principles. Let's get concrete. Show me every file involved, and walk me through exactly what happens when an unauthenticated user types https://myapp.com/dashboard into their browser.
Tomás: Finally! This is the section beginners actually need. Let me lay out every file, then we walk through three scenarios.
File 1: proxy.ts — The Network Boundary
// proxy.ts
import { NextRequest, NextResponse } from 'next/server'
const protectedPrefixes = ['/dashboard', '/settings', '/admin', '/profile']
const authPages = ['/login', '/signup']
export default function proxy(request: NextRequest) {
const { pathname } = request.nextUrl
const hasSession = request.cookies.has('access-token')
// SCENARIO A: Unauthenticated user tries to access a protected route
if (!hasSession && protectedPrefixes.some(p => pathname.startsWith(p))) {
const loginUrl = new URL('/login', request.url)
loginUrl.searchParams.set('returnTo', pathname)
return NextResponse.redirect(loginUrl)
}
// SCENARIO B: Authenticated user tries to access login/signup
if (hasSession && authPages.some(p => pathname.startsWith(p))) {
return NextResponse.redirect(new URL('/dashboard', request.url))
}
// Everything else: let it through
return NextResponse.next()
}
export const config = {
matcher: ['/((?!api|_next/static|_next/image|favicon.ico|sitemap.xml|robots.txt).*)'],
}Lina: Notice — this does NOT verify the token. It only checks cookies.has('access-token'). If the cookie exists but is expired, proxy.ts still lets you through. That's intentional. Layer 2 (the Layout) handles actual verification.
File 2: app/(authenticated)/layout.tsx — The UX Gate
// app/(authenticated)/layout.tsx
import { verifySession } from '@/lib/api'
export default async function AuthLayout({
children,
}: {
children: React.ReactNode
}) {
// Calls backend /auth/me — if 401, redirects to /login
const user = await verifySession()
return (
<div className="app-shell">
<Sidebar user={user} />
<Header user={user} />
<main>{children}</main>
</div>
)
}File 3: app/(authenticated)/dashboard/page.tsx — The Page
// app/(authenticated)/dashboard/page.tsx
import { apiFetch } from '@/lib/api'
export default async function DashboardPage() {
// apiFetch reads the cookie and calls the backend with Bearer token
// If 401 → redirects to /login
const stats = await apiFetch('/dashboard/stats')
return <DashboardView stats={stats} />
}File 4: app/(public)/login/page.tsx — The Login Page
// app/(public)/login/page.tsx
'use client'
import { useSearchParams, useRouter } from 'next/navigation'
import { useState } from 'react'
export default function LoginPage() {
const searchParams = useSearchParams()
const router = useRouter()
const [error, setError] = useState('')
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault()
const formData = new FormData(e.currentTarget)
const res = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
email: formData.get('email'),
password: formData.get('password'),
}),
})
if (!res.ok) {
const data = await res.json()
setError(data.error || 'Login failed')
return
}
// Cookie is now set by the BFF Route Handler (httpOnly, invisible to JS)
// Redirect to the original destination or dashboard
const returnTo = searchParams.get('returnTo') || '/dashboard'
const safeReturn = returnTo.startsWith('/') ? returnTo : '/dashboard'
router.push(safeReturn)
}
return (
<form onSubmit={handleSubmit}>
<input name="email" type="email" required />
<input name="password" type="password" required />
{error && <p className="error">{error}</p>}
<button type="submit">Log In</button>
</form>
)
}Kevin: Now the three scenarios.
Scenario A: Unauthenticated User Visits /dashboard
Step 1: Browser requests GET /dashboard
↓
Step 2: proxy.ts runs
→ cookies.has('access-token')? → NO
→ pathname starts with '/dashboard'? → YES
→ REDIRECT 307 → /login?returnTo=/dashboard
↓
Step 3: Browser follows redirect to /login?returnTo=/dashboard
↓
Step 4: proxy.ts runs again
→ cookies.has('access-token')? → NO
→ pathname is '/login' and no session? → Let it through
↓
Step 5: (public)/login/page.tsx renders the login form
→ User sees the login page
→ returnTo=/dashboard is preserved in the URLThe user never sees the dashboard. Not even a flash. proxy.ts redirects before any page rendering begins.
Scenario B: Authenticated User Visits /dashboard
Step 1: Browser requests GET /dashboard
→ Cookie 'access-token' is automatically sent
↓
Step 2: proxy.ts runs
→ cookies.has('access-token')? → YES
→ Let it through
↓
Step 3: (authenticated)/layout.tsx executes (Server Component)
→ verifySession() calls backend /auth/me with Bearer token
→ Backend returns 200 { id, name, role }
→ Layout renders: Sidebar + Header + {children}
↓
Step 4: (authenticated)/dashboard/page.tsx executes (Server Component)
→ apiFetch('/dashboard/stats') calls backend with Bearer token
→ Backend verifies token, returns dashboard data
→ Page renders with data
↓
Step 5: Complete HTML is sent to the browser
→ User sees the full dashboard with dataScenario C: Authenticated User Visits /login
Step 1: Browser requests GET /login
→ Cookie 'access-token' is automatically sent
↓
Step 2: proxy.ts runs
→ cookies.has('access-token')? → YES
→ pathname is '/login' and HAS session? → YES
→ REDIRECT 307 → /dashboard
↓
Step 3: User lands on /dashboard (Scenario B kicks in)The user never sees the login page if already logged in.
Scenario D: Session Expired During Navigation
Step 1: User is on /dashboard (logged in, page already rendered)
→ They click a link to /settings
↓
Step 2: Client-side navigation (NO full page load)
→ proxy.ts does NOT run (client-side nav)
→ (authenticated)/layout.tsx does NOT re-run (Partial Rendering)
↓
Step 3: (authenticated)/settings/page.tsx executes (Server Component)
→ apiFetch('/user/settings') calls backend with Bearer token
→ Backend returns 401 (token expired)
→ apiFetch() calls redirect('/login')
↓
Step 4: User is redirected to /loginMei: Scenario D is why the backend (Layer 3) matters. proxy.ts didn't run. The Layout didn't re-run. Only the data fetch caught the expired session. Without Layer 3, the user would see an empty or broken settings page.
Ryan: And this is why the Layout is a UX layer, not security. In Scenario B, it renders the shell (sidebar, header). In Scenario D, it doesn't re-execute, so it can't catch the expired session. The page-level apiFetch() is the real safety net.
插播:具體給我看——完整路由保護逐步解析
主持人: 我們一直在辯論原則。來具體點。給我看每個相關檔案,然後逐步走過一個未登入使用者在瀏覽器輸入 https://myapp.com/dashboard 時到底發生什麼事。
Tomás: 終於!這才是初學者真正需要的段落。讓我列出每個檔案,然後走過三個場景。
檔案 1:proxy.ts —— 網路邊界
// proxy.ts
import { NextRequest, NextResponse } from 'next/server'
const protectedPrefixes = ['/dashboard', '/settings', '/admin', '/profile']
const authPages = ['/login', '/signup']
export default function proxy(request: NextRequest) {
const { pathname } = request.nextUrl
const hasSession = request.cookies.has('access-token')
// 場景 A:未認證使用者嘗試存取受保護路由
if (!hasSession && protectedPrefixes.some(p => pathname.startsWith(p))) {
const loginUrl = new URL('/login', request.url)
loginUrl.searchParams.set('returnTo', pathname)
return NextResponse.redirect(loginUrl)
}
// 場景 B:已認證使用者嘗試存取登入/註冊頁
if (hasSession && authPages.some(p => pathname.startsWith(p))) {
return NextResponse.redirect(new URL('/dashboard', request.url))
}
// 其他所有情況:放行
return NextResponse.next()
}
export const config = {
matcher: ['/((?!api|_next/static|_next/image|favicon.ico|sitemap.xml|robots.txt).*)'],
}Lina: 注意——這不會驗證 token。它只檢查 cookies.has('access-token')。如果 cookie 存在但已過期,proxy.ts 仍然放行。這是刻意的。第二層(Layout)處理實際的驗證。
檔案 2:app/(authenticated)/layout.tsx —— UX 閘門
// app/(authenticated)/layout.tsx
import { verifySession } from '@/lib/api'
export default async function AuthLayout({
children,
}: {
children: React.ReactNode
}) {
// 呼叫後端 /auth/me——如果 401,重導向到 /login
const user = await verifySession()
return (
<div className="app-shell">
<Sidebar user={user} />
<Header user={user} />
<main>{children}</main>
</div>
)
}檔案 3:app/(authenticated)/dashboard/page.tsx —— 頁面
// app/(authenticated)/dashboard/page.tsx
import { apiFetch } from '@/lib/api'
export default async function DashboardPage() {
// apiFetch 讀取 cookie 並帶 Bearer token 呼叫後端
// 如果 401 → 重導向到 /login
const stats = await apiFetch('/dashboard/stats')
return <DashboardView stats={stats} />
}檔案 4:app/(public)/login/page.tsx —— 登入頁面
// app/(public)/login/page.tsx
'use client'
import { useSearchParams, useRouter } from 'next/navigation'
import { useState } from 'react'
export default function LoginPage() {
const searchParams = useSearchParams()
const router = useRouter()
const [error, setError] = useState('')
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault()
const formData = new FormData(e.currentTarget)
const res = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
email: formData.get('email'),
password: formData.get('password'),
}),
})
if (!res.ok) {
const data = await res.json()
setError(data.error || 'Login failed')
return
}
// Cookie 已由 BFF Route Handler 設定(httpOnly,JS 看不到)
// 重導向到原始目的地或 dashboard
const returnTo = searchParams.get('returnTo') || '/dashboard'
const safeReturn = returnTo.startsWith('/') ? returnTo : '/dashboard'
router.push(safeReturn)
}
return (
<form onSubmit={handleSubmit}>
<input name="email" type="email" required />
<input name="password" type="password" required />
{error && <p className="error">{error}</p>}
<button type="submit">Log In</button>
</form>
)
}Kevin: 現在看三個場景。
場景 A:未認證使用者造訪 /dashboard
步驟 1:瀏覽器請求 GET /dashboard
↓
步驟 2:proxy.ts 執行
→ cookies.has('access-token')?→ 沒有
→ pathname 以 '/dashboard' 開頭?→ 是
→ 重導向 307 → /login?returnTo=/dashboard
↓
步驟 3:瀏覽器跟隨重導向到 /login?returnTo=/dashboard
↓
步驟 4:proxy.ts 再次執行
→ cookies.has('access-token')?→ 沒有
→ pathname 是 '/login' 且沒有 session?→ 放行
↓
步驟 5:(public)/login/page.tsx 渲染登入表單
→ 使用者看到登入頁面
→ returnTo=/dashboard 保留在 URL 中使用者永遠看不到 dashboard。連閃一下都不會。proxy.ts 在任何頁面渲染開始之前就重導向了。
場景 B:已認證使用者造訪 /dashboard
步驟 1:瀏覽器請求 GET /dashboard
→ Cookie 'access-token' 自動送出
↓
步驟 2:proxy.ts 執行
→ cookies.has('access-token')?→ 有
→ 放行
↓
步驟 3:(authenticated)/layout.tsx 執行(Server Component)
→ verifySession() 帶 Bearer token 呼叫後端 /auth/me
→ 後端回傳 200 { id, name, role }
→ Layout 渲染:Sidebar + Header + {children}
↓
步驟 4:(authenticated)/dashboard/page.tsx 執行(Server Component)
→ apiFetch('/dashboard/stats') 帶 Bearer token 呼叫後端
→ 後端驗證 token,回傳 dashboard 資料
→ 頁面帶資料渲染
↓
步驟 5:完整 HTML 送到瀏覽器
→ 使用者看到帶有資料的完整 dashboard場景 C:已認證使用者造訪 /login
步驟 1:瀏覽器請求 GET /login
→ Cookie 'access-token' 自動送出
↓
步驟 2:proxy.ts 執行
→ cookies.has('access-token')?→ 有
→ pathname 是 '/login' 且有 session?→ 是
→ 重導向 307 → /dashboard
↓
步驟 3:使用者到達 /dashboard(場景 B 接手)已經登入的使用者永遠不會看到登入頁面。
場景 D:導航期間 Session 過期
步驟 1:使用者在 /dashboard 上(已登入,頁面已渲染)
→ 他們點擊連結到 /settings
↓
步驟 2:客戶端導航(不是完整頁面載入)
→ proxy.ts 不會執行(客戶端導航)
→ (authenticated)/layout.tsx 不會重新執行(Partial Rendering)
↓
步驟 3:(authenticated)/settings/page.tsx 執行(Server Component)
→ apiFetch('/user/settings') 帶 Bearer token 呼叫後端
→ 後端回傳 401(token 過期)
→ apiFetch() 呼叫 redirect('/login')
↓
步驟 4:使用者被重導向到 /loginMei: 場景 D 就是為什麼後端(第三層)很重要。proxy.ts 沒有執行。Layout 沒有重新執行。只有資料獲取攔住了過期的 session。沒有第三層,使用者會看到一個空白或壞掉的設定頁面。
Ryan: 這也是為什麼 Layout 是 UX 層,不是安全層。在場景 B 中,它渲染外殼(側邊欄、標頭)。在場景 D 中,它不會重新執行,所以它無法攔住過期的 session。頁面層級的 apiFetch() 才是真正的安全網。
Round 4: Token Refresh — Where and How?
Moderator: Access tokens expire. Refresh tokens exist. Where does the refresh happen in a BFF architecture?
Mei: There are three options, and I have a strong opinion:
Option A: Client-side interceptor (axios/fetch interceptor)
- Browser detects 401, calls /api/auth/refresh, retries
Option B: BFF Route Handler refresh
- The proxy catch-all detects 401, refreshes server-side, retries, returns success
Option C: proxy.ts preemptive refresh
- proxy.ts checks if access-token cookie exists, if not but refresh-token does, refresh before routingRyan: Option A is the classic SPA pattern. But it has a brutal race condition. Imagine five API calls fire simultaneously. All five get 401. All five trigger a refresh. If the backend does refresh token rotation (invalidating old refresh tokens on use), only the first refresh succeeds. The other four fail, logging the user out.
Kevin: You can solve that with a queue pattern — a mutex that ensures only one refresh runs at a time, and other requests wait:
// lib/api-client.ts (client-side)
let isRefreshing = false
let refreshQueue: Array<{
resolve: (token: string) => void
reject: (error: Error) => void
}> = []
async function refreshAndRetry(originalRequest: () => Promise<Response>) {
if (isRefreshing) {
return new Promise((resolve, reject) => {
refreshQueue.push({ resolve, reject })
}).then(() => originalRequest())
}
isRefreshing = true
try {
await fetch('/api/auth/refresh', { method: 'POST' })
refreshQueue.forEach(({ resolve }) => resolve(''))
refreshQueue = []
return originalRequest()
} catch (error) {
refreshQueue.forEach(({ reject }) => reject(error as Error))
refreshQueue = []
window.location.href = '/login'
throw error
} finally {
isRefreshing = false
}
}Mei: That works for client-side fetches. But what about Server Components? They use cookies() from next/headers, not axios interceptors. The client-side queue pattern doesn't apply.
Lina: Which is why I prefer Option B — refresh in the BFF proxy. Look at the catch-all proxy code I showed earlier. When the backend returns 401, the Route Handler automatically calls the refresh endpoint, gets a new token, sets the new cookie, and retries — all on the server. The browser never knows a refresh happened. It just gets the data.
Priya: Option B also solves the race condition more elegantly. The Route Handler is server-side, so you can use a simple lock or even rely on the fact that each request is processed independently. If two requests fail at the same time, they each try to refresh — but since the refresh token hasn't been used yet (the first one is still in flight), both can succeed. It's only an issue with token rotation.
Mei: For token rotation, you need a server-side mutex. In practice, I recommend not doing token rotation for access token refresh. Just have the refresh endpoint validate the refresh token and return a new access token. Only rotate the refresh token on explicit re-authentication.
Ryan: Option C — preemptive refresh in proxy.ts — is interesting but dangerous. You're adding network calls to proxy.ts, which runs on every matched request. If the refresh endpoint is slow, every page load blocks. I'd keep proxy.ts fast and dumb.
Kevin: Agreed. proxy.ts should be 1ms. Adding a refresh call makes it 50-100ms. That destroys TTFB for every single navigation.
第四回合:Token 刷新——在哪裡以及怎麼做?
主持人: Access token 會過期。Refresh token 存在。在 BFF 架構中,刷新在哪裡發生?
Mei: 有三個選項,我有強烈的看法:
選項 A:客戶端攔截器(axios/fetch 攔截器)
- 瀏覽器偵測到 401,呼叫 /api/auth/refresh,重試
選項 B:BFF Route Handler 刷新
- 代理 catch-all 偵測到 401,伺服器端刷新,重試,回傳成功
選項 C:proxy.ts 預防性刷新
- proxy.ts 檢查 access-token cookie 是否存在,若不存在但 refresh-token 存在,在路由前刷新Ryan: 選項 A 是經典 SPA 模式。但它有一個殘酷的競態條件。想像五個 API 呼叫同時發出。五個都拿到 401。五個都觸發刷新。如果後端做 refresh token 輪轉(使用後使舊 refresh token 失效),只有第一個刷新成功。其他四個失敗,使用者被登出。
Kevin: 你可以用佇列模式解決——一個 mutex 確保同時只有一個刷新在執行,其他請求等待:
// lib/api-client.ts(客戶端)
let isRefreshing = false
let refreshQueue: Array<{
resolve: (token: string) => void
reject: (error: Error) => void
}> = []
async function refreshAndRetry(originalRequest: () => Promise<Response>) {
if (isRefreshing) {
return new Promise((resolve, reject) => {
refreshQueue.push({ resolve, reject })
}).then(() => originalRequest())
}
isRefreshing = true
try {
await fetch('/api/auth/refresh', { method: 'POST' })
refreshQueue.forEach(({ resolve }) => resolve(''))
refreshQueue = []
return originalRequest()
} catch (error) {
refreshQueue.forEach(({ reject }) => reject(error as Error))
refreshQueue = []
window.location.href = '/login'
throw error
} finally {
isRefreshing = false
}
}Mei: 這對客戶端 fetch 有效。但 Server Components 呢?它們用的是 next/headers 的 cookies(),不是 axios 攔截器。客戶端的佇列模式不適用。
Lina: 這就是為什麼我偏好選項 B——在 BFF 代理中刷新。看我之前展示的 catch-all 代理程式碼。當後端回傳 401,Route Handler 自動呼叫刷新端點、取得新 token、設定新 cookie、然後重試——全部在伺服器端。瀏覽器永遠不知道刷新發生了。它只是拿到資料。
Priya: 選項 B 也更優雅地解決了競態條件。Route Handler 是伺服器端的,所以你可以用簡單的鎖,甚至可以依賴每個請求獨立處理的事實。如果兩個請求同時失敗,各自嘗試刷新——但由於 refresh token 還沒被使用(第一個還在進行中),兩者都可以成功。只有在 token 輪轉時才有問題。
Mei: 對於 token 輪轉,你需要伺服器端的 mutex。實務上,我建議在 access token 刷新時不做 token 輪轉。 只要讓刷新端點驗證 refresh token 並回傳新的 access token。只在明確的重新認證時才輪轉 refresh token。
Ryan: 選項 C——在 proxy.ts 中預防性刷新——很有趣但很危險。你在 proxy.ts 中加入網路呼叫,而它在每個匹配的請求上都會執行。如果刷新端點很慢,每次頁面載入都會阻塞。我會保持 proxy.ts 快速和簡單。
Kevin: 同意。proxy.ts 應該是 1ms。加入刷新呼叫讓它變成 50-100ms。那會摧毀每一次導航的 TTFB。
Vote: Where Should Token Refresh Happen?
| Voter | Position | Reasoning |
|---|---|---|
| Lina | BFF Route Handler (Option B) | "Transparent to the browser, handles Server Components too" |
| Kevin | BFF Route Handler (Option B) | "Keep proxy.ts fast. Refresh in the proxy catch-all" |
| Mei | BFF Route Handler (Option B) | "Server-side refresh is more secure — no token in transit to browser" |
| Ryan | Client interceptor + BFF Route Handler (A + B) | "Client interceptor for client fetches, BFF for Server Components" |
| Priya | BFF Route Handler (Option B) | "One refresh strategy, not two" |
| Tomás | BFF Route Handler (Option B) | "Less code, less confusion" |
Result: 5-1. BFF Route Handler refresh wins. Ryan wants both client and server refresh; the majority prefers a single server-side approach through the BFF proxy.
投票:Token 刷新應該在哪裡發生?
| 投票者 | 立場 | 理由 |
|---|---|---|
| Lina | BFF Route Handler(選項 B) | 「對瀏覽器透明,也處理 Server Components」 |
| Kevin | BFF Route Handler(選項 B) | 「保持 proxy.ts 快速。在代理 catch-all 中刷新」 |
| Mei | BFF Route Handler(選項 B) | 「伺服器端刷新更安全——token 不需要傳輸到瀏覽器」 |
| Ryan | 客戶端攔截器 + BFF Route Handler(A + B) | 「客戶端 fetch 用攔截器,Server Components 用 BFF」 |
| Priya | BFF Route Handler(選項 B) | 「一種刷新策略,不是兩種」 |
| Tomás | BFF Route Handler(選項 B) | 「更少程式碼,更少困惑」 |
結果:5-1。BFF Route Handler 刷新勝出。Ryan 想要客戶端和伺服器端都刷新;多數人偏好透過 BFF 代理的單一伺服器端方式。
Round 5: Server Components and Server Actions in a Separated Architecture
Moderator: In a full-stack Next.js app, Server Components call the database directly. In a separated architecture, how do they make authenticated requests?
Kevin: Two paths. The BFF proxy catch-all (for Client Components calling /api/proxy/...), and direct backend calls from Server Components using cookies():
// This is a SERVER COMPONENT — runs on the Next.js server
// app/(authenticated)/dashboard/page.tsx
import { apiFetch } from '@/lib/api'
export default async function DashboardPage() {
// apiFetch reads the httpOnly cookie, adds Authorization header,
// calls the backend directly (server-to-server, no browser involved)
const stats = await apiFetch('/dashboard/stats')
const recentOrders = await apiFetch('/orders?limit=10')
return (
<div>
<StatsGrid data={stats} />
<RecentOrders orders={recentOrders} />
</div>
)
}Ryan: Wait. Two different calling patterns for the same backend. Client Components go through /api/proxy/... (Route Handler). Server Components call the backend directly via apiFetch(). Doesn't that create confusion?
Tomás: It does. The rule is:
Server Component / Server Action → use apiFetch() directly
(server-to-server, reads cookie, adds Bearer header)
Client Component → use fetch('/api/proxy/...') or the proxy catch-all
(browser → Next.js Route Handler → backend)The Next.js docs actually recommend this split. Server Components should fetch data directly from the source, not through Route Handlers. Because Route Handlers add an unnecessary hop for server-side requests.
Priya: Server Actions follow the same pattern as Server Components — they run on the server and can access cookies():
// app/actions/orders.ts
'use server'
import { apiFetch } from '@/lib/api'
import { revalidatePath } from 'next/cache'
export async function cancelOrder(orderId: string) {
await apiFetch(`/orders/${orderId}/cancel`, {
method: 'POST',
})
revalidatePath('/dashboard')
}Mei: One critical detail. Notice cache: 'no-store' in apiFetch(). Every authenticated request must be dynamically rendered. If you accidentally cache an authenticated response, User A's data could be served to User B. This is a data leak, not a performance issue.
Kevin: And 'server-only' on lib/api.ts. If a Client Component accidentally imports apiFetch, the build fails. Without 'server-only', the import would succeed, but cookies() would throw at runtime — a much harder bug to catch.
第五回合:分離架構中的 Server Components 和 Server Actions
主持人: 在全端 Next.js 應用中,Server Components 直接呼叫資料庫。在分離架構中,它們怎麼發出認證請求?
Kevin: 兩條路徑。BFF 代理 catch-all(給 Client Components 呼叫 /api/proxy/...),和 Server Components 透過 cookies() 直接呼叫後端:
// 這是一個 SERVER COMPONENT——在 Next.js 伺服器上執行
// app/(authenticated)/dashboard/page.tsx
import { apiFetch } from '@/lib/api'
export default async function DashboardPage() {
// apiFetch 讀取 httpOnly cookie,加上 Authorization 標頭,
// 直接呼叫後端(伺服器對伺服器,不經過瀏覽器)
const stats = await apiFetch('/dashboard/stats')
const recentOrders = await apiFetch('/orders?limit=10')
return (
<div>
<StatsGrid data={stats} />
<RecentOrders orders={recentOrders} />
</div>
)
}Ryan: 等等。同一個後端兩種不同的呼叫模式。Client Components 通過 /api/proxy/...(Route Handler)。Server Components 通過 apiFetch() 直接呼叫後端。這不會造成混亂嗎?
Tomás: 會。規則是:
Server Component / Server Action → 直接使用 apiFetch()
(伺服器對伺服器,讀取 cookie,加 Bearer 標頭)
Client Component → 使用 fetch('/api/proxy/...') 或代理 catch-all
(瀏覽器 → Next.js Route Handler → 後端)Next.js 文件實際上建議這種分割。Server Components 應該直接從來源獲取資料,不要通過 Route Handlers。因為 Route Handlers 對伺服器端請求來說是一次不必要的跳轉。
Priya: Server Actions 遵循與 Server Components 相同的模式——它們在伺服器上執行,可以存取 cookies():
// app/actions/orders.ts
'use server'
import { apiFetch } from '@/lib/api'
import { revalidatePath } from 'next/cache'
export async function cancelOrder(orderId: string) {
await apiFetch(`/orders/${orderId}/cancel`, {
method: 'POST',
})
revalidatePath('/dashboard')
}Mei: 一個關鍵細節。注意 apiFetch() 中的 cache: 'no-store'。每個認證請求都必須是動態渲染的。 如果你不小心快取了一個認證回應,使用者 A 的資料可能被提供給使用者 B。這是資料洩漏,不是效能問題。
Kevin: 還有 lib/api.ts 上的 'server-only'。如果 Client Component 不小心匯入 apiFetch,建置會失敗。沒有 'server-only',匯入會成功,但 cookies() 會在 runtime 拋錯——一個更難捕捉的 bug。
Round 6: "But Some APIs MUST Be Called from the Client" — Is Bypassing the BFF Ever Justified?
Moderator: Hold on. We've established two data paths: Server Components call the backend directly via apiFetch(), and Client Components call /api/proxy/.... But some engineers argue: for real-time features, infinite scroll, autocomplete, and other interactive patterns, going through the BFF proxy adds unnecessary latency. Why not give the client the access token for those calls? And more fundamentally — why should my user's private data travel backend → BFF → browser instead of backend → browser directly?
Kevin: I'll be the one to ask it bluntly. I have a stock trading dashboard. Prices update every 100ms via WebSocket. The WebSocket connection needs a Bearer token for authentication. WebSocket connections are initiated from the browser. How do I authenticate a WebSocket through a BFF?
Ryan: You don't. And this is where the hard truth comes in: there are legitimate cases where the client needs an access token. WebSocket, Server-Sent Events, direct file uploads to a pre-signed URL, third-party SDKs (Stripe.js, Firebase client SDK) — these all require client-side tokens. The question is not "should the client ever have a token?" The question is "how do you minimize the damage?"
Mei: Leans forward. Let me be very precise here. There are two fundamentally different things people conflate:
1. Client fetches through the BFF proxy:
Browser → fetch('/api/proxy/data') → Next.js reads httpOnly cookie
→ adds Authorization header → calls backend → returns data to browser
CLIENT NEVER SEES THE TOKEN. Cookie is httpOnly.
2. Client fetches directly with a token:
Browser → fetch('https://api.backend.com/data',
{ headers: { Authorization: 'Bearer eyJ...' } })
CLIENT HAS THE TOKEN IN JAVASCRIPT MEMORY.Option 1 is what we've been recommending. The client makes the request, yes. But the token is invisible — the browser sends the httpOnly cookie automatically, and the Route Handler does the token injection. This IS client-side fetching. It just routes through the BFF.
Kevin: But with added latency. My /api/proxy/prices call goes browser → Next.js → backend → Next.js → browser. That's two extra hops compared to browser → backend. For real-time data that's a dealbreaker.
Priya: Let me share what we actually do in production. We use a hybrid model:
Regular API calls (CRUD, forms, navigation data):
→ Through BFF proxy. Token stays in httpOnly cookie. No exceptions.
Real-time connections (WebSocket, SSE):
→ Client gets a SHORT-LIVED, SCOPED token from the BFF
→ This token can ONLY be used for the WebSocket endpoint
→ It expires in 30 seconds (just enough to establish the connection)
→ It CANNOT be used for any other API endpointHere's the code:
// app/api/auth/ws-token/route.ts — Issues a scoped, short-lived token
import { cookies } from 'next/headers'
import { NextResponse } from 'next/server'
const BACKEND_URL = process.env.BACKEND_API_URL!
export async function POST() {
const cookieStore = await cookies()
const accessToken = cookieStore.get('access-token')?.value
if (!accessToken) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
// Ask the backend to issue a scoped WebSocket-only token
const res = await fetch(`${BACKEND_URL}/auth/ws-token`, {
method: 'POST',
headers: { Authorization: `Bearer ${accessToken}` },
})
if (!res.ok) {
return NextResponse.json({ error: 'Failed' }, { status: res.status })
}
const { wsToken } = await res.json()
// This token goes to the CLIENT — it's scoped and short-lived
return NextResponse.json({ wsToken })
}// Client Component — establishes WebSocket with scoped token
'use client'
import { useEffect, useRef } from 'react'
function usePriceStream(symbols: string[]) {
const wsRef = useRef<WebSocket | null>(null)
useEffect(() => {
async function connect() {
// Step 1: Get a scoped, short-lived token from BFF
const res = await fetch('/api/auth/ws-token', { method: 'POST' })
if (!res.ok) return
const { wsToken } = await res.json()
// Step 2: Connect directly to backend WebSocket with scoped token
const ws = new WebSocket(
`wss://api.backend.com/ws/prices?token=${wsToken}`
)
wsRef.current = ws
// ... handle messages
}
connect()
return () => wsRef.current?.close()
}, [symbols])
}Mei: That's the correct pattern. The scoped token principle:
- The main access token NEVER leaves the server. It stays in the httpOnly cookie. Period.
- If the client needs a token, issue a purpose-built one. Scoped to one endpoint, expires in seconds, cannot be used for anything else.
- Even if the scoped token is stolen via XSS, the blast radius is minimal. The attacker gets 30 seconds of WebSocket access to price data. They don't get the user's full session, can't call
/users/me, can't change passwords, can't access billing.
Kevin: OK, I accept the scoped token pattern for WebSocket. But what about plain HTTP calls from Client Components? Infinite scroll, autocomplete, search-as-you-type — these fire dozens of requests. Every one goes through the BFF proxy. How much latency are we really adding?
Lina: Let me benchmark this concretely:
Direct call (browser → backend):
DNS + TLS + HTTP: ~50ms (first call), ~20ms (keep-alive)
BFF proxy (browser → Next.js → backend → Next.js → browser):
Same VPC: +2-5ms overhead
Same region, different service: +5-15ms overhead
Cross-region: +30-80ms overhead (DON'T DO THIS)For same-VPC deployment, the BFF adds 2-5ms. Your autocomplete goes from 20ms to 25ms. Your user cannot perceive a 5ms difference. The latency argument against BFF is a premature optimization for 99% of apps.
Kevin: Pauses. 2-5ms same-VPC. Fine. I concede for most apps. But I want to note: for my trading platform with sub-10ms latency requirements, the BFF proxy is not an option for price feeds. That's why we use the scoped token pattern.
Tomás: Now let me address the deeper question: why should private data go through the BFF at all? Someone in the audience is thinking: "The backend already verified the Bearer token. The response is encrypted via HTTPS. Why does the data need to touch the Next.js server?"
Three reasons:
Reason 1: The token must be invisible to steal. If the client calls the backend directly, the client must have the access token in JavaScript. XSS can steal it. If the client calls the BFF proxy, the token is in an httpOnly cookie — JavaScript cannot read it. The data path is longer, but the token path is sealed.
Reason 2: The BFF can transform and filter. Your backend might return { name, email, ssn, internalScore }. The BFF proxy can strip sensitive fields before sending to the browser:
// In the BFF proxy, you can filter the response
const backendData = await backendRes.json()
const { ssn, internalScore, ...safeData } = backendData
return NextResponse.json(safeData)Without BFF, you trust the backend to never over-return data. With BFF, you have a second chance to filter.
Reason 3: The backend URL is hidden. The browser only sees /api/proxy/.... It never sees https://internal-api.vpc.company.com:8443. DevTools network tab shows your Next.js domain, not your backend infrastructure. This is defense in depth — an attacker can't directly probe your backend.
Mei: And there's a Reason 4 that nobody talks about: audit and logging. Every API call flows through your BFF. You can log every request, detect anomalies, rate-limit per user, and add CSRF protection — all in one place, in your own codebase, without depending on the backend team to implement these controls.
Priya: Let me summarize the decision framework:
| Call Type | Pattern | Token Exposure |
|---|---|---|
| Regular CRUD (read/write) | BFF proxy (/api/proxy/...) | None — httpOnly cookie |
| Form submissions | Server Action (apiFetch()) | None — server-side |
| Page data loading | Server Component (apiFetch()) | None — server-side |
| WebSocket / SSE | Scoped token from BFF | Minimal — scoped, 30s TTL |
| Third-party SDK (Stripe, etc.) | SDK's own token (publishable key) | SDK-managed, not your JWT |
| File upload to pre-signed URL | Pre-signed URL from BFF | None — URL is the auth |
Ryan: The rule is simple: your main access token lives in an httpOnly cookie and never touches window, document, or any JavaScript variable. If a specific feature needs client-side authentication, issue a scoped, short-lived, single-purpose token through the BFF. That's it. That's the entire model.
第六回合:「但有些 API 必須從 Client Side 呼叫」——繞過 BFF 有道理嗎?
主持人: 等一下。我們建立了兩條資料路徑:Server Components 透過 apiFetch() 直接呼叫後端,Client Components 呼叫 /api/proxy/...。但有些工程師主張:對於即時功能、無限滾動、自動完成和其他互動模式,走 BFF 代理增加不必要的延遲。為什麼不直接給客戶端 access token 來呼叫?更根本的問題是——為什麼我的使用者私人資料要走後端 → BFF → 瀏覽器,而不是後端 → 瀏覽器直接送達?
Kevin: 我直接問。我有一個股票交易儀表板。價格每 100ms 透過 WebSocket 更新。WebSocket 連線需要 Bearer token 來認證。WebSocket 連線從瀏覽器發起。我怎麼透過 BFF 認證一個 WebSocket?
Ryan: 你做不到。這就是殘酷真相:確實有客戶端需要 access token 的合理情境。 WebSocket、Server-Sent Events、直接上傳檔案到 pre-signed URL、第三方 SDK(Stripe.js、Firebase client SDK)——這些都需要客戶端 token。問題不是「客戶端是否應該拿到 token?」問題是「你怎麼最小化損害?」
Mei: 身體前傾。 讓我非常精確地說。有兩件根本不同的事情被混為一談:
1. 客戶端透過 BFF 代理 fetch:
瀏覽器 → fetch('/api/proxy/data') → Next.js 讀取 httpOnly cookie
→ 加上 Authorization 標頭 → 呼叫後端 → 回傳資料給瀏覽器
客戶端永遠看不到 TOKEN。Cookie 是 httpOnly 的。
2. 客戶端帶 token 直接 fetch:
瀏覽器 → fetch('https://api.backend.com/data',
{ headers: { Authorization: 'Bearer eyJ...' } })
客戶端在 JAVASCRIPT 記憶體中持有 TOKEN。選項 1 是我們一直在建議的。客戶端確實在發出請求。但 token 是不可見的——瀏覽器自動送出 httpOnly cookie,Route Handler 做 token 注入。這就是客戶端 fetching。它只是通過 BFF 路由。
Kevin: 但有額外延遲。我的 /api/proxy/prices 呼叫走瀏覽器 → Next.js → 後端 → Next.js → 瀏覽器。比起瀏覽器 → 後端多了兩次跳轉。對即時資料來說這是不可接受的。
Priya: 讓我分享我們在生產環境實際怎麼做。我們用混合模型:
常規 API 呼叫(CRUD、表單、導航資料):
→ 通過 BFF 代理。Token 留在 httpOnly cookie。沒有例外。
即時連線(WebSocket、SSE):
→ 客戶端從 BFF 取得一個短效、限定範圍的 token
→ 這個 token 只能用於 WebSocket 端點
→ 它在 30 秒後過期(剛好夠建立連線)
→ 它不能用於任何其他 API 端點程式碼如下:
// app/api/auth/ws-token/route.ts——發行限定範圍的短效 token
import { cookies } from 'next/headers'
import { NextResponse } from 'next/server'
const BACKEND_URL = process.env.BACKEND_API_URL!
export async function POST() {
const cookieStore = await cookies()
const accessToken = cookieStore.get('access-token')?.value
if (!accessToken) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
// 請求後端發行一個限定 WebSocket 範圍的 token
const res = await fetch(`${BACKEND_URL}/auth/ws-token`, {
method: 'POST',
headers: { Authorization: `Bearer ${accessToken}` },
})
if (!res.ok) {
return NextResponse.json({ error: 'Failed' }, { status: res.status })
}
const { wsToken } = await res.json()
// 這個 token 會到客戶端——它是限定範圍且短效的
return NextResponse.json({ wsToken })
}// Client Component——用限定範圍的 token 建立 WebSocket
'use client'
import { useEffect, useRef } from 'react'
function usePriceStream(symbols: string[]) {
const wsRef = useRef<WebSocket | null>(null)
useEffect(() => {
async function connect() {
// 步驟 1:從 BFF 取得限定範圍的短效 token
const res = await fetch('/api/auth/ws-token', { method: 'POST' })
if (!res.ok) return
const { wsToken } = await res.json()
// 步驟 2:用限定範圍的 token 直接連線到後端 WebSocket
const ws = new WebSocket(
`wss://api.backend.com/ws/prices?token=${wsToken}`
)
wsRef.current = ws
// ... 處理訊息
}
connect()
return () => wsRef.current?.close()
}, [symbols])
}Mei: 這才是正確的模式。限定範圍 token 原則:
- 主要的 access token 永遠不離開伺服器。 它留在 httpOnly cookie 裡。句點。
- 如果客戶端需要 token,發行一個專用的。 限定到一個端點、幾秒內過期、不能用於其他任何用途。
- 即使限定範圍的 token 被 XSS 竊取,影響範圍是最小的。 攻擊者得到 30 秒的 WebSocket 存取價格資料。他們得不到使用者的完整 session,不能呼叫
/users/me,不能改密碼,不能存取帳單。
Kevin: OK,我接受 WebSocket 的限定範圍 token 模式。但一般的 HTTP 呼叫從 Client Components 發出呢?無限滾動、自動完成、即時搜尋——這些會發出幾十個請求。每個都走 BFF 代理。我們到底增加了多少延遲?
Lina: 讓我具體跑個數據:
直接呼叫(瀏覽器 → 後端):
DNS + TLS + HTTP:~50ms(首次呼叫),~20ms(keep-alive)
BFF 代理(瀏覽器 → Next.js → 後端 → Next.js → 瀏覽器):
同 VPC:+2-5ms 開銷
同區域,不同服務:+5-15ms 開銷
跨區域:+30-80ms 開銷(不要這樣做)對同 VPC 部署,BFF 增加 2-5ms。你的自動完成從 20ms 變成 25ms。你的使用者無法感知 5ms 的差異。BFF 的延遲論點對 99% 的應用來說都是過早優化。
Kevin: 停頓。 同 VPC 2-5ms。好吧,對大多數應用我讓步。但我要指出:對我的交易平台有低於 10ms 延遲需求的情況,BFF 代理不適用於價格 feed。這就是為什麼我們用限定範圍 token 模式。
Tomás: 現在讓我回答更深層的問題:為什麼私人資料需要通過 BFF? 觀眾中有人在想:「後端已經驗證了 Bearer token。回應透過 HTTPS 加密了。資料為什麼需要碰 Next.js 伺服器?」
三個理由:
理由 1:token 必須對竊取不可見。 如果客戶端直接呼叫後端,客戶端必須在 JavaScript 中持有 access token。XSS 可以偷走它。如果客戶端呼叫 BFF 代理,token 在 httpOnly cookie 中——JavaScript 讀不到。資料路徑更長,但 token 路徑是密封的。
理由 2:BFF 可以轉換和過濾。 你的後端可能回傳 { name, email, ssn, internalScore }。BFF 代理可以在送到瀏覽器之前剝除敏感欄位:
// 在 BFF 代理中,你可以過濾回應
const backendData = await backendRes.json()
const { ssn, internalScore, ...safeData } = backendData
return NextResponse.json(safeData)沒有 BFF,你信任後端永遠不會過度回傳資料。有了 BFF,你有第二次過濾的機會。
理由 3:後端 URL 被隱藏。 瀏覽器只看到 /api/proxy/...。它永遠看不到 https://internal-api.vpc.company.com:8443。DevTools 網路面板顯示你的 Next.js 域名,不是你的後端基礎設施。這是縱深防禦——攻擊者無法直接探測你的後端。
Mei: 還有一個理由 4沒人提到:稽核和日誌。 每個 API 呼叫都流經你的 BFF。你可以記錄每個請求、偵測異常、按使用者限速、加上 CSRF 保護——全部在一個地方,在你自己的程式碼庫裡,不需要依賴後端團隊來實作這些控制。
Priya: 讓我總結決策框架:
| 呼叫類型 | 模式 | Token 暴露程度 |
|---|---|---|
| 常規 CRUD(讀/寫) | BFF 代理(/api/proxy/...) | 無——httpOnly cookie |
| 表單提交 | Server Action(apiFetch()) | 無——伺服器端 |
| 頁面資料載入 | Server Component(apiFetch()) | 無——伺服器端 |
| WebSocket / SSE | 從 BFF 取得限定範圍 token | 最小——限定範圍,30 秒 TTL |
| 第三方 SDK(Stripe 等) | SDK 自有 token(publishable key) | SDK 管理,不是你的 JWT |
| 檔案上傳到 pre-signed URL | 從 BFF 取得 pre-signed URL | 無——URL 本身就是授權 |
Ryan: 規則很簡單:你的主要 access token 活在 httpOnly cookie 裡,永遠不碰 window、document 或任何 JavaScript 變數。如果特定功能需要客戶端認證,透過 BFF 發行一個限定範圍、短效、單一用途的 token。 就這樣。這就是整個模型。
Vote: Is It Ever Acceptable for the Client to Hold the Main Access Token?
| Voter | Position | Reasoning |
|---|---|---|
| Mei | Never | "The main token is the keys to the kingdom. It stays server-side" |
| Lina | Never | "Scoped tokens exist for a reason" |
| Ryan | Never for the main token | "Issue purpose-built tokens through BFF for edge cases" |
| Kevin | Never for regular calls | "I accept BFF proxy for CRUD. Scoped tokens for WebSocket" |
| Priya | Never | "Our hybrid model proves you don't need to" |
| Tomás | Never | "If you're putting your main JWT in JavaScript, you've already lost" |
Result: 6-0. The main access token must never be exposed to client-side JavaScript. For use cases that require client-side authentication (WebSocket, SSE), issue scoped, short-lived, single-purpose tokens through the BFF.
投票:客戶端持有主要 Access Token 是否可以接受?
| 投票者 | 立場 | 理由 |
|---|---|---|
| Mei | 永遠不行 | 「主要 token 是王國的鑰匙。它留在伺服器端」 |
| Lina | 永遠不行 | 「限定範圍 token 的存在是有原因的」 |
| Ryan | 主要 token 永遠不行 | 「透過 BFF 為邊界案例發行專用 token」 |
| Kevin | 常規呼叫永遠不行 | 「CRUD 我接受 BFF 代理。WebSocket 用限定範圍 token」 |
| Priya | 永遠不行 | 「我們的混合模型證明你不需要」 |
| Tomás | 永遠不行 | 「如果你把主要 JWT 放在 JavaScript 裡,你已經輸了」 |
結果:6-0。主要 access token 絕不能暴露給客戶端 JavaScript。對於需要客戶端認證的使用案例(WebSocket、SSE),透過 BFF 發行限定範圍、短效、單一用途的 token。
Round 7: What About the "Pure SPA" Approach? (No BFF)
Moderator: We've been assuming BFF. But what about teams that don't want the proxy layer? Client-side auth with localStorage, direct API calls to the backend. When is that acceptable?
Kevin: I'll steel-man it. Pure SPA has real advantages:
- No extra network hop (browser calls backend directly)
- Simpler deployment (static export, CDN)
- No server-side Next.js infrastructure to maintain
- Works with
output: 'export' - Lower operational complexity
For internal tools, admin dashboards behind a VPN, prototypes — this is fine.
Mei: Crosses arms. Define "internal." If "internal" means "accessible only from the corporate VPN, behind SSO, used by 15 employees" — sure, localStorage is acceptable. The blast radius of an XSS attack is limited. If "internal" means "internal but accessible from the internet" — no. That's just external with fewer users.
Tomás: There's also a technical limitation. Without BFF, you lose Server Components for authenticated content. Server Components run on the server and have no access to localStorage. So every authenticated page must be a Client Component with useEffect for data fetching. You lose streaming, you lose SSR, you lose all the React 19 Server Component benefits. Your "Next.js 16" app is basically a Create React App with a file-system router.
Priya: That's harsh but accurate. We tried the pure SPA approach first. Within three months we migrated to BFF because:
- We needed SSR for authenticated pages (SEO for tenant-specific content)
- We kept getting hydration mismatches between server-rendered shells and client-fetched auth state
- We had an XSS scare (luckily in staging, not production) that made us rethink localStorage
Ryan: The decision matrix is clear:
| Factor | Pure SPA | BFF Proxy |
|---|---|---|
| Token security | Vulnerable to XSS | XSS-proof (httpOnly) |
| proxy.ts works? | No | Yes |
| Server Components work? | No (for auth content) | Yes |
| Server Actions work? | Partially | Yes |
| SSR for auth pages | No | Yes |
| Deployment | Static CDN | Node.js server |
| Latency | Lower (direct) | Higher (+1 hop) |
| Complexity | Lower | Higher |
| CORS needed? | Yes (cross-origin) | No (same-origin) |
Lina: Bottom line: if you chose Next.js 16 over React SPA, you chose it for Server Components, streaming, proxy.ts, and the full rendering model. Using it as a pure SPA throws away everything that makes Next.js worth the complexity. Just use Vite at that point.
第七回合:「純 SPA」方式呢?(不用 BFF)
主持人: 我們一直在假設 BFF。但如果團隊不想要代理層呢?用 localStorage 的客戶端認證,直接打後端 API。什麼時候可以接受?
Kevin: 我來最強力地為它辯護。純 SPA 有實際的優點:
- 沒有額外網路跳轉(瀏覽器直接呼叫後端)
- 更簡單的部署(靜態匯出、CDN)
- 不需要維護伺服器端 Next.js 基礎設施
- 可以用
output: 'export' - 更低的營運複雜度
對內部工具、VPN 後面的管理後台、原型——這沒問題。
Mei: 雙臂交叉。 定義「內部」。如果「內部」是指「只能從公司 VPN 存取、在 SSO 後面、15 個員工使用」——可以,localStorage 是可接受的。XSS 攻擊的影響範圍有限。如果「內部」是指「內部但可以從網際網路存取」——不行。那只是使用者較少的外部應用。
Tomás: 還有一個技術限制。沒有 BFF,你失去了認證內容的 Server Components。 Server Components 在伺服器上執行,無法存取 localStorage。所以每個認證頁面必須是 Client Component,用 useEffect 來獲取資料。你失去了 streaming、失去了 SSR、失去了所有 React 19 Server Component 的好處。你的「Next.js 16」應用基本上就是一個帶檔案系統路由器的 Create React App。
Priya: 這話嚴厲但準確。我們一開始嘗試了純 SPA 方式。三個月內就遷移到 BFF,因為:
- 我們需要認證頁面的 SSR(租戶特定內容的 SEO)
- 我們不斷遇到伺服器渲染的外殼和客戶端獲取的認證狀態之間的 hydration 不匹配
- 我們有一次 XSS 驚嚇(幸運的是在 staging 環境,不是 production)讓我們重新思考 localStorage
Ryan: 決策矩陣很清楚:
| 因素 | 純 SPA | BFF 代理 |
|---|---|---|
| Token 安全性 | 易受 XSS 攻擊 | XSS 免疫(httpOnly) |
| proxy.ts 可用? | 否 | 是 |
| Server Components 可用? | 否(對認證內容) | 是 |
| Server Actions 可用? | 部分可用 | 是 |
| 認證頁面的 SSR | 否 | 是 |
| 部署方式 | 靜態 CDN | Node.js 伺服器 |
| 延遲 | 較低(直接) | 較高(+1 跳轉) |
| 複雜度 | 較低 | 較高 |
| 需要 CORS? | 是(跨域) | 否(同源) |
Lina: 總結:如果你選了 Next.js 16 而不是 React SPA,你是為了 Server Components、streaming、proxy.ts 和完整的渲染模型而選的。把它當純 SPA 用等於丟掉了 Next.js 值得這些複雜度的所有東西。那不如直接用 Vite。
Vote: When Is Pure SPA (No BFF) Acceptable?
| Voter | Position | Reasoning |
|---|---|---|
| Kevin | Internal tools / prototypes only | "If you don't need SSR, use Vite" |
| Mei | VPN-only internal tools | "Acceptable when blast radius is minimal" |
| Lina | Never for user-facing apps | "BFF or don't use Next.js" |
| Ryan | Prototypes and hackathons | "Speed matters when you're validating, not shipping" |
| Priya | We tried it, migrated away | "The technical limitations killed us" |
| Tomás | Tutorials and learning | "localStorage is fine for learning — terrible for production" |
Result: 6-0. Pure SPA without BFF is unanimously rejected for production user-facing Next.js 16 apps. Acceptable only for internal tools, prototypes, and learning.
投票:純 SPA(不用 BFF)什麼時候可以接受?
| 投票者 | 立場 | 理由 |
|---|---|---|
| Kevin | 僅限內部工具 / 原型 | 「如果你不需要 SSR,就用 Vite」 |
| Mei | 僅限 VPN 內部工具 | 「當影響範圍最小時可以接受」 |
| Lina | 面向使用者的應用永遠不行 | 「要麼 BFF,要麼別用 Next.js」 |
| Ryan | 原型和 hackathon | 「在驗證而非出貨時速度很重要」 |
| Priya | 我們試過了,遷移走了 | 「技術限制殺了我們」 |
| Tomás | 教學和學習 | 「localStorage 學習時沒問題——生產環境很糟糕」 |
結果:6-0。純 SPA 不用 BFF 在生產面向使用者的 Next.js 16 應用中一致被否決。僅在內部工具、原型和學習時可接受。
Final Verdict: The Complete Implementation Guide for Separated Architecture
The Recommended File Structure
app/
├── (public)/
│ ├── layout.tsx ← Public layout (no auth)
│ ├── login/page.tsx
│ └── signup/page.tsx
├── (authenticated)/
│ ├── layout.tsx ← Auth layout (verifySession via backend + UI shell)
│ ├── dashboard/page.tsx ← Server Component, uses apiFetch()
│ ├── settings/page.tsx
│ └── admin/
│ ├── layout.tsx ← Role check (via backend)
│ └── page.tsx
├── layout.tsx ← Root layout (providers, global CSS)
└── page.tsx ← Landing page
app/api/
├── auth/
│ ├── login/route.ts ← Forwards credentials, sets httpOnly cookie
│ ├── logout/route.ts ← Clears cookies
│ └── refresh/route.ts ← Refreshes access token
└── proxy/
└── [...path]/route.ts ← Catch-all: reads cookie, adds Bearer, forwards
lib/
├── api.ts ← 'server-only': verifySession(), apiFetch()
proxy.ts ← Network boundary (cookie existence check)The Adapted Three-Layer Defense
| Layer | Question | Speed | Implementation |
|---|---|---|---|
| proxy.ts | Does the cookie exist? | 1ms | request.cookies.has('access-token') |
| Layout | Is the session valid? (UX layer) | 50-100ms | verifySession() → calls backend /auth/me |
| Backend API | Is this user authorized for this data? | 0ms extra | Every endpoint verifies the Bearer token |
How Data Flows in Each Context
| Context | How Auth Works |
|---|---|
| Server Component | apiFetch() reads cookie, calls backend directly (server-to-server) |
| Server Action | apiFetch() reads cookie, calls backend directly |
| Client Component | fetch('/api/proxy/...') → Route Handler reads cookie, forwards with Bearer |
| proxy.ts | Checks cookie existence only. No backend calls. |
The Golden Rules (Separated Architecture Edition)
JWT never touches browser JavaScript. Use the BFF pattern: Next.js Route Handlers set httpOnly cookies and proxy API requests.
proxy.ts is still a fast reject. Cookie existence check only. No backend calls. No token refresh. 1ms.
Layout is still a UX layer, not security. It calls
/auth/meonce on first load. Partial Rendering means it doesn't re-run on navigation. Don't rely on it for security.The backend IS the DAL. Every backend endpoint must verify the Bearer token. This is your real security layer.
cache: 'no-store'on every authenticated fetch. If you cache authenticated responses, you risk serving User A's data to User B.import 'server-only'onlib/api.ts. Prevents Client Components from importing server-side cookie-reading code.Server Components call the backend directly. Client Components go through the BFF proxy. Two paths, one architecture.
Token refresh happens in the BFF proxy. The Route Handler catch-all detects 401, refreshes server-side, retries. The browser never knows.
For WebSocket/SSE, issue scoped tokens through the BFF. Short-lived (30s), single-purpose, single-endpoint. Even if stolen via XSS, blast radius is minimal.
Complete Vote Summary
| Topic | Result | Score |
|---|---|---|
| Token storage: httpOnly cookie via BFF? | Yes | 6-0 |
| BFF proxy worth the extra hop? | Yes (same VPC) | 6-0 |
| Three-layer defense still applies? | Yes, adapted | 6-0 |
| Token refresh location? | BFF Route Handler | 5-1 |
| Client holds main access token? | Never — use scoped tokens for WebSocket/SSE | 6-0 |
| Pure SPA acceptable for production? | No | 6-0 |
| Layout is security layer? | No — UX layer only | 6-0 |
最終裁決:分離架構完整實作指南
建議的檔案結構
app/
├── (public)/
│ ├── layout.tsx ← 公開 layout(不做認證)
│ ├── login/page.tsx
│ └── signup/page.tsx
├── (authenticated)/
│ ├── layout.tsx ← 認證 layout(透過後端 verifySession + UI 外殼)
│ ├── dashboard/page.tsx ← Server Component,使用 apiFetch()
│ ├── settings/page.tsx
│ └── admin/
│ ├── layout.tsx ← 角色檢查(透過後端)
│ └── page.tsx
├── layout.tsx ← 根 layout(providers、全域 CSS)
└── page.tsx ← 首頁
app/api/
├── auth/
│ ├── login/route.ts ← 轉發憑證,設定 httpOnly cookie
│ ├── logout/route.ts ← 清除 cookie
│ └── refresh/route.ts ← 刷新 access token
└── proxy/
└── [...path]/route.ts ← Catch-all:讀取 cookie,加 Bearer,轉發
lib/
├── api.ts ← 'server-only':verifySession()、apiFetch()
proxy.ts ← 網路邊界(cookie 存在性檢查)適配版三層防禦
| 層級 | 回答的問題 | 速度 | 實作方式 |
|---|---|---|---|
| proxy.ts | cookie 存在嗎? | 1ms | request.cookies.has('access-token') |
| Layout | session 有效嗎?(UX 層) | 50-100ms | verifySession() → 呼叫後端 /auth/me |
| 後端 API | 這個使用者有權存取這筆資料嗎? | 0ms 額外成本 | 每個端點都驗證 Bearer token |
各情境中的資料流
| 情境 | 認證如何運作 |
|---|---|
| Server Component | apiFetch() 讀取 cookie,直接呼叫後端(伺服器對伺服器) |
| Server Action | apiFetch() 讀取 cookie,直接呼叫後端 |
| Client Component | fetch('/api/proxy/...') → Route Handler 讀取 cookie,帶 Bearer 轉發 |
| proxy.ts | 只檢查 cookie 存在性。不呼叫後端。 |
黃金法則(分離架構版)
JWT 永遠不碰瀏覽器 JavaScript。 使用 BFF 模式:Next.js Route Handlers 設定 httpOnly cookie 並代理 API 請求。
proxy.ts 仍然是快速拒絕。 只做 cookie 存在性檢查。不呼叫後端。不刷新 token。1ms。
Layout 仍然是 UX 層,不是安全層。 它在首次載入時呼叫
/auth/me一次。Partial Rendering 意味著導航時不會重新執行。不要依賴它做安全。後端就是 DAL。 每個後端端點都必須驗證 Bearer token。這是你真正的安全層。
每個認證 fetch 都要
cache: 'no-store'。 如果你快取認證回應,你有風險把使用者 A 的資料提供給使用者 B。lib/api.ts上的import 'server-only'。 防止 Client Components 匯入伺服器端讀取 cookie 的程式碼。Server Components 直接呼叫後端。 Client Components 通過 BFF 代理。兩條路徑,一個架構。
Token 刷新在 BFF 代理中發生。 Route Handler catch-all 偵測到 401,伺服器端刷新,重試。瀏覽器永遠不知道。
WebSocket/SSE 透過 BFF 發行限定範圍 token。 短效(30 秒)、單一用途、單一端點。即使被 XSS 竊取,影響範圍也是最小的。
完整投票總結
| 議題 | 結果 | 比分 |
|---|---|---|
| Token 儲存:透過 BFF 的 httpOnly cookie? | 是 | 6-0 |
| BFF 代理值得多一次跳轉? | 是(同 VPC) | 6-0 |
| 三層防禦仍然適用? | 是,已適配 | 6-0 |
| Token 刷新位置? | BFF Route Handler | 5-1 |
| 客戶端持有主要 access token? | 永遠不行——WebSocket/SSE 用限定範圍 token | 6-0 |
| 純 SPA 可接受用於生產? | 否 | 6-0 |
| Layout 是安全層? | 不是——僅是 UX 層 | 6-0 |