Skip to content

Next.js 16 proxy.ts Implementation Checklist

Route Protection, Security Headers, Token Rotation, E2E Testing — Seven Experts Debate

Next.js 16 proxy.ts 實戰清單

路由保護、安全 Headers、Token Rotation、E2E 測試——七位專家圓桌激辯

"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:

  1. Public/Private Route Protection — route matcher, auth check, redirect logic
  2. Security Headers — CSP, HSTS, X-Frame-Options, and friends
  3. E2E Test Auth Bypass — how to test without breaking security
  4. Token Rotation Strategy — sliding session vs refresh token rotation
  5. 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 防護的辯論。架構決策已經做了。現在你有一份具體的實作待辦清單:

  1. Public/Private Route 路由保護 — route matcher、auth check、重導向邏輯
  2. 安全 Headers 配置 — CSP、HSTS、X-Frame-Options 及其同伴
  3. E2E 測試 Auth Bypass — 如何在不破壞安全的前提下測試
  4. Token Rotation 方案選型 — sliding session vs refresh token rotation
  5. 整合測試 + 文件 — 驗證一切運作

每一項聽起來都很簡單。每一項都有陷阱門。我們把專家們找回來,針對每個細節爭論。


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.

typescript
// 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:

typescript
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.

typescript
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)?"

ExpertVote
Lina TorresAllowlist — principle of least privilege
Kevin WuAllowlist — with config.matcher for static asset exclusion
Marcus WebbAllowlist — deny by default is security 101
Elena KowalskiAllowlist
Raj PatelAllowlist — every team I've worked with that used blocklist eventually had a leak
Sarah ChenAllowlist
David ParkAllowlist

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 方法,它自動受保護。

typescript
// 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:

typescript
export const config = {
  matcher: [
    '/((?!_next/static|_next/image|favicon.ico|sitemap.xml|robots.txt).*)',
  ],
};

Kevin: 強調地。 是的。沒有 matcher,proxy.ts 會對每個靜態資源請求觸發。每個頁面載入數十次額外的函式執行,零收益。

Sarah: 一個細微之處:回應處理必須區分頁面路由和 API 路由。頁面路由拿到重導向。API 路由拿到 401 JSON 回應。

typescript
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 TorresAllowlist——最小權限原則
Kevin WuAllowlist——搭配 config.matcher 排除靜態資源
Marcus WebbAllowlist——預設拒絕是安全 101
Elena KowalskiAllowlist
Raj PatelAllowlist——我合作過的每個用 blocklist 的團隊最終都出了漏洞
Sarah ChenAllowlist
David ParkAllowlist

結果:7-0——明確的公開路由 allowlist。其他一切預設受保護。


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:

typescript
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:

  1. proxy.ts: Optimistic check — cookie exists + not expired
  2. Data Access Layer (DAL): Full verification — call backend /auth/me with cache() for request deduplication
  3. Route Handlers: Full verification — check auth before proxying
typescript
// 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?"

ExpertVote
Kevin WuCookie existence + JWT expiry decode only — zero network calls
Sarah ChenCookie existence + JWT expiry decode — matches official Next.js guidance
Lina TorresCookie existence + JWT expiry decode — with mandatory DAL for full verification
Marcus WebbCookie existence + JWT expiry decode — but document clearly that this is optimistic, not a security gate
Raj PatelCookie existence + JWT expiry decode
Elena KowalskiCookie existence + JWT expiry decode
David ParkCookie 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 是否過期:

typescript
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: 正是如此。這就是為什麼三層防禦不可商量:

  1. proxy.ts:樂觀檢查——cookie 存在 + 未過期
  2. Data Access Layer (DAL):完整驗證——用 cache() 做請求去重,呼叫後端 /auth/me
  3. Route Handlers:完整驗證——代理前檢查認證
typescript
// 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 ChenCookie 存在 + JWT 過期解碼——符合官方 Next.js 指南
Lina TorresCookie 存在 + JWT 過期解碼——必須搭配 DAL 做完整驗證
Marcus WebbCookie 存在 + JWT 過期解碼——但要清楚記錄這是樂觀的,不是安全閘門
Raj PatelCookie 存在 + JWT 過期解碼
Elena KowalskiCookie 存在 + JWT 過期解碼
David ParkCookie 存在 + 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:

typescript
// 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 SAMEORIGIN only if you embed your own pages. Also set frame-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, set max-age=63072000 (2 years) with preload. Only add preload if 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:

typescript
// 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:

typescript
// 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?"

ExpertVote
Elena KowalskiStatic headers in next.config.ts; CSP with nonces in proxy.ts
Marcus WebbSame — with CSP report-uri for monitoring violations
Kevin WuSame — but flag: nonce CSP forces dynamic rendering, incompatible with PPR
Sarah ChenSame — use hash-based CSP if PPR is required
Raj PatelSame
Lina TorresSame
David ParkSame — 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 ——這些不需要請求時資料:

typescript
// 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:

typescript
// 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 組件:

typescript
// 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:

  1. Attacker steals refresh token
  2. Attacker uses it to get a new access token + new refresh token
  3. The old refresh token is now invalidated
  4. When the legitimate user's BFF tries to use the old refresh token, the backend detects reuse
  5. The entire token family is revoked — attacker and user are both logged out
  6. 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:

typescript
// 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:

typescript
// 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?"

ExpertVote
Marcus WebbRefresh token rotation — reuse detection is the strongest theft defense
Sarah ChenRefresh token rotation — BFF makes it transparent to client
David ParkRefresh token rotation — no shared state needed between ECS instances
Kevin WuRefresh token rotation — with proxy.ts refresh only on expired tokens
Raj PatelRefresh token rotation — we've used this for 2 years
Lina TorresRefresh token rotation
Elena KowalskiRefresh 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 被偷的場景:

  1. 攻擊者偷到 refresh token
  2. 攻擊者用它取得新的 access token + 新的 refresh token
  3. 舊的 refresh token 現在已失效
  4. 當合法用戶的 BFF 嘗試使用舊的 refresh token 時,後端偵測到重複使用
  5. 整個 token 家族被撤銷——攻擊者和用戶都被登出
  6. 用戶重新認證;攻擊者被鎖定

這叫做 refresh token 重複使用偵測,是無狀態架構中對抗 token 竊取最強的防禦。

Raj: BFF 的殺手級功能是 refresh 對客戶端完全透明:

typescript
// 在 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 端點:

typescript
// 在 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 WebbRefresh token rotation——重複使用偵測是最強的竊取防禦
Sarah ChenRefresh token rotation——BFF 讓它對客戶端透明
David ParkRefresh token rotation——ECS 實例之間不需要共享狀態
Kevin WuRefresh token rotation——proxy.ts 只在過期 token 時 refresh
Raj PatelRefresh token rotation——我們用了 2 年
Lina TorresRefresh token rotation
Elena KowalskiRefresh 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:

typescript
// playwright.config.ts
export default defineConfig({
  projects: [
    { name: 'setup', testMatch: /.*\.setup\.ts/ },
    {
      name: 'authenticated',
      use: { storageState: 'playwright/.auth/user.json' },
      dependencies: ['setup'],
    },
  ],
});
typescript
// 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:

typescript
// 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:

typescript
// 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:

typescript
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:

typescript
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?"

ExpertVote
Kevin WuBoth — real auth for auth suite, storageState for feature tests
Raj PatelBoth — Playwright storageState with setup project
Marcus WebbBoth — but bypass must be triple-gated (NODE_ENV + E2E_TEST_MODE + startup check)
Lina TorresBoth — prefer direct cookie injection over bypass endpoints
Sarah ChenBoth
Elena KowalskiBoth — and verify E2E_TEST_MODE is absent from production ECS task definitions
David ParkBoth

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),所有後續測試都以已認證狀態開始:

typescript
// playwright.config.ts
export default defineConfig({
  projects: [
    { name: 'setup', testMatch: /.*\.setup\.ts/ },
    {
      name: 'authenticated',
      use: { storageState: 'playwright/.auth/user.json' },
      dependencies: ['setup'],
    },
  ],
});
typescript
// 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 端點,它必須用多重安全措施做環境閘控:

typescript
// 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
}

並加入啟動檢查:

typescript
// 在你的應用初始化中
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:

typescript
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+ 提供實驗性的測試工具:

typescript
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:

typescript
// __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:

typescript
// 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:

yaml
# In your CI pipeline
- name: Security header scan
  run: |
    npm run build && npm start &
    sleep 10
    npx drheader scan single http://localhost:3000

David: One more: test the auth redirect flow end-to-end:

typescript
// 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

typescript
// __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 測試對運行中的應用:

typescript
// 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 對齊的評分:

yaml
# 在你的 CI pipeline 中
- name: 安全 header 掃描
  run: |
    npm run build && npm start &
    sleep 10
    npx drheader scan single http://localhost:3000

David: 再一個:端到端測試 auth 重導向流程:

typescript
// 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

TaskDecisionDetails
Route definitionPublic route allowlist (deny by default)Explicit publicRoutes[] + publicPrefixes[]; config.matcher excludes static assets
Auth check in proxy.tsOptimistic: cookie existence + JWT expiry decodeNo network calls; jwtDecode for expiry check; three-layer defense with DAL
Unauthorized handlingPage routes → redirect to /login?returnUrl=; API routes → 401 JSONValidate returnUrl against own domain to prevent open redirect
CSPNonce-based in proxy.ts with strict-dynamicIncompatible with PPR; use hash-based CSP if PPR needed
Static security headersnext.config.ts headers()HSTS, X-Frame-Options, X-Content-Type-Options, Referrer-Policy, Permissions-Policy
Token rotationRefresh token rotation15-min access token + 7-day refresh token; BFF handles refresh transparently
proxy.ts refreshOnly when access token is expiredZero latency for 99%+ of requests; network call only on expiry
E2E auth: auth suiteReal auth flow with test credentialsTests login, redirect, cookie setting
E2E auth: feature testsPlaywright storageState or direct cookie injectionAuth setup project runs once, features reuse state
Test-only endpointsTriple-gated: NODE_ENV + E2E_TEST_MODE + startup checkVerify absence from production ECS task definitions
proxy.ts unit testsNextRequest-based tests for routes, auth, headersTest each route category, CSP nonce uniqueness
Security header CIPlaywright E2E + DrHeader/Observatory automated scanFail CI if required headers are missing

The 10 Golden Rules

  1. Deny by default. Use a public route allowlist. New routes are protected automatically.
  2. proxy.ts is fast, not thorough. Optimistic cookie + expiry check only. No network calls.
  3. DAL is thorough, not fast. Full /auth/me verification with cache() deduplication.
  4. Three layers or zero layers. proxy.ts → DAL → Route Handler. Never rely on one.
  5. Static headers in next.config.ts. Dynamic CSP in proxy.ts. Don't mix them up.
  6. Nonce-based CSP kills PPR. Choose one or use hash-based CSP.
  7. Refresh token rotation with BFF transparency. Client never sees tokens or refresh.
  8. Test auth with real flow. Test features with bypass. Both, not either/or.
  9. Test-only endpoints are triple-gated. NODE_ENV + flag + startup assertion.
  10. 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
CSPproxy.ts 中用 nonce-based 搭配 strict-dynamic與 PPR 不相容;需要 PPR 時用 hash-based CSP
靜態安全 headersnext.config.ts headers()HSTS、X-Frame-Options、X-Content-Type-Options、Referrer-Policy、Permissions-Policy
Token rotationRefresh token rotation15 分鐘 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 CIPlaywright E2E + DrHeader/Observatory 自動掃描必要 headers 缺失就讓 CI 失敗

十條黃金準則

  1. 預設拒絕。 使用公開路由 allowlist。新路由自動受保護。
  2. proxy.ts 求快,不求全。 只做樂觀的 cookie + 過期檢查。無網路呼叫。
  3. DAL 求全,不求快。cache() 去重做完整的 /auth/me 驗證。
  4. 三層或零層。 proxy.ts → DAL → Route Handler。永遠不要只依賴一層。
  5. 靜態 headers 在 next.config.ts。動態 CSP 在 proxy.ts。 不要搞混。
  6. Nonce-based CSP 殺死 PPR。 二擇一,或用 hash-based CSP。
  7. Refresh token rotation 搭配 BFF 透明性。 客戶端永遠看不到 token 或 refresh。
  8. auth 用真實 flow 測試。功能用 bypass 測試。 兩者都要,不是二擇一。
  9. 測試專用端點三重閘控。 NODE_ENV + flag + 啟動斷言。
  10. CI 中掃描安全 headers。 每個 PR、每次部署,無例外。