Next.js 16 proxy.ts Implementation Checklist
Route Protection, Security Headers, Token Rotation, E2E Testing — Seven Experts Debate
"Plans are useless, but planning is indispensable." — Dwight D. Eisenhower
「計畫沒有用,但規劃不可或缺。」—— Dwight D. Eisenhower
The Setup
You have a Next.js 16 BFF deployed on AWS ECS. You've read the previous debates on public/private routes, cross-user data isolation, and CSRF protection. The architecture decisions are made. Now you have a concrete implementation checklist:
- Public/Private Route Protection — route matcher, auth check, redirect logic
- Security Headers — CSP, HSTS, X-Frame-Options, and friends
- E2E Test Auth Bypass — how to test without breaking security
- Token Rotation Strategy — sliding session vs refresh token rotation
- Integration Testing + Documentation — verifying it all works
Each item sounds simple. Each one has trap doors. We brought back the experts to argue about every detail.
場景設定
你有一個部署在 AWS ECS 上的 Next.js 16 BFF。你讀過之前關於 public/private routes、跨用戶資料隔離、CSRF 防護的辯論。架構決策已經做了。現在你有一份具體的實作待辦清單:
- Public/Private Route 路由保護 — route matcher、auth check、重導向邏輯
- 安全 Headers 配置 — CSP、HSTS、X-Frame-Options 及其同伴
- E2E 測試 Auth Bypass — 如何在不破壞安全的前提下測試
- Token Rotation 方案選型 — sliding session vs refresh token rotation
- 整合測試 + 文件 — 驗證一切運作
每一項聽起來都很簡單。每一項都有陷阱門。我們把專家們找回來,針對每個細節爭論。
Roundtable Participants:
Security:
- Marcus Webb — Application Security Lead, 15 years penetration testing, OWASP contributor
- Elena Kowalski — Cloud Security Architect at a major bank, CDN and infrastructure hardening specialist
Frontend Architecture:
- Raj Patel — Staff Frontend Engineer, maintains a BFF framework used by 8 product teams
- Lina Torres — Senior Frontend Engineer, burned by CVE-2025-29927, her mantra: "three layers or zero layers"
Infrastructure & Runtime:
- David Park — Staff SRE, deployed Next.js BFF on ECS across 4 subdomains with 12 services
- Sarah Chen — Principal Engineer, former Next.js core contributor
Performance & Testing:
- Kevin Wu — Performance engineer, ex-Vercel, obsessed with TTFB, believes proxy.ts should do as little as possible
Moderator:
- Ana Reyes — Engineering Director
圓桌會議參與者:
安全:
- Marcus Webb — Application Security Lead,15 年滲透測試經驗,OWASP 貢獻者
- Elena Kowalski — 大型銀行的 Cloud Security Architect,CDN 和基礎設施強化專家
前端架構:
- Raj Patel — Staff Frontend Engineer,維護一個供 8 個產品團隊使用的 BFF 框架
- Lina Torres — Senior Frontend Engineer,被 CVE-2025-29927 燒過,她的座右銘:「三層或零層」
基礎設施 & 運行時:
- David Park — Staff SRE,在 ECS 上跨 4 個子網域部署 12 個服務的 Next.js BFF
- Sarah Chen — Principal Engineer,前 Next.js 核心貢獻者
效能 & 測試:
- Kevin Wu — Performance engineer,前 Vercel,TTFB 強迫症,認為 proxy.ts 應該做越少越好
主持人:
- Ana Reyes — Engineering Director
Round 1: Public/Private Route Definition — Allowlist or Blocklist?
Ana: First item: defining which routes are public and which require authentication. Two approaches: explicitly list public routes (everything else is protected), or explicitly list protected routes (everything else is public). Which one?
Lina: Allowlist. No debate. The principle of least privilege demands it: deny by default, allow explicitly. If a developer adds /admin/nuclear-launch-codes and forgets to add it to the protection list in a blocklist approach, it's publicly accessible. With an allowlist approach, it's protected automatically.
// proxy.ts — Allowlist approach
const publicRoutes = ['/login', '/signup', '/forgot-password', '/'];
const publicPrefixes = ['/auth/', '/public/'];
function isPublicRoute(pathname: string): boolean {
if (publicRoutes.includes(pathname)) return true;
if (publicPrefixes.some(p => pathname.startsWith(p))) return true;
return false;
}Raj: I agree, but I want to add the config.matcher for performance. If you don't filter in the matcher, proxy.ts runs on every request — including _next/static, images, and favicon:
export const config = {
matcher: [
'/((?!_next/static|_next/image|favicon.ico|sitemap.xml|robots.txt).*)',
],
};Kevin: Emphatically. Yes. Without the matcher, proxy.ts fires on every static asset request. That's dozens of extra function executions per page load for zero benefit.
Sarah: One nuance: the response handling must differentiate between page routes and API routes. Page routes get a redirect. API routes get a 401 JSON response.
function handleUnauthorized(request: NextRequest) {
const { pathname } = request.nextUrl;
if (pathname.startsWith('/api/')) {
return Response.json(
{ error: 'Unauthorized', code: 'AUTH_REQUIRED' },
{ status: 401 }
);
}
const loginUrl = new URL('/login', request.url);
loginUrl.searchParams.set('returnUrl', pathname);
return NextResponse.redirect(loginUrl);
}Marcus: The returnUrl parameter is important for UX, but it's also a potential open redirect vulnerability. You must validate that returnUrl points to your own domain before using it after login.
Vote: "Allowlist (deny by default) or Blocklist (allow by default)?"
| Expert | Vote |
|---|---|
| Lina Torres | Allowlist — principle of least privilege |
| Kevin Wu | Allowlist — with config.matcher for static asset exclusion |
| Marcus Webb | Allowlist — deny by default is security 101 |
| Elena Kowalski | Allowlist |
| Raj Patel | Allowlist — every team I've worked with that used blocklist eventually had a leak |
| Sarah Chen | Allowlist |
| David Park | Allowlist |
Result: 7-0 — Explicit public route allowlist. Everything else is protected by default.
第一回合:Public/Private Route 定義——Allowlist 還是 Blocklist?
Ana: 第一項:定義哪些路由是公開的、哪些需要認證。兩種方法:明確列出公開路由(其他一切受保護),或明確列出受保護路由(其他一切公開)。哪一個?
Lina: Allowlist。沒有辯論餘地。最小權限原則要求:預設拒絕,明確允許。 如果開發者新增了 /admin/nuclear-launch-codes 但忘了在 blocklist 方法中加到保護清單,它就是公開可存取的。用 allowlist 方法,它自動受保護。
// proxy.ts — Allowlist 方法
const publicRoutes = ['/login', '/signup', '/forgot-password', '/'];
const publicPrefixes = ['/auth/', '/public/'];
function isPublicRoute(pathname: string): boolean {
if (publicRoutes.includes(pathname)) return true;
if (publicPrefixes.some(p => pathname.startsWith(p))) return true;
return false;
}Raj: 我同意,但我要加上 config.matcher 來提升效能。如果不在 matcher 中過濾,proxy.ts 會在每個請求上執行——包括 _next/static、圖片和 favicon:
export const config = {
matcher: [
'/((?!_next/static|_next/image|favicon.ico|sitemap.xml|robots.txt).*)',
],
};Kevin: 強調地。 是的。沒有 matcher,proxy.ts 會對每個靜態資源請求觸發。每個頁面載入數十次額外的函式執行,零收益。
Sarah: 一個細微之處:回應處理必須區分頁面路由和 API 路由。頁面路由拿到重導向。API 路由拿到 401 JSON 回應。
function handleUnauthorized(request: NextRequest) {
const { pathname } = request.nextUrl;
if (pathname.startsWith('/api/')) {
return Response.json(
{ error: 'Unauthorized', code: 'AUTH_REQUIRED' },
{ status: 401 }
);
}
const loginUrl = new URL('/login', request.url);
loginUrl.searchParams.set('returnUrl', pathname);
return NextResponse.redirect(loginUrl);
}Marcus: returnUrl 參數對 UX 很重要,但它也是潛在的 open redirect 漏洞。你必須在登入後使用 returnUrl 之前驗證它指向你自己的網域。
投票:「Allowlist(預設拒絕)還是 Blocklist(預設允許)?」
| 專家 | 投票 |
|---|---|
| Lina Torres | Allowlist——最小權限原則 |
| Kevin Wu | Allowlist——搭配 config.matcher 排除靜態資源 |
| Marcus Webb | Allowlist——預設拒絕是安全 101 |
| Elena Kowalski | Allowlist |
| Raj Patel | Allowlist——我合作過的每個用 blocklist 的團隊最終都出了漏洞 |
| Sarah Chen | Allowlist |
| David Park | Allowlist |
結果:7-0——明確的公開路由 allowlist。其他一切預設受保護。
Round 2: Auth Check in proxy.ts — Cookie Existence or Full Verification?
Ana: The auth check. proxy.ts runs on every request, including prefetches. Do we call the backend /auth/me to verify the token, or just check if the cookie exists?
Kevin: If you call the backend on every request, I will personally come to your office and unplug your server. proxy.ts runs on every matched request — page loads, client-side navigations, prefetches, soft navigations. Adding a network round-trip to every single one of those is a TTFB disaster.
Sarah: The official Next.js authentication guide is explicit: "Since Proxy runs on every route, including prefetched routes, it's important to only read the session from the cookie (optimistic checks), and avoid database checks to prevent performance issues."
Lina: But a pure cookie existence check is security theater. A deleted cookie blocks access. A malformed cookie passes through. An expired token passes through. The user gets to see the protected page skeleton before the Data Access Layer catches the invalid session.
Raj: There's a middle ground: lightweight JWT decode without signature verification. You base64-decode the JWT to check the exp claim. No network call, no crypto — just check if the token is expired:
import { jwtDecode } from 'jwt-decode';
export async function proxy(request: NextRequest) {
const token = request.cookies.get('__Host-session')?.value;
if (!isPublicRoute(request.nextUrl.pathname)) {
if (!token) return handleUnauthorized(request);
try {
const decoded = jwtDecode(token);
if (decoded.exp && decoded.exp * 1000 < Date.now()) {
return handleUnauthorized(request);
}
} catch {
// Malformed token — clear and redirect
const res = NextResponse.redirect(new URL('/login', request.url));
res.cookies.delete('__Host-session');
return res;
}
}
return NextResponse.next();
}Marcus: Sharply. I need to flag that jwtDecode does NOT verify the signature. Anyone can craft a JWT with a future exp claim. This is an optimistic check that catches expired and malformed tokens, not a security gate.
Lina: Exactly. That's why the three-layer defense is non-negotiable:
- proxy.ts: Optimistic check — cookie exists + not expired
- Data Access Layer (DAL): Full verification — call backend
/auth/mewithcache()for request deduplication - Route Handlers: Full verification — check auth before proxying
// lib/dal.ts — Full verification, called in Server Components
import { cache } from 'react';
import { cookies } from 'next/headers';
import { redirect } from 'next/navigation';
export const verifySession = cache(async () => {
const token = (await cookies()).get('__Host-session')?.value;
if (!token) redirect('/login');
const res = await fetch(`${process.env.BACKEND_API_URL}/auth/me`, {
headers: { Authorization: `Bearer ${token}` },
cache: 'no-store',
});
if (!res.ok) redirect('/login');
return res.json();
});Kevin: I support this. proxy.ts stays fast — zero network calls. The DAL catches expired sessions during rendering. Route Handlers catch them for API calls. Three layers, correct separation of concerns.
David: The cache() wrapper is important. If the layout, page, and three components all call verifySession(), it only makes one actual fetch to /auth/me per request.
Vote: "What level of auth check should proxy.ts perform?"
| Expert | Vote |
|---|---|
| Kevin Wu | Cookie existence + JWT expiry decode only — zero network calls |
| Sarah Chen | Cookie existence + JWT expiry decode — matches official Next.js guidance |
| Lina Torres | Cookie existence + JWT expiry decode — with mandatory DAL for full verification |
| Marcus Webb | Cookie existence + JWT expiry decode — but document clearly that this is optimistic, not a security gate |
| Raj Patel | Cookie existence + JWT expiry decode |
| Elena Kowalski | Cookie existence + JWT expiry decode |
| David Park | Cookie existence + JWT expiry decode — DAL does the real work |
Result: 7-0 — Optimistic check in proxy.ts (cookie existence + JWT expiry decode). Full verification in DAL and Route Handlers.
第二回合:proxy.ts 中的 Auth Check——Cookie 存在檢查還是完整驗證?
Ana: 認證檢查。proxy.ts 在每個請求上執行,包括 prefetch。我們要呼叫後端 /auth/me 來驗證 token,還是只檢查 cookie 是否存在?
Kevin: 如果你在每個請求上呼叫後端,我會親自到你的辦公室把你的伺服器插頭拔掉。proxy.ts 在每個匹配的請求上執行——頁面載入、客戶端導航、prefetch、軟導航。對每一個都加一個網路往返是 TTFB 災難。
Sarah: 官方 Next.js 認證指南說得很明確:「由於 Proxy 在每個路由上執行,包括預載的路由,重要的是只從 cookie 讀取 session(樂觀檢查),避免資料庫檢查以防止效能問題。」
Lina: 但純粹的 cookie 存在檢查是安全演戲。刪除的 cookie 會阻止存取。格式錯誤的 cookie 會通過。過期的 token 會通過。用戶會在 Data Access Layer 捕捉到無效 session 之前看到受保護頁面的骨架。
Raj: 有一個折衷:不做簽章驗證的輕量 JWT 解碼。 你 base64 解碼 JWT 來檢查 exp claim。沒有網路呼叫、沒有加密——只是檢查 token 是否過期:
import { jwtDecode } from 'jwt-decode';
export async function proxy(request: NextRequest) {
const token = request.cookies.get('__Host-session')?.value;
if (!isPublicRoute(request.nextUrl.pathname)) {
if (!token) return handleUnauthorized(request);
try {
const decoded = jwtDecode(token);
if (decoded.exp && decoded.exp * 1000 < Date.now()) {
return handleUnauthorized(request);
}
} catch {
// 格式錯誤的 token——清除並重導向
const res = NextResponse.redirect(new URL('/login', request.url));
res.cookies.delete('__Host-session');
return res;
}
}
return NextResponse.next();
}Marcus: 語氣銳利。 我需要指出 jwtDecode 不驗證簽章。任何人都可以製造一個帶有未來 exp claim 的 JWT。這是一個樂觀檢查,捕捉過期和格式錯誤的 token,不是安全閘門。
Lina: 正是如此。這就是為什麼三層防禦不可商量:
- proxy.ts:樂觀檢查——cookie 存在 + 未過期
- Data Access Layer (DAL):完整驗證——用
cache()做請求去重,呼叫後端/auth/me - Route Handlers:完整驗證——代理前檢查認證
// lib/dal.ts — 完整驗證,在 Server Components 中呼叫
import { cache } from 'react';
import { cookies } from 'next/headers';
import { redirect } from 'next/navigation';
export const verifySession = cache(async () => {
const token = (await cookies()).get('__Host-session')?.value;
if (!token) redirect('/login');
const res = await fetch(`${process.env.BACKEND_API_URL}/auth/me`, {
headers: { Authorization: `Bearer ${token}` },
cache: 'no-store',
});
if (!res.ok) redirect('/login');
return res.json();
});Kevin: 我支持這個。proxy.ts 保持快速——零網路呼叫。DAL 在渲染期間捕捉過期 session。Route Handlers 為 API 呼叫捕捉它們。三層,正確的關注點分離。
David: cache() 包裝很重要。如果 layout、page 和三個 components 都呼叫 verifySession(),每個請求只會對 /auth/me 做一次實際的 fetch。
投票:「proxy.ts 應該執行什麼層級的 auth 檢查?」
| 專家 | 投票 |
|---|---|
| Kevin Wu | 只有 cookie 存在 + JWT 過期解碼——零網路呼叫 |
| Sarah Chen | Cookie 存在 + JWT 過期解碼——符合官方 Next.js 指南 |
| Lina Torres | Cookie 存在 + JWT 過期解碼——必須搭配 DAL 做完整驗證 |
| Marcus Webb | Cookie 存在 + JWT 過期解碼——但要清楚記錄這是樂觀的,不是安全閘門 |
| Raj Patel | Cookie 存在 + JWT 過期解碼 |
| Elena Kowalski | Cookie 存在 + JWT 過期解碼 |
| David Park | Cookie 存在 + JWT 過期解碼——DAL 做真正的工作 |
結果:7-0——proxy.ts 中做樂觀檢查(cookie 存在 + JWT 過期解碼)。完整驗證在 DAL 和 Route Handlers 中。
Round 3: Security Headers — Where to Set Them and the CSP Nonce Trap
Ana: Security headers. Where do they go — proxy.ts, next.config.ts, or Route Handlers?
Elena: Split them by nature. Static headers go in next.config.ts. Dynamic headers (CSP with nonces) go in proxy.ts.
Static headers in next.config.ts — these don't need request-time data:
// next.config.ts
const nextConfig: NextConfig = {
async headers() {
return [{
source: '/(.*)',
headers: [
{ key: 'X-Content-Type-Options', value: 'nosniff' },
{ key: 'X-Frame-Options', value: 'DENY' },
{ key: 'Referrer-Policy', value: 'strict-origin-when-cross-origin' },
{ key: 'Permissions-Policy', value: 'camera=(), microphone=(), geolocation=()' },
{ key: 'Strict-Transport-Security', value: 'max-age=63072000; includeSubDomains; preload' },
{ key: 'X-DNS-Prefetch-Control', value: 'on' },
],
}];
},
};Marcus: Let me go through each one and why:
- X-Content-Type-Options: nosniff — Prevents MIME-type sniffing. No valid reason not to set it. Ever.
- X-Frame-Options: DENY — Prevents your app from being embedded in iframes. Use
SAMEORIGINonly if you embed your own pages. Also setframe-ancestors 'none'in CSP for modern browser coverage. - Referrer-Policy: strict-origin-when-cross-origin — Sends full URL for same-origin requests (useful for analytics), only the origin for cross-origin (doesn't leak paths). Best balance of utility and privacy.
- Permissions-Policy — Disables browser features you don't use. Blocks third-party scripts from accessing camera, microphone, etc.
- HSTS — Forces HTTPS. Start with
max-age=86400(1 day) in staging. Once verified, setmax-age=63072000(2 years) withpreload. Only addpreloadif you're committed to HTTPS permanently.
Sarah: Now the controversial one: CSP with nonces. This has to go in proxy.ts because each request needs a unique nonce:
// proxy.ts
export function proxy(request: NextRequest) {
const nonce = Buffer.from(crypto.randomUUID()).toString('base64');
const isDev = process.env.NODE_ENV === 'development';
const csp = `
default-src 'self';
script-src 'self' 'nonce-${nonce}' 'strict-dynamic'${isDev ? " 'unsafe-eval'" : ''};
style-src 'self' 'nonce-${nonce}'${isDev ? " 'unsafe-inline'" : ''};
img-src 'self' blob: data: https:;
font-src 'self';
connect-src 'self' ${process.env.NEXT_PUBLIC_API_URL || ''};
object-src 'none';
base-uri 'self';
form-action 'self';
frame-ancestors 'none';
upgrade-insecure-requests;
`.replace(/\s{2,}/g, ' ').trim();
const requestHeaders = new Headers(request.headers);
requestHeaders.set('x-nonce', nonce);
const response = NextResponse.next({ request: { headers: requestHeaders } });
response.headers.set('Content-Security-Policy', csp);
return response;
}Then in your layout, read the nonce and pass it to Script components:
// app/layout.tsx
import { headers } from 'next/headers';
export default async function RootLayout({ children }) {
const nonce = (await headers()).get('x-nonce') || '';
return (
<html>
<body>{children}</body>
</html>
);
}Kevin: Raising hand. Here's the trap. Nonce-based CSP is incompatible with Partial Prerendering (PPR). The static shell can't access the nonce. If you're using PPR, you can't use nonces. There's also a known issue with cacheComponents — GitHub Issue #89754.
Raj: This is a real problem. Our team spent two weeks debugging why CSP violations were firing in production — PPR was caching the static shell with one nonce, and subsequent requests had different nonces.
Sarah: The solution: if you use nonce-based CSP, you must force dynamic rendering. Add await connection() from next/server in your layout, or just accept that nonce pages won't be statically generated.
Lina: For teams that need PPR, the alternative is hash-based CSP instead of nonce-based. You hash your known inline scripts at build time. It's less flexible but compatible with static rendering.
Vote: "Where should each security header be set?"
| Expert | Vote |
|---|---|
| Elena Kowalski | Static headers in next.config.ts; CSP with nonces in proxy.ts |
| Marcus Webb | Same — with CSP report-uri for monitoring violations |
| Kevin Wu | Same — but flag: nonce CSP forces dynamic rendering, incompatible with PPR |
| Sarah Chen | Same — use hash-based CSP if PPR is required |
| Raj Patel | Same |
| Lina Torres | Same |
| David Park | Same — start HSTS with low max-age in staging |
Result: 7-0 — Static headers in next.config.ts. Dynamic CSP in proxy.ts. Nonce-based CSP is incompatible with PPR.
第三回合:安全 Headers——放在哪裡,以及 CSP Nonce 陷阱
Ana: 安全 headers。放在哪裡——proxy.ts、next.config.ts、還是 Route Handlers?
Elena: 按性質分開。靜態 headers 放在 next.config.ts。動態 headers(帶 nonce 的 CSP)放在 proxy.ts。
next.config.ts 中的靜態 headers ——這些不需要請求時資料:
// next.config.ts
const nextConfig: NextConfig = {
async headers() {
return [{
source: '/(.*)',
headers: [
{ key: 'X-Content-Type-Options', value: 'nosniff' },
{ key: 'X-Frame-Options', value: 'DENY' },
{ key: 'Referrer-Policy', value: 'strict-origin-when-cross-origin' },
{ key: 'Permissions-Policy', value: 'camera=(), microphone=(), geolocation=()' },
{ key: 'Strict-Transport-Security', value: 'max-age=63072000; includeSubDomains; preload' },
{ key: 'X-DNS-Prefetch-Control', value: 'on' },
],
}];
},
};Marcus: 讓我逐一說明每個和原因:
- X-Content-Type-Options: nosniff — 防止 MIME 類型嗅探。沒有不設定的理由。永遠沒有。
- X-Frame-Options: DENY — 防止你的應用被嵌入 iframe。只有在嵌入自己的頁面時才用
SAMEORIGIN。同時在 CSP 中設定frame-ancestors 'none'覆蓋現代瀏覽器。 - Referrer-Policy: strict-origin-when-cross-origin — 同源請求發送完整 URL(對分析有用),跨源只發送 origin(不洩漏路徑)。實用性和隱私的最佳平衡。
- Permissions-Policy — 禁用你不使用的瀏覽器功能。阻止第三方腳本存取相機、麥克風等。
- HSTS — 強制 HTTPS。staging 先用
max-age=86400(1 天)。驗證後設定max-age=63072000(2 年)加preload。只有確定永久使用 HTTPS 才加preload。
Sarah: 現在是有爭議的那個:帶 nonce 的 CSP。 這必須放在 proxy.ts 中,因為每個請求需要唯一的 nonce:
// proxy.ts
export function proxy(request: NextRequest) {
const nonce = Buffer.from(crypto.randomUUID()).toString('base64');
const isDev = process.env.NODE_ENV === 'development';
const csp = `
default-src 'self';
script-src 'self' 'nonce-${nonce}' 'strict-dynamic'${isDev ? " 'unsafe-eval'" : ''};
style-src 'self' 'nonce-${nonce}'${isDev ? " 'unsafe-inline'" : ''};
img-src 'self' blob: data: https:;
font-src 'self';
connect-src 'self' ${process.env.NEXT_PUBLIC_API_URL || ''};
object-src 'none';
base-uri 'self';
form-action 'self';
frame-ancestors 'none';
upgrade-insecure-requests;
`.replace(/\\s{2,}/g, ' ').trim();
const requestHeaders = new Headers(request.headers);
requestHeaders.set('x-nonce', nonce);
const response = NextResponse.next({ request: { headers: requestHeaders } });
response.headers.set('Content-Security-Policy', csp);
return response;
}然後在 layout 中讀取 nonce 並傳給 Script 組件:
// app/layout.tsx
import { headers } from 'next/headers';
export default async function RootLayout({ children }) {
const nonce = (await headers()).get('x-nonce') || '';
return (
<html>
<body>{children}</body>
</html>
);
}Kevin: 舉手。 陷阱在這裡。Nonce-based CSP 與 Partial Prerendering(PPR)不相容。 靜態 shell 無法存取 nonce。如果你用 PPR,就不能用 nonce。還有一個已知的 cacheComponents 問題——GitHub Issue #89754。
Raj: 這是真實的問題。我們團隊花了兩週除錯為什麼 CSP violations 在正式環境觸發——PPR 用一個 nonce 快取了靜態 shell,後續請求有不同的 nonce。
Sarah: 解決方案:如果你用 nonce-based CSP,必須強制動態渲染。在 layout 中加 await connection() from next/server,或者接受 nonce 頁面不會被靜態生成。
Lina: 對於需要 PPR 的團隊,替代方案是 hash-based CSP 而不是 nonce-based。你在構建時 hash 已知的 inline scripts。靈活性較低但與靜態渲染相容。
投票:「每個安全 header 應該設定在哪裡?」
| 專家 | 投票 |
|---|---|
| Elena Kowalski | 靜態 headers 在 next.config.ts;帶 nonce 的 CSP 在 proxy.ts |
| Marcus Webb | 同上——加上 CSP report-uri 監控違規 |
| Kevin Wu | 同上——但標記:nonce CSP 強制動態渲染,與 PPR 不相容 |
| Sarah Chen | 同上——如果需要 PPR 就用 hash-based CSP |
| Raj Patel | 同上 |
| Lina Torres | 同上 |
| David Park | 同上——staging 先用低 max-age 的 HSTS |
結果:7-0——靜態 headers 在 next.config.ts。動態 CSP 在 proxy.ts。Nonce-based CSP 與 PPR 不相容。
Round 4: Token Rotation — Sliding Session vs Refresh Token Rotation
Ana: Token rotation. Three options: sliding session, refresh token rotation, or server-side sessions. Go/No-Go for each.
Sarah: Let me lay them out:
Sliding Session: One token, extended on every request. Simple. But a stolen token never expires as long as the attacker keeps using it.
Refresh Token Rotation: Short-lived access token (15 minutes) + longer-lived refresh token (7 days). On each refresh, a new refresh token is issued and the old one is invalidated. Stolen refresh tokens are detected via rotation — if the old token is reused, the entire token family is revoked.
Server-Side Sessions: No JWT. Session ID in cookie maps to server-side state in Redis. Instantly revocable. But requires shared state between ECS instances.
David: For our ECS deployment, server-side sessions would require a Redis cluster shared across all container instances. That's additional infrastructure, additional failure modes, additional cost. Refresh token rotation keeps state on the backend API side — the BFF is stateless.
Marcus: From a security perspective, refresh token rotation is the sweet spot. Here's the stolen-token scenario:
- Attacker steals refresh token
- Attacker uses it to get a new access token + new refresh token
- The old refresh token is now invalidated
- When the legitimate user's BFF tries to use the old refresh token, the backend detects reuse
- The entire token family is revoked — attacker and user are both logged out
- User re-authenticates; attacker is locked out
This is called refresh token reuse detection, and it's the strongest defense against token theft in a stateless architecture.
Raj: The killer feature for BFF is that refresh is completely transparent to the client:
// In a Route Handler or DAL
export async function bffFetch(url: string, options: RequestInit = {}) {
const cookieStore = await cookies();
const accessToken = cookieStore.get('__Host-access')?.value;
const res = await fetch(`${BACKEND_URL}${url}`, {
...options,
headers: { ...options.headers, Authorization: `Bearer ${accessToken}` },
cache: 'no-store',
});
if (res.status === 401) {
const refreshed = await attemptRefresh();
if (refreshed) return bffFetch(url, { ...options }); // retry once
redirect('/login');
}
return res;
}
async function attemptRefresh(): Promise<boolean> {
const cookieStore = await cookies();
const refreshToken = cookieStore.get('__Host-refresh')?.value;
if (!refreshToken) return false;
const res = await fetch(`${BACKEND_URL}/auth/refresh`, {
method: 'POST',
headers: { Cookie: `refresh_token=${refreshToken}` },
});
if (!res.ok) return false;
const data = await res.json();
cookieStore.set('__Host-access', data.accessToken, {
httpOnly: true, secure: true, sameSite: 'lax',
path: '/', maxAge: 900,
});
cookieStore.set('__Host-refresh', data.refreshToken, {
httpOnly: true, secure: true, sameSite: 'lax',
path: '/', maxAge: 7 * 24 * 3600,
});
return true;
}The client makes a request, gets a response. It never knows refresh happened. The BFF handles everything.
Kevin: Can proxy.ts do the refresh too? To avoid rendering a page with an expired token?
Sarah: Yes, but only when the access token is actually expired. Don't call the refresh endpoint on every request:
// In proxy.ts — only refresh when access token is expired
if (isProtectedRoute(pathname) && accessToken) {
try {
const decoded = jwtDecode(accessToken);
if (decoded.exp && decoded.exp * 1000 < Date.now()) {
// Token expired — try refresh before rendering
const refreshToken = request.cookies.get('__Host-refresh')?.value;
if (refreshToken) {
const res = await fetch(`${BACKEND_URL}/auth/refresh`, {
method: 'POST',
headers: { Cookie: `refresh_token=${refreshToken}` },
});
if (res.ok) {
const data = await res.json();
const response = NextResponse.next();
response.cookies.set('__Host-access', data.accessToken, /* ... */);
response.cookies.set('__Host-refresh', data.refreshToken, /* ... */);
return response;
}
}
return handleUnauthorized(request);
}
} catch { /* malformed token */ }
}Lina: This adds a network call in proxy.ts, but only when the token is expired. For active users with valid tokens — which is 99%+ of requests — proxy.ts adds zero latency.
Vote: "Which token rotation strategy for the BFF?"
| Expert | Vote |
|---|---|
| Marcus Webb | Refresh token rotation — reuse detection is the strongest theft defense |
| Sarah Chen | Refresh token rotation — BFF makes it transparent to client |
| David Park | Refresh token rotation — no shared state needed between ECS instances |
| Kevin Wu | Refresh token rotation — with proxy.ts refresh only on expired tokens |
| Raj Patel | Refresh token rotation — we've used this for 2 years |
| Lina Torres | Refresh token rotation |
| Elena Kowalski | Refresh token rotation — server-side sessions only for highest-security apps |
Result: 7-0 — Refresh token rotation. BFF handles refresh transparently. proxy.ts can pre-render refresh when token is expired.
第四回合:Token Rotation——Sliding Session vs Refresh Token Rotation
Ana: Token rotation。三個選項:sliding session、refresh token rotation、或 server-side sessions。逐個 Go/No-Go。
Sarah: 讓我列出它們:
Sliding Session: 一個 token,每次請求時延長。簡單。但被偷的 token 只要攻擊者持續使用就永遠不會過期。
Refresh Token Rotation: 短命 access token(15 分鐘)+ 長命 refresh token(7 天)。每次 refresh 時,發出新的 refresh token 並使舊的失效。被偷的 refresh token 通過 rotation 偵測——如果舊 token 被重複使用,整個 token 家族被撤銷。
Server-Side Sessions: 沒有 JWT。Cookie 中的 session ID 映射到 Redis 中的伺服器端狀態。可即時撤銷。但需要 ECS 實例之間的共享狀態。
David: 對我們的 ECS 部署來說,server-side sessions 需要一個跨所有 container 實例共享的 Redis 集群。那是額外的基礎設施、額外的故障模式、額外的成本。Refresh token rotation 把狀態保留在後端 API 端——BFF 是無狀態的。
Marcus: 從安全角度來看,refresh token rotation 是甜蜜點。以下是 token 被偷的場景:
- 攻擊者偷到 refresh token
- 攻擊者用它取得新的 access token + 新的 refresh token
- 舊的 refresh token 現在已失效
- 當合法用戶的 BFF 嘗試使用舊的 refresh token 時,後端偵測到重複使用
- 整個 token 家族被撤銷——攻擊者和用戶都被登出
- 用戶重新認證;攻擊者被鎖定
這叫做 refresh token 重複使用偵測,是無狀態架構中對抗 token 竊取最強的防禦。
Raj: BFF 的殺手級功能是 refresh 對客戶端完全透明:
// 在 Route Handler 或 DAL 中
export async function bffFetch(url: string, options: RequestInit = {}) {
const cookieStore = await cookies();
const accessToken = cookieStore.get('__Host-access')?.value;
const res = await fetch(`${BACKEND_URL}${url}`, {
...options,
headers: { ...options.headers, Authorization: `Bearer ${accessToken}` },
cache: 'no-store',
});
if (res.status === 401) {
const refreshed = await attemptRefresh();
if (refreshed) return bffFetch(url, { ...options }); // 重試一次
redirect('/login');
}
return res;
}
async function attemptRefresh(): Promise<boolean> {
const cookieStore = await cookies();
const refreshToken = cookieStore.get('__Host-refresh')?.value;
if (!refreshToken) return false;
const res = await fetch(`${BACKEND_URL}/auth/refresh`, {
method: 'POST',
headers: { Cookie: `refresh_token=${refreshToken}` },
});
if (!res.ok) return false;
const data = await res.json();
cookieStore.set('__Host-access', data.accessToken, {
httpOnly: true, secure: true, sameSite: 'lax',
path: '/', maxAge: 900,
});
cookieStore.set('__Host-refresh', data.refreshToken, {
httpOnly: true, secure: true, sameSite: 'lax',
path: '/', maxAge: 7 * 24 * 3600,
});
return true;
}客戶端發出請求,收到回應。它永遠不知道 refresh 發生了。BFF 處理一切。
Kevin: proxy.ts 也能做 refresh 嗎?避免用過期 token 渲染頁面?
Sarah: 可以,但只在 access token 真正過期時。不要在每個請求上呼叫 refresh 端點:
// 在 proxy.ts 中——只在 access token 過期時 refresh
if (isProtectedRoute(pathname) && accessToken) {
try {
const decoded = jwtDecode(accessToken);
if (decoded.exp && decoded.exp * 1000 < Date.now()) {
// Token 過期——渲染前嘗試 refresh
const refreshToken = request.cookies.get('__Host-refresh')?.value;
if (refreshToken) {
const res = await fetch(`${BACKEND_URL}/auth/refresh`, {
method: 'POST',
headers: { Cookie: `refresh_token=${refreshToken}` },
});
if (res.ok) {
const data = await res.json();
const response = NextResponse.next();
response.cookies.set('__Host-access', data.accessToken, /* ... */);
response.cookies.set('__Host-refresh', data.refreshToken, /* ... */);
return response;
}
}
return handleUnauthorized(request);
}
} catch { /* 格式錯誤的 token */ }
}Lina: 這在 proxy.ts 中加了一個網路呼叫,但只在 token 過期時。對持有有效 token 的活躍用戶——這是 99% 以上的請求——proxy.ts 加零延遲。
投票:「BFF 該用哪種 token rotation 策略?」
| 專家 | 投票 |
|---|---|
| Marcus Webb | Refresh token rotation——重複使用偵測是最強的竊取防禦 |
| Sarah Chen | Refresh token rotation——BFF 讓它對客戶端透明 |
| David Park | Refresh token rotation——ECS 實例之間不需要共享狀態 |
| Kevin Wu | Refresh token rotation——proxy.ts 只在過期 token 時 refresh |
| Raj Patel | Refresh token rotation——我們用了 2 年 |
| Lina Torres | Refresh token rotation |
| Elena Kowalski | Refresh token rotation——server-side sessions 只給最高安全需求的應用 |
結果:7-0——Refresh token rotation。BFF 透明處理 refresh。proxy.ts 可以在 token 過期時做 pre-render refresh。
Round 5: E2E Test Auth Bypass — Real Auth or Mock?
Ana: E2E testing. Do we run the real auth flow with test credentials, or bypass auth entirely in test environments?
Kevin: Both. Use real auth for the auth test suite. Use auth bypass for everything else. The auth flow itself needs to be tested — login, redirect, cookie setting, token refresh. But your dashboard tests, your settings page tests, your feature tests — they shouldn't be flaky because the auth service had a hiccup.
Raj: Playwright's storageState is the right pattern. You authenticate once in a setup project, save the browser state (cookies, localStorage), and all subsequent tests start authenticated:
// playwright.config.ts
export default defineConfig({
projects: [
{ name: 'setup', testMatch: /.*\.setup\.ts/ },
{
name: 'authenticated',
use: { storageState: 'playwright/.auth/user.json' },
dependencies: ['setup'],
},
],
});// tests/auth.setup.ts
import { test as setup } from '@playwright/test';
setup('authenticate', async ({ page }) => {
await page.goto('/login');
await page.getByLabel('Email').fill(process.env.TEST_USER_EMAIL!);
await page.getByLabel('Password').fill(process.env.TEST_USER_PASSWORD!);
await page.getByRole('button', { name: 'Sign in' }).click();
await page.waitForURL('/dashboard');
await page.context().storageState({ path: 'playwright/.auth/user.json' });
});Marcus: Firmly. If you build a test-only auth bypass endpoint, it MUST be environment-gated with multiple safeguards:
// app/api/auth/test-login/route.ts
export async function POST(request: Request) {
if (process.env.NODE_ENV === 'production') {
return new Response('Not Found', { status: 404 });
}
if (process.env.E2E_TEST_MODE !== 'true') {
return new Response('Not Found', { status: 404 });
}
// ... set test cookies
}And add a startup check:
// In your app initialization
if (process.env.E2E_TEST_MODE === 'true' && process.env.NODE_ENV === 'production') {
throw new Error('FATAL: E2E_TEST_MODE enabled in production!');
}Lina: I prefer a different approach. Instead of a bypass endpoint, inject cookies directly in Playwright:
export async function injectAuthCookies(context: BrowserContext) {
await context.addCookies([{
name: '__Host-access',
value: generateTestJWT({ sub: 'test-user', exp: Date.now() / 1000 + 3600 }),
domain: 'localhost',
path: '/',
httpOnly: true,
secure: false,
sameSite: 'Lax',
}]);
}This doesn't require any test-only endpoints in the app. The JWT is self-contained — if your backend validates it (using a test signing key), no bypass endpoint is needed.
Sarah: For testing proxy.ts itself, Next.js 15.1+ provides experimental testing utilities:
import { unstable_doesProxyMatch } from 'next/experimental/testing/server';
test('matches protected routes', () => {
expect(unstable_doesProxyMatch({ config, url: '/dashboard' })).toBe(true);
});
test('skips static assets', () => {
expect(unstable_doesProxyMatch({ config, url: '/_next/static/chunk.js' })).toBe(false);
});Vote: "Real auth flow or auth bypass for E2E tests?"
| Expert | Vote |
|---|---|
| Kevin Wu | Both — real auth for auth suite, storageState for feature tests |
| Raj Patel | Both — Playwright storageState with setup project |
| Marcus Webb | Both — but bypass must be triple-gated (NODE_ENV + E2E_TEST_MODE + startup check) |
| Lina Torres | Both — prefer direct cookie injection over bypass endpoints |
| Sarah Chen | Both |
| Elena Kowalski | Both — and verify E2E_TEST_MODE is absent from production ECS task definitions |
| David Park | Both |
Result: 7-0 — Real auth for auth-specific tests. Auth bypass (storageState or cookie injection) for feature tests. Test-only endpoints must be environment-gated.
第五回合:E2E 測試 Auth Bypass——真實 Auth 還是 Mock?
Ana: E2E 測試。我們要用測試憑證跑真實的 auth flow,還是在測試環境中完全繞過 auth?
Kevin: 兩者都要。auth 測試套件用真實 auth。 其他所有東西用 auth bypass。 auth flow 本身需要被測試——登入、重導向、cookie 設定、token refresh。但你的儀表板測試、設定頁面測試、功能測試——它們不該因為 auth 服務打嗝就變得不穩定。
Raj: Playwright 的 storageState 是正確的模式。你在一個 setup project 中認證一次,儲存瀏覽器狀態(cookies、localStorage),所有後續測試都以已認證狀態開始:
// playwright.config.ts
export default defineConfig({
projects: [
{ name: 'setup', testMatch: /.*\.setup\.ts/ },
{
name: 'authenticated',
use: { storageState: 'playwright/.auth/user.json' },
dependencies: ['setup'],
},
],
});// tests/auth.setup.ts
import { test as setup } from '@playwright/test';
setup('authenticate', async ({ page }) => {
await page.goto('/login');
await page.getByLabel('Email').fill(process.env.TEST_USER_EMAIL!);
await page.getByLabel('Password').fill(process.env.TEST_USER_PASSWORD!);
await page.getByRole('button', { name: 'Sign in' }).click();
await page.waitForURL('/dashboard');
await page.context().storageState({ path: 'playwright/.auth/user.json' });
});Marcus: 堅定地。 如果你建立測試專用的 auth bypass 端點,它必須用多重安全措施做環境閘控:
// app/api/auth/test-login/route.ts
export async function POST(request: Request) {
if (process.env.NODE_ENV === 'production') {
return new Response('Not Found', { status: 404 });
}
if (process.env.E2E_TEST_MODE !== 'true') {
return new Response('Not Found', { status: 404 });
}
// ... 設定測試 cookies
}並加入啟動檢查:
// 在你的應用初始化中
if (process.env.E2E_TEST_MODE === 'true' && process.env.NODE_ENV === 'production') {
throw new Error('FATAL: E2E_TEST_MODE enabled in production!');
}Lina: 我偏好不同的方法。不用 bypass 端點,直接在 Playwright 中注入 cookies:
export async function injectAuthCookies(context: BrowserContext) {
await context.addCookies([{
name: '__Host-access',
value: generateTestJWT({ sub: 'test-user', exp: Date.now() / 1000 + 3600 }),
domain: 'localhost',
path: '/',
httpOnly: true,
secure: false,
sameSite: 'Lax',
}]);
}這不需要應用中任何測試專用端點。JWT 是自包含的——如果你的後端驗證它(用測試用的簽名金鑰),不需要 bypass 端點。
Sarah: 對於測試 proxy.ts 本身,Next.js 15.1+ 提供實驗性的測試工具:
import { unstable_doesProxyMatch } from 'next/experimental/testing/server';
test('matches protected routes', () => {
expect(unstable_doesProxyMatch({ config, url: '/dashboard' })).toBe(true);
});
test('skips static assets', () => {
expect(unstable_doesProxyMatch({ config, url: '/_next/static/chunk.js' })).toBe(false);
});投票:「E2E 測試用真實 auth flow 還是 auth bypass?」
| 專家 | 投票 |
|---|---|
| Kevin Wu | 兩者——auth 套件用真實 auth,功能測試用 storageState |
| Raj Patel | 兩者——Playwright storageState 搭配 setup project |
| Marcus Webb | 兩者——但 bypass 必須三重閘控(NODE_ENV + E2E_TEST_MODE + 啟動檢查) |
| Lina Torres | 兩者——偏好直接 cookie 注入而非 bypass 端點 |
| Sarah Chen | 兩者 |
| Elena Kowalski | 兩者——並驗證 E2E_TEST_MODE 不存在於 production ECS task definitions 中 |
| David Park | 兩者 |
結果:7-0——auth 特定測試用真實 auth。功能測試用 auth bypass(storageState 或 cookie 注入)。測試專用端點必須做環境閘控。
Round 6: Integration Testing and Security Header Verification
Ana: Final item. How do we verify that everything we've built actually works? Specifically: how do we test proxy.ts behavior, and how do we verify security headers in CI?
Sarah: For proxy.ts unit tests, use NextRequest directly:
// __tests__/proxy.test.ts
import { NextRequest } from 'next/server';
import { proxy } from '../proxy';
test('redirects unauthenticated user from /dashboard to /login', async () => {
const request = new NextRequest('http://localhost:3000/dashboard');
const response = await proxy(request);
expect(response.status).toBe(307);
expect(response.headers.get('Location')).toContain('/login?returnUrl=/dashboard');
});
test('allows authenticated user through', async () => {
const request = new NextRequest('http://localhost:3000/dashboard', {
headers: { cookie: '__Host-access=valid.jwt.token' },
});
const response = await proxy(request);
expect(response.status).toBe(200);
});
test('returns 401 JSON for unauthenticated API requests', async () => {
const request = new NextRequest('http://localhost:3000/api/protected/data');
const response = await proxy(request);
expect(response.status).toBe(401);
const body = await response.json();
expect(body.error).toBe('Unauthorized');
});
test('allows public routes without auth', async () => {
const request = new NextRequest('http://localhost:3000/login');
const response = await proxy(request);
expect(response.status).toBe(200);
});
test('CSP header includes nonce', async () => {
const request = new NextRequest('http://localhost:3000/');
const response = await proxy(request);
const csp = response.headers.get('Content-Security-Policy');
expect(csp).toContain("default-src 'self'");
expect(csp).toMatch(/nonce-[A-Za-z0-9+/=]+/);
});
test('CSP nonce is unique per request', async () => {
const res1 = await proxy(new NextRequest('http://localhost:3000/'));
const res2 = await proxy(new NextRequest('http://localhost:3000/'));
const nonce1 = res1.headers.get('Content-Security-Policy')!.match(/nonce-([A-Za-z0-9+/=]+)/)![1];
const nonce2 = res2.headers.get('Content-Security-Policy')!.match(/nonce-([A-Za-z0-9+/=]+)/)![1];
expect(nonce1).not.toBe(nonce2);
});Elena: For security header verification in CI, use Playwright E2E tests against the running app:
// e2e/security-headers.spec.ts
test('response includes all required security headers', async ({ page }) => {
const response = await page.goto('/');
const headers = response!.headers();
expect(headers['content-security-policy']).toBeTruthy();
expect(headers['x-content-type-options']).toBe('nosniff');
expect(headers['x-frame-options']).toBe('DENY');
expect(headers['referrer-policy']).toBe('strict-origin-when-cross-origin');
expect(headers['strict-transport-security']).toContain('max-age=');
});Marcus: And add automated scanning in your CI pipeline. Mozilla Observatory and DrHeader give you OWASP-aligned scoring:
# In your CI pipeline
- name: Security header scan
run: |
npm run build && npm start &
sleep 10
npx drheader scan single http://localhost:3000David: One more: test the auth redirect flow end-to-end:
// e2e/auth-flow.spec.ts
test.use({ storageState: { cookies: [], origins: [] } });
test('unauthenticated /dashboard redirects to /login with returnUrl', async ({ page }) => {
await page.goto('/dashboard');
await expect(page).toHaveURL(/\/login\?returnUrl=%2Fdashboard/);
});
test('API returns 401 for unauthenticated request', async ({ request }) => {
const res = await request.get('/api/protected/user');
expect(res.status()).toBe(401);
});Vote: "Is this testing strategy comprehensive enough?"
Unanimous agreement.
Result: 7-0 — Unit test proxy.ts with NextRequest. E2E test auth flows and security headers with Playwright. Automated security scanning in CI.
第六回合:整合測試和安全 Header 驗證
Ana: 最後一項。我們如何驗證我們建的一切實際上有效?具體來說:怎麼測試 proxy.ts 行為,怎麼在 CI 中驗證安全 headers?
Sarah: 對於 proxy.ts 單元測試,直接使用 NextRequest:
// __tests__/proxy.test.ts
import { NextRequest } from 'next/server';
import { proxy } from '../proxy';
test('未認證用戶從 /dashboard 重導向到 /login', async () => {
const request = new NextRequest('http://localhost:3000/dashboard');
const response = await proxy(request);
expect(response.status).toBe(307);
expect(response.headers.get('Location')).toContain('/login?returnUrl=/dashboard');
});
test('認證用戶通過', async () => {
const request = new NextRequest('http://localhost:3000/dashboard', {
headers: { cookie: '__Host-access=valid.jwt.token' },
});
const response = await proxy(request);
expect(response.status).toBe(200);
});
test('未認證的 API 請求返回 401 JSON', async () => {
const request = new NextRequest('http://localhost:3000/api/protected/data');
const response = await proxy(request);
expect(response.status).toBe(401);
const body = await response.json();
expect(body.error).toBe('Unauthorized');
});
test('公開路由不需認證', async () => {
const request = new NextRequest('http://localhost:3000/login');
const response = await proxy(request);
expect(response.status).toBe(200);
});
test('CSP header 包含 nonce', async () => {
const request = new NextRequest('http://localhost:3000/');
const response = await proxy(request);
const csp = response.headers.get('Content-Security-Policy');
expect(csp).toContain("default-src 'self'");
expect(csp).toMatch(/nonce-[A-Za-z0-9+/=]+/);
});
test('CSP nonce 每個請求唯一', async () => {
const res1 = await proxy(new NextRequest('http://localhost:3000/'));
const res2 = await proxy(new NextRequest('http://localhost:3000/'));
const nonce1 = res1.headers.get('Content-Security-Policy')!.match(/nonce-([A-Za-z0-9+/=]+)/)![1];
const nonce2 = res2.headers.get('Content-Security-Policy')!.match(/nonce-([A-Za-z0-9+/=]+)/)![1];
expect(nonce1).not.toBe(nonce2);
});Elena: 在 CI 中驗證安全 headers,用 Playwright E2E 測試對運行中的應用:
// e2e/security-headers.spec.ts
test('回應包含所有必要的安全 headers', async ({ page }) => {
const response = await page.goto('/');
const headers = response!.headers();
expect(headers['content-security-policy']).toBeTruthy();
expect(headers['x-content-type-options']).toBe('nosniff');
expect(headers['x-frame-options']).toBe('DENY');
expect(headers['referrer-policy']).toBe('strict-origin-when-cross-origin');
expect(headers['strict-transport-security']).toContain('max-age=');
});Marcus: 在 CI pipeline 中加自動化掃描。Mozilla Observatory 和 DrHeader 給你 OWASP 對齊的評分:
# 在你的 CI pipeline 中
- name: 安全 header 掃描
run: |
npm run build && npm start &
sleep 10
npx drheader scan single http://localhost:3000David: 再一個:端到端測試 auth 重導向流程:
// e2e/auth-flow.spec.ts
test.use({ storageState: { cookies: [], origins: [] } });
test('未認證的 /dashboard 重導向到帶 returnUrl 的 /login', async ({ page }) => {
await page.goto('/dashboard');
await expect(page).toHaveURL(/\/login\?returnUrl=%2Fdashboard/);
});
test('API 對未認證請求返回 401', async ({ request }) => {
const res = await request.get('/api/protected/user');
expect(res.status()).toBe(401);
});投票:「這個測試策略夠全面嗎?」
全場一致同意。
結果:7-0——用 NextRequest 單元測試 proxy.ts。用 Playwright E2E 測試 auth flow 和安全 headers。CI 中自動化安全掃描。
Final Verdict: The Complete Implementation Checklist
| Task | Decision | Details |
|---|---|---|
| Route definition | Public route allowlist (deny by default) | Explicit publicRoutes[] + publicPrefixes[]; config.matcher excludes static assets |
| Auth check in proxy.ts | Optimistic: cookie existence + JWT expiry decode | No network calls; jwtDecode for expiry check; three-layer defense with DAL |
| Unauthorized handling | Page routes → redirect to /login?returnUrl=; API routes → 401 JSON | Validate returnUrl against own domain to prevent open redirect |
| CSP | Nonce-based in proxy.ts with strict-dynamic | Incompatible with PPR; use hash-based CSP if PPR needed |
| Static security headers | next.config.ts headers() | HSTS, X-Frame-Options, X-Content-Type-Options, Referrer-Policy, Permissions-Policy |
| Token rotation | Refresh token rotation | 15-min access token + 7-day refresh token; BFF handles refresh transparently |
| proxy.ts refresh | Only when access token is expired | Zero latency for 99%+ of requests; network call only on expiry |
| E2E auth: auth suite | Real auth flow with test credentials | Tests login, redirect, cookie setting |
| E2E auth: feature tests | Playwright storageState or direct cookie injection | Auth setup project runs once, features reuse state |
| Test-only endpoints | Triple-gated: NODE_ENV + E2E_TEST_MODE + startup check | Verify absence from production ECS task definitions |
| proxy.ts unit tests | NextRequest-based tests for routes, auth, headers | Test each route category, CSP nonce uniqueness |
| Security header CI | Playwright E2E + DrHeader/Observatory automated scan | Fail CI if required headers are missing |
The 10 Golden Rules
- Deny by default. Use a public route allowlist. New routes are protected automatically.
- proxy.ts is fast, not thorough. Optimistic cookie + expiry check only. No network calls.
- DAL is thorough, not fast. Full
/auth/meverification withcache()deduplication. - Three layers or zero layers. proxy.ts → DAL → Route Handler. Never rely on one.
- Static headers in next.config.ts. Dynamic CSP in proxy.ts. Don't mix them up.
- Nonce-based CSP kills PPR. Choose one or use hash-based CSP.
- Refresh token rotation with BFF transparency. Client never sees tokens or refresh.
- Test auth with real flow. Test features with bypass. Both, not either/or.
- Test-only endpoints are triple-gated. NODE_ENV + flag + startup assertion.
- Scan security headers in CI. Every PR, every deploy, no exceptions.
最終裁決:完整實作檢查清單
| 任務 | 決策 | 詳情 |
|---|---|---|
| 路由定義 | 公開路由 allowlist(預設拒絕) | 明確的 publicRoutes[] + publicPrefixes[];config.matcher 排除靜態資源 |
| proxy.ts 中的 auth 檢查 | 樂觀:cookie 存在 + JWT 過期解碼 | 無網路呼叫;jwtDecode 做過期檢查;搭配 DAL 的三層防禦 |
| 未授權處理 | 頁面路由 → 重導向 /login?returnUrl=;API 路由 → 401 JSON | 驗證 returnUrl 指向自己的網域以防 open redirect |
| CSP | proxy.ts 中用 nonce-based 搭配 strict-dynamic | 與 PPR 不相容;需要 PPR 時用 hash-based CSP |
| 靜態安全 headers | next.config.ts headers() | HSTS、X-Frame-Options、X-Content-Type-Options、Referrer-Policy、Permissions-Policy |
| Token rotation | Refresh token rotation | 15 分鐘 access token + 7 天 refresh token;BFF 透明處理 refresh |
| proxy.ts refresh | 只在 access token 過期時 | 99% 以上的請求零延遲;只在過期時才有網路呼叫 |
| E2E auth:auth 套件 | 用測試憑證的真實 auth flow | 測試登入、重導向、cookie 設定 |
| E2E auth:功能測試 | Playwright storageState 或直接 cookie 注入 | Auth setup project 執行一次,功能測試重用狀態 |
| 測試專用端點 | 三重閘控:NODE_ENV + E2E_TEST_MODE + 啟動檢查 | 驗證 production ECS task definitions 中不存在 |
| proxy.ts 單元測試 | 基於 NextRequest 的路由、auth、headers 測試 | 測試每個路由類別、CSP nonce 唯一性 |
| 安全 header CI | Playwright E2E + DrHeader/Observatory 自動掃描 | 必要 headers 缺失就讓 CI 失敗 |
十條黃金準則
- 預設拒絕。 使用公開路由 allowlist。新路由自動受保護。
- proxy.ts 求快,不求全。 只做樂觀的 cookie + 過期檢查。無網路呼叫。
- DAL 求全,不求快。 用
cache()去重做完整的/auth/me驗證。 - 三層或零層。 proxy.ts → DAL → Route Handler。永遠不要只依賴一層。
- 靜態 headers 在 next.config.ts。動態 CSP 在 proxy.ts。 不要搞混。
- Nonce-based CSP 殺死 PPR。 二擇一,或用 hash-based CSP。
- Refresh token rotation 搭配 BFF 透明性。 客戶端永遠看不到 token 或 refresh。
- auth 用真實 flow 測試。功能用 bypass 測試。 兩者都要,不是二擇一。
- 測試專用端點三重閘控。 NODE_ENV + flag + 啟動斷言。
- CI 中掃描安全 headers。 每個 PR、每次部署,無例外。