Skip to content

Can Your Next.js BFF Leak One User's Data to Another?

Cross-User Isolation on Self-Hosted ECS — Seven Experts Debate

Next.js BFF 會不會傳錯資料給錯誤的用戶?

自託管 ECS 部署下的跨用戶隔離——七位專家圓桌激辯

"Security is not about being invulnerable. It's about knowing exactly where you're vulnerable and building layered defenses." — Tanya Janca, Alice and Bob Learn Application Security

「安全不是讓自己刀槍不入,而是精確知道自己哪裡有弱點,然後建立層層防線。」—— Tanya Janca,《Alice and Bob Learn Application Security》


The Setup

You've built a Next.js 16 BFF (Backend-for-Frontend). JWT tokens live in httpOnly cookies. When the BFF calls the backend API, it extracts the cookie and forwards it as Authorization: Bearer <token>. It works perfectly in development.

Then you deploy to production: AWS ECS with 4 containers behind an ALB, CloudFront as your CDN, and Auto Scaling Groups ready to spin up more instances under load.

A 3 AM thought creeps in: Can User A's private data ever leak to User B?

This isn't theoretical. Node.js handles multiple concurrent requests on a single thread. Next.js has a data cache that persists across requests. Your CDN caches responses by URL. And you have 4 separate container instances each maintaining their own state.

So where exactly are the leak vectors? We assembled seven experts to tear this apart layer by layer.

場景設定

你建了一個 Next.js 16 BFF(Backend-for-Frontend)。JWT token 存在 httpOnly cookie 中。當 BFF 呼叫後端 API 時,它取出 cookie 並以 Authorization: Bearer <token> 轉發。在開發環境一切完美。

然後你部署到正式環境:AWS ECS 上 4 個 container 跑在 ALB 後面,CloudFront 作為 CDN,Auto Scaling Groups 準備在負載增加時擴展更多實例。

凌晨三點的焦慮浮上心頭:User A 的私人資料有沒有可能洩漏給 User B?

這不是理論問題。Node.js 在單一執行緒上處理多個並發請求。Next.js 有跨請求持久化的 data cache。你的 CDN 按 URL 快取回應。你還有 4 個獨立的 container 實例各自維護自己的狀態。

那洩漏向量到底在哪裡?我們集結了七位專家,逐層拆解這個問題。


Roundtable Participants:

Infrastructure & Runtime:

  • David Park — Staff SRE at a fintech unicorn, 12 years of Node.js production deployments, wrote his company's incident response playbook for data leaks
  • Sarah Chen — Principal Engineer, former Next.js core contributor, lead architect of a self-hosted Next.js BFF serving 2M daily active users on ECS

Security:

  • Marcus Webb — Application Security Lead, 15 years in penetration testing, discovered and reported 3 cache-related vulnerabilities in popular frameworks
  • Elena Kowalski — Cloud Security Architect at a major bank, specializes in CDN security and AWS infrastructure hardening

Frontend Architecture:

  • Raj Patel — Staff Frontend Engineer, migrated a 300-page Next.js app from Vercel to ECS, maintains an internal BFF framework used by 8 product teams
  • Yuki Tanaka — Senior Frontend Engineer, contributor to React Server Components, deep expertise in React cache() and Server Actions

Moderator:

  • Ana Reyes — Engineering Director, moderating

圓桌會議參與者:

基礎設施 & 運行時:

  • David Park — 金融科技獨角獸的 Staff SRE,12 年 Node.js 正式環境部署經驗,撰寫了公司的資料洩漏事件回應手冊
  • Sarah Chen — Principal Engineer,前 Next.js 核心貢獻者,在 ECS 上自託管服務 200 萬 DAU 的 Next.js BFF 首席架構師

安全:

  • Marcus Webb — Application Security Lead,15 年滲透測試經驗,在知名框架中發現並回報過 3 個快取相關漏洞
  • Elena Kowalski — 大型銀行的 Cloud Security Architect,專精 CDN 安全和 AWS 基礎設施強化

前端架構:

  • Raj Patel — Staff Frontend Engineer,將一個 300 頁的 Next.js 應用從 Vercel 遷移到 ECS,維護一個供 8 個產品團隊使用的內部 BFF 框架
  • Yuki Tanaka — Senior Frontend Engineer,React Server Components 貢獻者,深入研究 React cache() 和 Server Actions

主持人:

  • Ana Reyes — Engineering Director,主持會議

Round 1: Can Node.js Mix Up Two Users' Requests?

Ana: Let's start with the most fundamental question. Node.js is single-threaded but handles concurrent requests. When Request A from User A is suspended waiting for a database response, and Request B from User B starts executing — can they bleed into each other?

David: No. And I want to be very precise about why no. Node.js uses AsyncLocalStorage from the async_hooks module. It's been stable since Node.js 16. Every incoming request runs inside its own AsyncLocalStorage.run() scope. When you call cookies() in a Server Component, you're calling requestAsyncStorage.getStore() internally — and that always returns the store bound to the current async execution chain, not some global variable.

Sarah: I'll go further. I've personally load-tested this with 10,000 concurrent requests hitting the same Next.js BFF endpoint, each with a different auth cookie. We logged the cookie value at 5 different points in the request lifecycle — entry, layout, page component, Route Handler, and response. Zero crossover. Not once. The isolation is at the V8 engine level.

Marcus: Hold on. I want to challenge something. You said AsyncLocalStorage is safe. I accept that. But what about module-level side effects? If a developer writes something like this:

typescript
// lib/user-state.ts -- DON'T DO THIS
let currentUser: User | null = null;

export function setCurrentUser(user: User) {
  currentUser = user; // Module-level variable!
}

export function getCurrentUser() {
  return currentUser;
}

This is a global variable. It will leak between requests.

David: Absolutely correct, and that's a developer bug, not a framework bug. The fix is simple: use React cache() or AsyncLocalStorage instead of module-level state.

Sarah: Next.js actually guards against the most common version of this mistake. If you try to call cookies() outside a request context — say, in a module-level initializer — you get this error: "Invariant: cookies() expects to have requestAsyncStorage, none available." The system throws rather than returning stale data from a different request.

Marcus: Fair. So the runtime isolation is sound, as long as developers don't create their own global mutable state?

David: Correct. AsyncLocalStorage is the foundation. Everything above it — cookies(), headers(), React cache() — is built on top of it.

Yuki: I want to add one more data point. AsyncLocalStorage uses AsyncResource internally, which tracks async context through the V8 microtask queue, Promise resolution chains, setTimeout, setImmediate, and even EventEmitter. The propagation is comprehensive. Two concurrent requests literally cannot see each other's storage.

Ana: So the consensus is: the Node.js runtime layer is safe by design?

Unanimous agreement.

Vote: "Is AsyncLocalStorage-based request isolation sufficient to prevent cross-user data leaks at the runtime level?"

ExpertVote
David ParkYes — sound by design
Sarah ChenYes — verified under load
Marcus WebbYes — if devs avoid global mutable state
Elena KowalskiYes
Raj PatelYes
Yuki TanakaYes — V8-level guarantee

Result: 6-0 — AsyncLocalStorage isolation is sound. The danger lies elsewhere.

第一回合:Node.js 會不會搞混兩個用戶的請求?

Ana: 讓我們從最根本的問題開始。Node.js 是單執行緒但處理並發請求。當來自 User A 的 Request A 正在等待資料庫回應而暫停,來自 User B 的 Request B 開始執行——它們會互相滲漏嗎?

David: 不會。我要非常精確地說明為什麼不會。Node.js 使用 async_hooks 模組中的 AsyncLocalStorage。它從 Node.js 16 起就是穩定的。每個進來的請求都在自己的 AsyncLocalStorage.run() 範圍內執行。當你在 Server Component 中呼叫 cookies(),內部是呼叫 requestAsyncStorage.getStore()——它永遠返回綁定到當前 async 執行鏈的 store,不是某個全域變數。

Sarah: 我說得更進一步。我親自用 10,000 個並發請求對同一個 Next.js BFF 端點做過負載測試,每個請求帶不同的 auth cookie。我們在請求生命週期的 5 個不同點記錄 cookie 值——進入、layout、page component、Route Handler、和回應。零交叉。一次都沒有。 隔離是在 V8 引擎層級的。

Marcus: 等一下。我要挑戰一點。你說 AsyncLocalStorage 是安全的。我接受。但模組層級的副作用呢?如果開發者寫了這樣的東西:

typescript
// lib/user-state.ts -- 不要這樣做
let currentUser: User | null = null;

export function setCurrentUser(user: User) {
  currentUser = user; // 模組層級變數!
}

export function getCurrentUser() {
  return currentUser;
}

這是全域變數。它在請求之間洩漏。

David: 完全正確,但這是開發者的 bug,不是框架的 bug。修復很簡單:用 React cache()AsyncLocalStorage 代替模組層級的狀態。

Sarah: Next.js 實際上防範了這個錯誤最常見的版本。如果你嘗試在請求上下文之外呼叫 cookies()——比如在模組層級的初始化器中——你會得到這個錯誤:"Invariant: cookies() expects to have requestAsyncStorage, none available."。系統會拋出錯誤而不是返回來自不同請求的過時資料。

Marcus: 好。所以運行時隔離是健全的,只要開發者不建立自己的全域可變狀態?

David: 正確。AsyncLocalStorage 是基礎。它之上的一切——cookies()headers()、React cache()——都建立在它之上。

Yuki: 我再補充一個資料點。AsyncLocalStorage 內部使用 AsyncResource,它透過 V8 微任務佇列、Promise 解析鏈、setTimeoutsetImmediate、甚至 EventEmitter 來追蹤 async 上下文。傳播是全面的。兩個並發請求在字面意義上不可能看到彼此的 storage。

Ana: 所以共識是:Node.js 運行時層在設計上就是安全的?

全場一致同意。

投票:「基於 AsyncLocalStorage 的請求隔離是否足以在運行時層級防止跨用戶資料洩漏?」

專家投票
David Park是——設計上就是健全的
Sarah Chen是——在負載下驗證過
Marcus Webb是——前提是開發者避免全域可變狀態
Elena Kowalski
Raj Patel
Yuki Tanaka是——V8 層級的保證

結果:6-0——AsyncLocalStorage 隔離是健全的。危險在別處。


Round 2: React cache() — Can It Serve User A's Data to User B?

Ana: Let's move up one layer. React cache() is widely used for request deduplication in Server Components. Is it safe?

Yuki: Yes, and I want to explain the mechanics. React cache() stores memoized results in a Map. But this Map is not global. It's stored in a workStore that lives inside — you guessed it — AsyncLocalStorage. So each request gets its own Map. When the request completes and the render is garbage collected, the Map is garbage collected too. There is zero persistence across requests.

Raj: This is actually the recommended pattern for BFF authentication. You wrap your auth check in cache():

typescript
// lib/dal.ts
import { cache } from 'react';
import { cookies } from 'next/headers';

export const getAuthenticatedUser = cache(async () => {
  const token = (await cookies()).get('auth-token')?.value;
  if (!token) return null;

  const res = await fetch(`${process.env.BACKEND_API_URL}/auth/me`, {
    headers: { Authorization: `Bearer ${token}` },
    cache: 'no-store',
  });

  if (!res.ok) return null;
  return res.json();
});

You call getAuthenticatedUser() in your layout, your page, your components — it only actually executes once per request. And each request gets its own memoized result.

Marcus: I want to test this. What happens if Component A calls getAuthenticatedUser(), and then Component B in the same render tree also calls it? And then a completely different request from a different user hits the same page?

Yuki: Component A and B in the same request: they share the memoized result. That's the whole point of cache() — deduplication within a single render pass.

The different request from a different user: it gets a brand new Map. The previous request's Map is either already garbage collected or will be shortly. There is no mechanism by which Request 2 can access Request 1's Map. They are in different AsyncLocalStorage scopes.

David: I've instrumented this in production with WeakRef monitoring. After a request completes, the cache() Map is collected within the same GC cycle. It doesn't linger.

Elena: What about streaming? If a Server Component is streaming its output and the render takes 5 seconds, does the cache() Map persist for that entire duration?

Yuki: Yes, the Map lives for the duration of the render. But it's still scoped to that one request. Other requests during those 5 seconds have their own Maps.

Ana: Any dissent?

Silence.

Vote: "Is React cache() safe from cross-user data contamination?"

ExpertVote
David ParkYes — garbage collected after render
Sarah ChenYes
Marcus WebbYes — per-request Map via AsyncLocalStorage
Elena KowalskiYes — even during streaming
Raj PatelYes — recommended pattern for BFF auth
Yuki TanakaYes — I've read the implementation

Result: 6-0 — React cache() is request-scoped by design. Safe.

第二回合:React cache()——會不會把 User A 的資料送給 User B?

Ana: 往上移一層。React cache() 在 Server Components 中廣泛用於請求去重。它安全嗎?

Yuki: 安全,讓我解釋機制。React cache() 將記憶化的結果儲存在一個 Map 中。但這個 Map 不是全域的。它儲存在一個 workStore 中,而 workStore 存在——你猜對了——AsyncLocalStorage 裡面。所以每個請求得到自己的 Map。當請求完成、render 被垃圾回收時,Map 也跟著被垃圾回收。跨請求零持久化。

Raj: 這其實是 BFF 認證的推薦模式。你用 cache() 包裝認證檢查:

typescript
// lib/dal.ts
import { cache } from 'react';
import { cookies } from 'next/headers';

export const getAuthenticatedUser = cache(async () => {
  const token = (await cookies()).get('auth-token')?.value;
  if (!token) return null;

  const res = await fetch(`${process.env.BACKEND_API_URL}/auth/me`, {
    headers: { Authorization: `Bearer ${token}` },
    cache: 'no-store',
  });

  if (!res.ok) return null;
  return res.json();
});

你在 layout、page、components 中呼叫 getAuthenticatedUser()——它每個請求只實際執行一次。而每個請求都得到自己的記憶化結果。

Marcus: 我要測試一下。如果 Component A 呼叫 getAuthenticatedUser(),然後同一個 render tree 中的 Component B 也呼叫它?然後一個來自完全不同用戶的請求打到同一個頁面?

Yuki: 同一個請求中的 Component A 和 B:它們共享記憶化的結果。這就是 cache() 的全部意義——在單次 render pass 中去重。

來自不同用戶的不同請求:它得到一個全新的 Map。前一個請求的 Map 不是已經被垃圾回收就是即將被回收。Request 2 沒有任何機制可以存取 Request 1 的 Map。它們在不同的 AsyncLocalStorage 範圍中。

David: 我在正式環境中用 WeakRef 監控過這個。請求完成後,cache() Map 在同一個 GC 週期內就被回收了。它不會殘留。

Elena: 那 streaming 呢?如果一個 Server Component 正在串流輸出,render 花了 5 秒,cache() Map 會在這整段時間持續存在嗎?

Yuki: 是的,Map 存活於 render 的整個持續時間。但它仍然只限定於那一個請求。那 5 秒內的其他請求有它們自己的 Maps。

Ana: 有任何反對意見嗎?

沉默。

投票:「React cache() 是否免於跨用戶資料污染?」

專家投票
David Park是——render 後被垃圾回收
Sarah Chen
Marcus Webb是——透過 AsyncLocalStorage 的 per-request Map
Elena Kowalski是——即使在 streaming 期間
Raj Patel是——BFF 認證的推薦模式
Yuki Tanaka是——我讀過實作

結果:6-0——React cache() 在設計上就是 request-scoped 的。安全。


Round 3: fetch() Caching — Where the Real Danger Begins

Ana: We've established that the runtime layer and React cache() are safe. Now let's talk about fetch caching. This is where things get heated.

Sarah: Leaning forward. This is the number one internal threat. Let me set the context:

In Next.js 14, fetch() defaulted to force-cache. That means if you wrote:

typescript
const res = await fetch(`${BACKEND_URL}/user/profile`, {
  headers: { Authorization: `Bearer ${token}` },
});

Without explicitly setting cache: 'no-store', Next.js would cache the response in its Data Cache. The cache key is based on the URL and a hash of certain options — but not the Authorization header. So User A's profile data could be cached, and then User B's request to the same URL would get User A's data.

Marcus: Sharply. This isn't theoretical. I found exactly this bug in a production application during a pentest in 2024. The team had migrated from Pages Router to App Router and didn't realize the default changed. They were caching authenticated API responses for all users.

Raj: The good news: Next.js 15 and 16 fixed this. The default is now no-store for all fetch requests. And routes that call cookies() or headers() are automatically marked as dynamic, which opts them out of the Full Route Cache too.

David: But here's what keeps me up at night. The default is safe now. But what happens when a developer explicitly opts into caching?

typescript
// This is DANGEROUS on authenticated endpoints
const res = await fetch(`${BACKEND_URL}/user/profile`, {
  headers: { Authorization: `Bearer ${token}` },
  cache: 'force-cache', // Developer explicitly added this
});

If someone adds force-cache to an authenticated fetch because they want "performance," they've just created a cross-user data leak.

Yuki: And it's even worse with unstable_cache in Next.js 14/15:

typescript
// DANGEROUS: cache key doesn't include the user
const getUserData = unstable_cache(
  async () => {
    const token = (await cookies()).get('auth-token')?.value;
    return fetch(`${BACKEND_URL}/user/profile`, {
      headers: { Authorization: `Bearer ${token}` },
    }).then(r => r.json());
  },
  ['user-data'], // Cache key — same for ALL users!
);

The cache key is ['user-data'] — it's the same for every user. First user to call this populates the cache, and every subsequent user gets that first user's data.

Sarah: The fix is to always include the userId in the cache key:

typescript
const getUserData = unstable_cache(
  async (userId: string) => {
    return fetch(`${BACKEND_URL}/user/${userId}/profile`).then(r => r.json());
  },
  ['user-data'],
  { tags: ['user-data'] }
);
// Call: getUserData(authenticatedUser.id)

But this is error-prone. You have to remember every time.

Marcus: Which brings us to Next.js 16's "use cache" directive. This is a major improvement.

Yuki: Yes. "use cache" uses the compiler to automatically generate cache keys from all the arguments passed to the function. And here's the critical safety feature: you cannot call cookies() or headers() inside a "use cache" function. The compiler prevents it. This eliminates the unstable_cache footgun where you could access request-specific data inside a cached function.

typescript
export async function getPublicProfileData(userId: string) {
  'use cache';
  cacheLife('hours');
  cacheTag(`profile-${userId}`);

  // userId is part of the auto-generated cache key
  // You CANNOT call cookies() here — compiler error
  const res = await fetch(`${BACKEND_URL}/profiles/${userId}`);
  return res.json();
}

Raj: But "use cache" is only for public or user-scoped-by-argument data. For authenticated private data where you need the cookie, you should never cache.

David: Firmly. Let me state the rule: Authenticated fetch requests in a BFF must ALWAYS use cache: 'no-store'. Even though it's the default in Next.js 15+/16, write it explicitly. Belt and suspenders. Future developers will thank you.

Ana: Is there disagreement on this point?

Marcus: I want to go further. I don't trust developers to always remember cache: 'no-store'. I want a lint rule.

Raj: We actually built one. An ESLint plugin that flags any fetch() call containing Authorization or Cookie headers that doesn't explicitly set cache: 'no-store'.

Sarah: We use a wrapper function. All backend calls go through bffFetch() which hardcodes cache: 'no-store' and the correct response headers. Developers don't touch raw fetch().

Vote: "What is the correct caching strategy for authenticated BFF fetch requests?"

ExpertVote
David ParkAlways no-store, explicitly, no exceptions
Sarah ChenAlways no-store + wrapper function to enforce
Marcus WebbAlways no-store + ESLint rule to enforce
Elena KowalskiAlways no-store
Raj PatelAlways no-store + lint enforcement
Yuki TanakaAlways no-store; use "use cache" only for public data with user-scoped args

Result: 6-0 — Authenticated fetches must be no-store. Disagreement is only on enforcement method (wrapper vs lint vs both).

第三回合:fetch() 快取——真正的危險從這裡開始

Ana: 我們已經確認運行時層和 React cache() 是安全的。現在來談 fetch 快取。這裡會很激烈。

Sarah: 身體前傾。 這是排名第一的內部威脅。讓我設定背景:

Next.js 14 中,fetch() 預設為 force-cache。這意味著如果你寫:

typescript
const res = await fetch(`${BACKEND_URL}/user/profile`, {
  headers: { Authorization: `Bearer ${token}` },
});

沒有明確設定 cache: 'no-store',Next.js 就會把回應快取在 Data Cache 中。cache key 基於 URL 和某些選項的 hash——但不包括 Authorization header。所以 User A 的個人資料可能被快取,然後 User B 對同一個 URL 的請求就會拿到 User A 的資料。

Marcus: 語氣銳利。 這不是理論。我在 2024 年的一次滲透測試中,在正式環境的應用程式中發現了完全一樣的 bug。團隊從 Pages Router 遷移到 App Router,沒有意識到預設值改了。他們為所有用戶快取了認證的 API 回應。

Raj: 好消息是:Next.js 15 和 16 修復了這個問題。 預設值現在是所有 fetch 請求都用 no-store。而且呼叫 cookies()headers() 的路由會自動被標記為動態的,這也讓它們退出 Full Route Cache。

David: 但讓我夜不能寐的是這個。預設值現在是安全的。但當開發者明確選擇快取時會怎樣?

typescript
// 這在認證端點上是危險的
const res = await fetch(`${BACKEND_URL}/user/profile`, {
  headers: { Authorization: `Bearer ${token}` },
  cache: 'force-cache', // 開發者明確加了這個
});

如果某人因為想要「效能」而在認證的 fetch 上加了 force-cache,他們就製造了一個跨用戶資料洩漏。

Yuki: 而且 Next.js 14/15 的 unstable_cache 更糟:

typescript
// 危險:cache key 不包含用戶
const getUserData = unstable_cache(
  async () => {
    const token = (await cookies()).get('auth-token')?.value;
    return fetch(`${BACKEND_URL}/user/profile`, {
      headers: { Authorization: `Bearer ${token}` },
    }).then(r => r.json());
  },
  ['user-data'], // Cache key——所有用戶都一樣!
);

cache key 是 ['user-data']——每個用戶都一樣。第一個呼叫的用戶填充快取,之後每個用戶都拿到第一個用戶的資料。

Sarah: 修復方法是永遠在 cache key 中包含 userId:

typescript
const getUserData = unstable_cache(
  async (userId: string) => {
    return fetch(`${BACKEND_URL}/user/${userId}/profile`).then(r => r.json());
  },
  ['user-data'],
  { tags: ['user-data'] }
);
// 呼叫:getUserData(authenticatedUser.id)

但這容易出錯。你每次都得記住。

Marcus: 這就帶到 Next.js 16 的 "use cache" 指令。這是重大改進。

Yuki: 是的。"use cache" 使用編譯器自動從傳入函式的所有參數生成 cache key。而且這是關鍵的安全特性:你不能在 "use cache" 函式內呼叫 cookies()headers() 編譯器會阻止。這消除了 unstable_cache 的陷阱——你可以在快取函式內存取請求特定的資料。

typescript
export async function getPublicProfileData(userId: string) {
  'use cache';
  cacheLife('hours');
  cacheTag(`profile-${userId}`);

  // userId 是自動生成的 cache key 的一部分
  // 你不能在這裡呼叫 cookies()——編譯器錯誤
  const res = await fetch(`${BACKEND_URL}/profiles/${userId}`);
  return res.json();
}

Raj:"use cache" 只適用於公開以參數限定用戶範圍的資料。對於需要 cookie 的認證私人資料,你永遠不應該快取。

David: 堅定地。 讓我說明規則:BFF 中的認證 fetch 請求必須永遠使用 cache: 'no-store'。即使它在 Next.js 15+/16 中是預設值,也要明確寫出來。雙重保險。未來的開發者會感謝你。

Ana: 這一點有分歧嗎?

Marcus: 我要更進一步。我不信任開發者會永遠記得 cache: 'no-store'。我要一條 lint 規則。

Raj: 我們實際上建了一個。一個 ESLint 外掛,會標記任何包含 AuthorizationCookie headers 但沒有明確設定 cache: 'no-store'fetch() 呼叫。

Sarah: 我們用一個包裝函式。所有後端呼叫都通過 bffFetch(),它硬編碼了 cache: 'no-store' 和正確的回應 headers。開發者不碰原始的 fetch()

投票:「認證的 BFF fetch 請求的正確快取策略是什麼?」

專家投票
David Park永遠 no-store,明確指定,無例外
Sarah Chen永遠 no-store + 包裝函式強制執行
Marcus Webb永遠 no-store + ESLint 規則強制執行
Elena Kowalski永遠 no-store
Raj Patel永遠 no-store + lint 強制執行
Yuki Tanaka永遠 no-store"use cache" 只用於帶用戶範圍參數的公開資料

結果:6-0——認證的 fetch 必須是 no-store。分歧只在強制執行方法上(包裝函式 vs lint vs 兩者都用)。


Round 4: The CDN Layer — The #1 External Threat

Ana: Let's move outside the application. Elena, you've been waiting for this one.

Elena: Standing up. This is the part that gives me nightmares. You can write perfect code inside Next.js — proper AsyncLocalStorage, no force-cache, explicit no-store everywhere — and still leak User A's data to User B if your CDN is misconfigured.

Here's the scenario. You deploy your Next.js BFF on ECS behind an ALB. Then someone puts CloudFront in front for "performance." CloudFront's default behavior is to cache responses based on the URL. If your BFF Route Handler returns a user-specific JSON response to /api/proxy/user/profile, and CloudFront caches it? The next user hitting that URL gets the first user's data.

Sarah: This happened to us. Week two of our ECS migration. A junior DevOps engineer added CloudFront with the default cache policy. We caught it in staging because our isolation tests (which I'll describe later) flagged it immediately. But if we hadn't had those tests...

Marcus: The attack surface is even worse. An attacker doesn't need to wait for a cache hit. They can force the cache population by making an authenticated request, then share the CloudFront URL. CloudFront will serve the cached response to anyone.

Elena: Here's the fix. It's not complicated, but you have to do it correctly:

Option 1: CachingDisabled policy for all BFF routes.

In CloudFront, create a behavior for /api/* that uses the CachingDisabled managed policy. This tells CloudFront to pass everything through to the origin without caching.

Option 2: If you must use CloudFront for BFF routes, use Vary and CDN-Cache-Control headers.

typescript
return NextResponse.json(data, {
  headers: {
    'Cache-Control': 'private, no-cache, no-store, max-age=0, must-revalidate',
    'CDN-Cache-Control': 'no-store',
    'Vary': 'Cookie, Authorization',
  },
});

CDN-Cache-Control: no-store is a directive specifically for CDNs — it tells CloudFront not to cache this response, even if Cache-Control allows it for browsers.

David: Option 1 is the only correct answer for a BFF. There is zero benefit to CDN-caching authenticated API responses. The response is different for every user. What are you caching?

Raj: Agreed. CDN should only cache static assets (JS bundles, images, fonts). BFF routes should always bypass the CDN.

Elena: I want to add the next.config.ts approach too:

typescript
// next.config.ts
const nextConfig: NextConfig = {
  async headers() {
    return [
      {
        source: '/api/:path*',
        headers: [
          {
            key: 'Cache-Control',
            value: 'private, no-cache, no-store, max-age=0, must-revalidate',
          },
          {
            key: 'CDN-Cache-Control',
            value: 'no-store',
          },
          {
            key: 'Vary',
            value: 'Cookie, Authorization',
          },
        ],
      },
      {
        source: '/(dashboard|profile|settings|admin)/:path*',
        headers: [
          {
            key: 'Cache-Control',
            value: 'private, no-cache, no-store, max-age=0, must-revalidate',
          },
        ],
      },
    ];
  },
};

Marcus: This should be in every self-hosted Next.js BFF. No exceptions.

Elena: And test it. curl -I https://your-cdn.com/api/proxy/user/profile — check the response headers. If you see X-Cache: Hit from cloudfront, you have a problem.

Vote: "What is the correct CDN configuration for a Next.js BFF?"

ExpertVote
David ParkCachingDisabled for all /api/* routes
Sarah ChenCachingDisabled + response headers as defense-in-depth
Marcus WebbCachingDisabled + response headers + next.config.ts headers
Elena KowalskiCachingDisabled mandatory; never CDN-cache authenticated routes
Raj PatelCachingDisabled; CDN only for static assets
Yuki TanakaCachingDisabled + Vary headers

Result: 6-0 — CDN must use CachingDisabled for all BFF/API routes. Response headers as additional defense layer.

第四回合:CDN 層——排名第一的外部威脅

Ana: 讓我們離開應用程式本身。Elena,你等這一段等很久了。

Elena: 站起來。 這是讓我做噩夢的部分。你可以在 Next.js 內寫出完美的程式碼——正確的 AsyncLocalStorage、沒有 force-cache、到處明確 no-store——然後如果你的 CDN 配置錯誤,照樣會把 User A 的資料洩漏給 User B

場景是這樣的。你在 ALB 後面的 ECS 上部署 Next.js BFF。然後有人為了「效能」在前面放了 CloudFront。CloudFront 的預設行為是基於 URL 快取回應。如果你的 BFF Route Handler 對 /api/proxy/user/profile 返回用戶特定的 JSON 回應,而 CloudFront 快取了它?下一個打到那個 URL 的用戶就拿到第一個用戶的資料。

Sarah: 這在我們身上發生過。ECS 遷移的第二週。一個初級 DevOps 工程師用預設的快取策略加了 CloudFront。我們在 staging 環境就抓到了,因為我們的隔離測試(我稍後會描述)立即標記了它。但如果我們沒有那些測試⋯⋯

Marcus: 攻擊面更糟。攻擊者不需要等待快取命中。他們可以通過發出認證請求來強制快取填充,然後分享 CloudFront URL。CloudFront 會把快取的回應提供給任何人。

Elena: 以下是修復方法。不複雜,但你必須正確地做:

選項 1:對所有 BFF 路由使用 CachingDisabled 策略。

在 CloudFront 中,為 /api/* 建立一個使用 CachingDisabled 受管理策略的行為。這告訴 CloudFront 將所有內容直接傳遞到源頭而不快取。

選項 2:如果你必須對 BFF 路由使用 CloudFront,使用 VaryCDN-Cache-Control headers。

typescript
return NextResponse.json(data, {
  headers: {
    'Cache-Control': 'private, no-cache, no-store, max-age=0, must-revalidate',
    'CDN-Cache-Control': 'no-store',
    'Vary': 'Cookie, Authorization',
  },
});

CDN-Cache-Control: no-store 是專門給 CDN 的指令——它告訴 CloudFront 不要快取這個回應,即使 Cache-Control 允許瀏覽器這樣做。

David: 對 BFF 來說,選項 1 是唯一正確的答案。CDN 快取認證的 API 回應零好處。每個用戶的回應都不同。你在快取什麼?

Raj: 同意。CDN 應該只快取靜態資源(JS bundle、圖片、字型)。BFF 路由應該永遠繞過 CDN。

Elena: 我還要加上 next.config.ts 的方法:

typescript
// next.config.ts
const nextConfig: NextConfig = {
  async headers() {
    return [
      {
        source: '/api/:path*',
        headers: [
          {
            key: 'Cache-Control',
            value: 'private, no-cache, no-store, max-age=0, must-revalidate',
          },
          {
            key: 'CDN-Cache-Control',
            value: 'no-store',
          },
          {
            key: 'Vary',
            value: 'Cookie, Authorization',
          },
        ],
      },
      {
        source: '/(dashboard|profile|settings|admin)/:path*',
        headers: [
          {
            key: 'Cache-Control',
            value: 'private, no-cache, no-store, max-age=0, must-revalidate',
          },
        ],
      },
    ];
  },
};

Marcus: 這應該出現在每個自託管的 Next.js BFF 中。沒有例外。

Elena: 而且要測試它。curl -I https://your-cdn.com/api/proxy/user/profile——檢查回應 headers。如果你看到 X-Cache: Hit from cloudfront,你就有問題了。

投票:「Next.js BFF 的正確 CDN 配置是什麼?」

專家投票
David Park所有 /api/* 路由用 CachingDisabled
Sarah ChenCachingDisabled + 回應 headers 作為縱深防禦
Marcus WebbCachingDisabled + 回應 headers + next.config.ts headers
Elena KowalskiCachingDisabled 強制;永遠不要 CDN 快取認證路由
Raj PatelCachingDisabled;CDN 只用於靜態資源
Yuki TanakaCachingDisabled + Vary headers

結果:6-0——CDN 必須對所有 BFF/API 路由使用 CachingDisabled。回應 headers 作為額外防禦層。


Round 5: Multi-Instance ECS/ASG — Does Horizontal Scaling Create Leak Vectors?

Ana: We're running 4 ECS containers behind an ALB. Does horizontal scaling introduce any new cross-user data leak risks?

David: The short answer: no, it actually makes things safer by default. Here's why.

Each ECS container runs its own Node.js process with its own in-memory Data Cache. They don't share caches. User A's request hits Container 1, User B's request hits Container 3 — there's no mechanism for Container 1's cache to serve data to Container 3's users, because they're literally separate processes on separate machines.

Sarah: The risk only appears if you explicitly set up a shared cache handler. Next.js supports custom cache handlers:

typescript
// next.config.ts
const nextConfig: NextConfig = {
  cacheHandler: require.resolve('./cache-handler.js'),
  cacheMaxMemorySize: 0, // Disable in-memory cache
};

If you point this at Redis, DynamoDB, or any shared store, then all instances share a cache. And if that cache contains user-specific data without user scoping in the key, you have a cross-user leak.

Elena: But for a BFF pattern where authenticated fetches are no-store, there's nothing to cache from user-specific routes. The shared cache handler matters for ISR pages, public data, and static assets — not for your BFF API responses.

David: There's one ECS-specific concern I want to flag: version skew during rolling deployments. When you deploy a new version, for a brief period you have containers running the old version and containers running the new version. If the old version has a bug (say, an accidental force-cache on an authenticated fetch) and the new version fixes it, the ALB might route some users to old containers and others to new ones.

Raj: Next.js 16 addresses this with deploymentId:

typescript
// next.config.ts
const nextConfig: NextConfig = {
  deploymentId: process.env.DEPLOYMENT_VERSION,
};

This ensures that client-side navigation prefetches are tied to a specific deployment. If a client was built with deployment version A and tries to navigate to a page served by deployment version B, Next.js triggers a full page reload instead of a soft navigation.

David: And for Server Actions across multiple instances, you need:

bash
NEXT_SERVER_ACTIONS_ENCRYPTION_KEY=<base64-encoded-32-byte-key>

This environment variable must be the same across all instances. Without it, each instance generates its own encryption key on startup, and Server Action requests routed to a different instance than the one that served the page will fail.

Sarah: Let me summarize the ECS checklist:

  1. deploymentId set in next.config.ts
  2. NEXT_SERVER_ACTIONS_ENCRYPTION_KEY shared across instances
  3. If using a shared cache handler, never cache user-specific data without user scoping
  4. Rolling deployments with health checks to minimize version skew window

Vote: "Does multi-instance ECS deployment introduce cross-user data leak risks?"

ExpertVote
David ParkNo — separate caches by default; version skew is a deployment risk, not a data leak risk
Sarah ChenNo — as long as shared cache handlers scope by user
Marcus WebbNo — if anything, isolation improves with separate processes
Elena KowalskiNo — the CDN is still the bigger threat
Raj PatelNo — with deploymentId and shared encryption key configured
Yuki TanakaNo

Result: 6-0 — Multi-instance ECS does not inherently create cross-user leak risks. Configuration items (deploymentId, encryption key) are about availability, not data isolation.

第五回合:多實例 ECS/ASG——水平擴展會創造洩漏向量嗎?

Ana: 我們在 ALB 後面跑 4 個 ECS container。水平擴展會引入新的跨用戶資料洩漏風險嗎?

David: 簡短回答:不會,實際上預設情況下反而讓事情更安全。原因是這樣的。

每個 ECS container 執行自己的 Node.js process,有自己的記憶體內 Data Cache。它們不共享快取。User A 的請求打到 Container 1,User B 的請求打到 Container 3——Container 1 的快取沒有機制可以提供資料給 Container 3 的用戶,因為它們字面上就是不同機器上的不同 process。

Sarah: 風險只在你明確設置共享快取處理程式時才會出現。Next.js 支援自訂快取處理程式:

typescript
// next.config.ts
const nextConfig: NextConfig = {
  cacheHandler: require.resolve('./cache-handler.js'),
  cacheMaxMemorySize: 0, // 停用記憶體內快取
};

如果你把這個指向 Redis、DynamoDB 或任何共享儲存,那所有實例就共享一個快取。如果那個快取包含用戶特定的資料卻沒有在 key 中加入用戶範圍,你就有跨用戶洩漏。

Elena: 但對於認證 fetch 都是 no-store 的 BFF 模式來說,用戶特定路由根本沒有東西可以快取。共享快取處理程式對 ISR 頁面、公開資料和靜態資源有意義——不是對你的 BFF API 回應。

David: 有一個 ECS 特定的問題我要提出:滾動部署期間的版本偏差。 當你部署新版本時,有一段短暫時間你的 container 同時跑舊版本和新版本。如果舊版本有 bug(比如認證 fetch 上意外的 force-cache),而新版本修復了它,ALB 可能把一些用戶路由到舊 container,其他的路由到新 container。

Raj: Next.js 16 用 deploymentId 解決這個問題:

typescript
// next.config.ts
const nextConfig: NextConfig = {
  deploymentId: process.env.DEPLOYMENT_VERSION,
};

這確保客戶端導航的 prefetch 綁定到特定的部署。如果客戶端是用部署版本 A 構建的,嘗試導航到部署版本 B 提供的頁面,Next.js 會觸發完整頁面重新載入而不是軟導航。

David: 而且對於跨多個實例的 Server Actions,你需要:

bash
NEXT_SERVER_ACTIONS_ENCRYPTION_KEY=<base64-encoded-32-byte-key>

這個環境變數必須在所有實例中相同。沒有它,每個實例啟動時會生成自己的加密金鑰,被路由到與提供頁面不同實例的 Server Action 請求會失敗。

Sarah: 讓我總結 ECS 的檢查清單:

  1. next.config.ts 中設定 deploymentId
  2. 所有實例共享 NEXT_SERVER_ACTIONS_ENCRYPTION_KEY
  3. 如果使用共享快取處理程式,永遠不要在沒有用戶範圍的情況下快取用戶特定資料
  4. 滾動部署搭配健康檢查以最小化版本偏差視窗

投票:「多實例 ECS 部署是否會引入跨用戶資料洩漏風險?」

專家投票
David Park不會——預設就是獨立快取;版本偏差是部署風險,不是資料洩漏風險
Sarah Chen不會——只要共享快取處理程式有用戶範圍
Marcus Webb不會——如果有的話,獨立 process 反而改善隔離
Elena Kowalski不會——CDN 仍然是更大的威脅
Raj Patel不會——前提是配置了 deploymentId 和共享加密金鑰
Yuki Tanaka不會

結果:6-0——多實例 ECS 本質上不會創造跨用戶洩漏風險。配置項(deploymentId、加密金鑰)關乎可用性,不是資料隔離。


Round 6: Server Actions and Route Handlers — Hidden Pitfalls

Ana: Let's talk about Server Actions and Route Handlers. These are the two main ways Next.js code handles mutations and API proxying. Are they safe?

Yuki: For request isolation — yes. Each Server Action invocation is an HTTP POST request. It runs inside its own AsyncLocalStorage.run() scope. Each Route Handler invocation — GET, POST, PUT, DELETE — gets its own request context. Two concurrent Server Action calls cannot see each other's data.

Marcus: But Server Actions have a critical security property that many developers miss: they are public HTTP endpoints. Even if a Server Action is only called from an authenticated page component, the action itself can be directly invoked by anyone with the endpoint URL.

typescript
'use server';

import { cookies } from 'next/headers';

export async function updateProfile(formData: FormData) {
  // You MUST check auth in every Server Action
  const token = (await cookies()).get('auth-token')?.value;
  if (!token) {
    throw new Error('Unauthorized');
  }

  // NEVER trust userId from client — always derive from token
  const user = await verifyTokenWithBackend(token);

  await callBackendAPI(`/users/${user.id}/profile`, {
    method: 'PUT',
    body: formData,
    token,
  });
}

Raj: I've seen this mistake multiple times. A developer writes a Server Action that updates a user's profile, and instead of deriving the userId from the authenticated session, they take it from a hidden form field. An attacker can change that form field and update someone else's profile.

Marcus: That's not a cross-user cache leak — it's a classic IDOR (Insecure Direct Object Reference). But it's in the same category of "User A's action affects User B's data."

David: For Route Handlers, the main concern is GET caching. In Next.js 15+/16, GET Route Handlers are not cached by default — which is a change from Next.js 14 where they were. But if someone adds export const dynamic = 'force-static', the GET handler becomes cached and serves the same response to all users.

Sarah: In a BFF, your Route Handlers are your proxy endpoints. They should all call cookies(), which automatically makes them dynamic. But as a safety measure, we explicitly add export const dynamic = 'force-dynamic' to every proxy Route Handler.

typescript
// app/api/proxy/[...path]/route.ts
export const dynamic = 'force-dynamic';

export async function GET(request: Request, { params }: { params: Promise<{ path: string[] }> }) {
  const token = (await cookies()).get('auth-token')?.value;
  // ...
}

Yuki: POST, PUT, DELETE Route Handlers are never cached in any version of Next.js. So mutation endpoints are inherently safe from caching issues.

Vote: "Do Server Actions and Route Handlers introduce cross-user data leak risks?"

ExpertVote
David ParkNo for isolation; watch out for GET Route Handler caching in Next.js 14
Sarah ChenNo — use force-dynamic on all BFF proxy handlers as belt-and-suspenders
Marcus WebbNo for isolation; YES for auth bypass if you don't verify auth in every Server Action
Elena KowalskiNo
Raj PatelNo — but always derive userId from token, never from client input
Yuki TanakaNo — request context is always isolated

Result: 6-0 — No cross-user isolation risk. Separate concern: every Server Action must independently verify auth, and never trust client-provided userId.

第六回合:Server Actions 和 Route Handlers——隱藏的陷阱

Ana: 來談談 Server Actions 和 Route Handlers。這是 Next.js 程式碼處理 mutations 和 API 代理的兩個主要方式。它們安全嗎?

Yuki: 對於請求隔離——安全。每次 Server Action 調用都是一個 HTTP POST 請求。它在自己的 AsyncLocalStorage.run() 範圍內執行。每次 Route Handler 調用——GET、POST、PUT、DELETE——都有自己的請求上下文。兩個並發的 Server Action 呼叫不能看到彼此的資料。

Marcus: 但 Server Actions 有一個很多開發者忽略的關鍵安全特性:它們是公開的 HTTP 端點。 即使一個 Server Action 只從認證頁面組件呼叫,action 本身也可以被任何擁有端點 URL 的人直接調用。

typescript
'use server';

import { cookies } from 'next/headers';

export async function updateProfile(formData: FormData) {
  // 你必須在每個 Server Action 中檢查認證
  const token = (await cookies()).get('auth-token')?.value;
  if (!token) {
    throw new Error('Unauthorized');
  }

  // 永遠不要信任來自客戶端的 userId——永遠從 token 推導
  const user = await verifyTokenWithBackend(token);

  await callBackendAPI(`/users/${user.id}/profile`, {
    method: 'PUT',
    body: formData,
    token,
  });
}

Raj: 我看過這個錯誤很多次。開發者寫了一個更新用戶個人資料的 Server Action,不是從認證的 session 推導 userId,而是從隱藏的表單欄位取得。攻擊者可以修改那個表單欄位,更新別人的個人資料。

Marcus: 那不是跨用戶快取洩漏——是經典的 IDOR(Insecure Direct Object Reference,不安全的直接物件引用)。但它在同一類別的「User A 的操作影響 User B 的資料」中。

David: 對於 Route Handlers,主要的擔憂是 GET 快取。在 Next.js 15+/16 中,GET Route Handlers 預設不被快取——這是相對 Next.js 14 的改變,14 中它們是被快取的。但如果某人加了 export const dynamic = 'force-static',GET handler 就會被快取並對所有用戶提供相同的回應。

Sarah: 在 BFF 中,你的 Route Handlers 就是你的代理端點。它們都應該呼叫 cookies(),這會自動讓它們成為動態的。但作為安全措施,我們在每個代理 Route Handler 上明確加上 export const dynamic = 'force-dynamic'

typescript
// app/api/proxy/[...path]/route.ts
export const dynamic = 'force-dynamic';

export async function GET(request: Request, { params }: { params: Promise<{ path: string[] }> }) {
  const token = (await cookies()).get('auth-token')?.value;
  // ...
}

Yuki: POST、PUT、DELETE Route Handlers 在任何版本的 Next.js 中都永遠不會被快取。所以 mutation 端點本質上不受快取問題影響。

投票:「Server Actions 和 Route Handlers 是否引入跨用戶資料洩漏風險?」

專家投票
David Park隔離方面不會;注意 Next.js 14 中 GET Route Handler 的快取
Sarah Chen不會——在所有 BFF 代理 handlers 上用 force-dynamic 作雙重保險
Marcus Webb隔離方面不會;如果你不在每個 Server Action 驗證認證,則會有認證繞過
Elena Kowalski不會
Raj Patel不會——但永遠從 token 推導 userId,永遠不要從客戶端輸入
Yuki Tanaka不會——請求上下文永遠是隔離的

結果:6-0——沒有跨用戶隔離風險。獨立的關注點:每個 Server Action 必須獨立驗證認證,永遠不要信任客戶端提供的 userId。


Round 7: Real-World CVEs — When the Framework Itself Had Bugs

Ana: We've been discussing correct usage. But what about when Next.js itself had bugs that caused cross-user data leaks? Marcus, walk us through the real CVEs.

Marcus: Gladly. This is the part that proves you can't just trust the framework — you need defense in depth. Let me go through the ones directly relevant to cross-user data isolation:

CVE-2025-57752: Image Optimization Cache Deception. Severity: Medium. Affects all Next.js versions below 14.2.31 and 15.x below 15.4.5.

The next/image optimization feature had a cache key that only included image-specific parameters — URL, width, quality, MIME type. But the actual image fetch forwarded the user's Authorization and Cookie headers. So:

  1. User A requests an image behind auth → fetchInternalImage sends User A's cookies → image is optimized and cached
  2. User B requests the same image URL → cache key matches → User B gets User A's authenticated image

Sarah: We were affected by this. We served user-uploaded profile photos through next/image. After the CVE disclosure, we immediately upgraded.

Marcus: CVE-2024-46982: Cache Poisoning via SSR Routes. Severity: High (CVSS 7.5). Pages Router only, affecting versions 13.5.1 through 14.2.9.

An attacker could craft HTTP requests with __nextDataReq parameter and x-now-route-matches header to trick Next.js into treating SSR requests as SSG requests. This forced caching of content that should never be cached, including user-specific data. The root cause was a ternary operator in base-server.ts that forced revalidate=1 when no value was set.

Elena: This doesn't affect App Router, so if you're on Next.js 16 with App Router, you're not vulnerable to this specific CVE. But the pattern is instructive — the framework had a code path where a dynamic route could be tricked into acting static.

Marcus: CVE-2025-49826: Cache Poisoning DoS. Affects Next.js 15.1.0 through 15.1.7. A cache poisoning bug that could cause denial of service.

And the big one: CVE-2025-55182 / CVE-2025-66478: React Server Components RCE. CVSS 10.0. Disclosed December 2025. An unauthenticated attacker could execute arbitrary code via crafted requests targeting Server Functions deserialization.

David: Grimly. CVSS 10. That's not a data leak — that's full server compromise. If you have RCE, all user data is exposed.

Marcus: Here's my point: even well-designed systems have bugs. Between 2024 and 2025, Next.js had at least 4 significant CVEs related to caching and server-side execution. The lesson is:

  1. Keep Next.js updated. Always run the latest patch version.
  2. Defense in depth. Don't rely on a single layer. Even if the framework's cache is safe, set Cache-Control: private, no-store headers. Even if the CDN respects those headers, configure CachingDisabled anyway.
  3. Monitor for CVEs. Subscribe to Next.js security advisories.

Sarah: We pin to minor versions and review every patch release for security content before upgrading. We also run our cross-user isolation test suite against every new version before deploying.

Raj: The community has reported additional concerns beyond formal CVEs:

  • Next.js 14 era: Widespread confusion about default force-cache leading to accidental data sharing. The team reversed this in v15.
  • Memory leaks from excessive caching (GitHub Issue #75314): Default in-memory cache causing OOM in long-running processes.
  • ISR cache consistency across pods (GitHub discussions #16852, #11428, #55836): Challenges sharing ISR caches across horizontally scaled instances.

Vote: "How should a team protect against framework-level vulnerabilities?"

ExpertVote
David ParkPatch immediately + isolation tests + response headers as defense-in-depth
Sarah ChenPin minor versions + review patches + run isolation tests before upgrade
Marcus WebbSubscribe to security advisories + pentest regularly + defense-in-depth always
Elena KowalskiPatch + CDN safety net + infrastructure-level monitoring
Raj PatelAutomated dependency updates + CI isolation tests
Yuki TanakaStay on latest stable + read changelogs + test caching behavior after upgrade

Result: 6-0 — Keep patching, test continuously, defend in depth. No single layer is trustworthy on its own.

第七回合:真實世界的 CVE——當框架本身有 Bug

Ana: 我們一直在討論正確的使用方式。但當 Next.js 本身有 bug 導致跨用戶資料洩漏時呢?Marcus,帶我們走一遍真實的 CVE。

Marcus: 非常樂意。這是證明你不能只信任框架——你需要縱深防禦的部分。讓我走一遍跟跨用戶資料隔離直接相關的:

CVE-2025-57752:Image Optimization 快取欺騙。 嚴重性:中等。影響所有低於 14.2.31 的 Next.js 版本和低於 15.4.5 的 15.x 版本。

next/image 的最佳化功能的 cache key 只包含圖片特定的參數——URL、寬度、品質、MIME 類型。但實際的圖片抓取會轉發用戶的 AuthorizationCookie headers。所以:

  1. User A 請求一個需要認證的圖片 → fetchInternalImage 發送 User A 的 cookies → 圖片被最佳化並快取
  2. User B 請求同一個圖片 URL → cache key 匹配 → User B 拿到 User A 的認證圖片

Sarah: 我們受到了影響。我們透過 next/image 提供用戶上傳的個人頭像。CVE 披露後,我們立即升級。

Marcus: CVE-2024-46982:透過 SSR 路由的快取中毒。 嚴重性:高(CVSS 7.5)。僅 Pages Router,影響版本 13.5.1 到 14.2.9。

攻擊者可以用帶有 __nextDataReq 參數和 x-now-route-matches header 的精心構造 HTTP 請求來欺騙 Next.js 將 SSR 請求視為 SSG 請求。這強制快取了不應被快取的內容,包括用戶特定的資料。根本原因是 base-server.ts 中的三元運算子在沒有設定值時強制 revalidate=1

Elena: 這不影響 App Router,所以如果你在 Next.js 16 上用 App Router,你不受這個特定 CVE 影響。但模式很有教育意義——框架有一個程式碼路徑讓動態路由被騙成表現為靜態。

Marcus: CVE-2025-49826:快取中毒 DoS。 影響 Next.js 15.1.0 到 15.1.7。一個快取中毒 bug 可能導致拒絕服務。

還有最大的:CVE-2025-55182 / CVE-2025-66478:React Server Components RCE。 CVSS 10.0。2025 年 12 月披露。未經認證的攻擊者可以透過針對 Server Functions 反序列化的精心構造請求執行任意程式碼。

David: 神情嚴肅。 CVSS 10。那不是資料洩漏——那是完整的伺服器入侵。如果你有 RCE,所有用戶資料都暴露了。

Marcus: 我的重點是:即使設計良好的系統也有 bug。在 2024 到 2025 年間,Next.js 至少有 4 個與快取和伺服器端執行相關的重大 CVE。教訓是:

  1. 保持 Next.js 更新。 永遠執行最新的 patch 版本。
  2. 縱深防禦。 不要依賴單一層。即使框架的快取是安全的,也設定 Cache-Control: private, no-store headers。即使 CDN 尊重這些 headers,還是配置 CachingDisabled。
  3. 監控 CVE。 訂閱 Next.js 安全公告。

Sarah: 我們釘住 minor 版本,升級前審查每個 patch 版本的安全內容。我們還在部署前對每個新版本執行跨用戶隔離測試套件。

Raj: 社群也報告了超出正式 CVE 的額外問題:

  • Next.js 14 時代: 對預設 force-cache 的廣泛混淆導致意外的資料共享。團隊在 v15 反轉了這個預設值。
  • 過度快取導致的記憶體洩漏(GitHub Issue #75314):預設記憶體內快取導致長時間運行 process 的 OOM。
  • Pod 之間的 ISR 快取一致性(GitHub 討論 #16852、#11428、#55836):在水平擴展實例間共享 ISR 快取的挑戰。

投票:「團隊應該如何防範框架層級的漏洞?」

專家投票
David Park立即修補 + 隔離測試 + 回應 headers 作為縱深防禦
Sarah Chen釘住 minor 版本 + 審查修補 + 升級前執行隔離測試
Marcus Webb訂閱安全公告 + 定期滲透測試 + 永遠縱深防禦
Elena Kowalski修補 + CDN 安全網 + 基礎設施層級監控
Raj Patel自動化依賴更新 + CI 隔離測試
Yuki Tanaka保持最新穩定版 + 閱讀 changelog + 升級後測試快取行為

結果:6-0——持續修補、持續測試、縱深防禦。沒有任何單一層可以獨立信任。


Round 8: The Complete Defense — Testing, Monitoring, and Configuration

Ana: Final round. Let's bring it all together. If you had to give a team deploying a Next.js 16 BFF on ECS a concrete checklist, what would it be?

Sarah: I'll start with the test that saved us. We run this in CI against every deployment:

typescript
// test/cross-user-isolation.test.ts
import { test, expect } from '@playwright/test';

const USERS = [
  { email: 'user-a@test.com', password: 'pass-a', expectedName: 'User A' },
  { email: 'user-b@test.com', password: 'pass-b', expectedName: 'User B' },
  { email: 'user-c@test.com', password: 'pass-c', expectedName: 'User C' },
];

test.describe.parallel('Cross-user data isolation', () => {
  for (const user of USERS) {
    test(`${user.email} only sees own data`, async ({ request }) => {
      const loginRes = await request.post('/api/auth/login', {
        data: { email: user.email, password: user.password },
      });
      const cookies = loginRes.headers()['set-cookie'];

      // 100 rapid requests to detect contamination
      for (let i = 0; i < 100; i++) {
        const res = await request.get('/api/proxy/user/profile', {
          headers: { Cookie: cookies },
        });
        const data = await res.json();
        expect(data.name).toBe(user.expectedName);
        expect(data.email).toBe(user.email);
      }
    });
  }
});

Three users, 100 requests each, all running in parallel. If any request returns a different user's data, the test fails.

Marcus: And for load testing in staging, we use k6:

javascript
// k6/cross-user-test.js
import http from 'k6/http';
import { check, fail } from 'k6';

export const options = {
  scenarios: {
    concurrent_users: {
      executor: 'per-vu-iterations',
      vus: 30,
      iterations: 1000,
    },
  },
};

const USERS = [
  { token: 'token-user-a', expectedId: 'user-a-id' },
  { token: 'token-user-b', expectedId: 'user-b-id' },
  { token: 'token-user-c', expectedId: 'user-c-id' },
];

export default function () {
  const user = USERS[__VU % USERS.length];
  const res = http.get('https://staging.example.com/api/proxy/user/profile', {
    cookies: { 'auth-token': user.token },
  });

  const ok = check(res, {
    'status is 200': (r) => r.status === 200,
    'correct user data': (r) => {
      const body = JSON.parse(r.body);
      return body.userId === user.expectedId;
    },
  });

  if (!ok) fail(`CROSS-USER LEAK DETECTED for VU ${__VU}`);
}

30 virtual users, 1000 iterations each, all hitting the same endpoint with different auth cookies. Any mismatch = immediate failure.

David: Production monitoring. Every BFF proxy response should include a runtime check:

typescript
// lib/monitoring.ts
export function assertUserIsolation(
  requestUserId: string,
  responseUserId: string,
  endpoint: string
) {
  if (requestUserId !== responseUserId) {
    // This should NEVER happen
    console.error('CRITICAL: Cross-user data leak detected!', {
      requestUserId,
      responseUserId,
      endpoint,
      timestamp: new Date().toISOString(),
    });

    // Alert immediately
    reportCriticalIncident({
      type: 'CROSS_USER_DATA_LEAK',
      requestUserId,
      responseUserId,
      endpoint,
    });

    // Return empty data rather than wrong user's data
    throw new Error('Data isolation violation');
  }
}

Elena: And the complete next.config.ts:

typescript
import type { NextConfig } from 'next';

const nextConfig: NextConfig = {
  // Multi-instance version skew protection
  deploymentId: process.env.DEPLOYMENT_VERSION,

  // Security headers
  async headers() {
    return [
      {
        source: '/api/:path*',
        headers: [
          {
            key: 'Cache-Control',
            value: 'private, no-cache, no-store, max-age=0, must-revalidate',
          },
          { key: 'CDN-Cache-Control', value: 'no-store' },
          { key: 'Vary', value: 'Cookie, Authorization' },
        ],
      },
      {
        source: '/(dashboard|profile|settings|admin)/:path*',
        headers: [
          {
            key: 'Cache-Control',
            value: 'private, no-cache, no-store, max-age=0, must-revalidate',
          },
        ],
      },
    ];
  },

  // Image optimization safety
  images: {
    dangerouslyAllowLocalIP: false,
    maximumRedirects: 3,
  },
};

export default nextConfig;

Raj: Environment variables:

bash
# Required for multi-instance Server Actions
NEXT_SERVER_ACTIONS_ENCRYPTION_KEY=<base64-encoded-32-byte-key>

# Deployment version for version skew protection
DEPLOYMENT_VERSION=<unique-build-id>

# Backend API URL (never expose to client — no NEXT_PUBLIC_ prefix)
BACKEND_API_URL=https://internal-api.example.com

Ana: Let's compile the final checklist.

Vote: "Agree on the complete defense checklist?"

Unanimous agreement.

第八回合:完整防禦——測試、監控和配置

Ana: 最後一回合。讓我們把一切整合起來。如果你要給一個在 ECS 上部署 Next.js 16 BFF 的團隊一份具體的檢查清單,會是什麼?

Sarah: 我先講那個救了我們的測試。我們在每次部署的 CI 中執行這個:

typescript
// test/cross-user-isolation.test.ts
import { test, expect } from '@playwright/test';

const USERS = [
  { email: 'user-a@test.com', password: 'pass-a', expectedName: 'User A' },
  { email: 'user-b@test.com', password: 'pass-b', expectedName: 'User B' },
  { email: 'user-c@test.com', password: 'pass-c', expectedName: 'User C' },
];

test.describe.parallel('跨用戶資料隔離', () => {
  for (const user of USERS) {
    test(`${user.email} 只看到自己的資料`, async ({ request }) => {
      const loginRes = await request.post('/api/auth/login', {
        data: { email: user.email, password: user.password },
      });
      const cookies = loginRes.headers()['set-cookie'];

      // 100 個快速請求偵測污染
      for (let i = 0; i < 100; i++) {
        const res = await request.get('/api/proxy/user/profile', {
          headers: { Cookie: cookies },
        });
        const data = await res.json();
        expect(data.name).toBe(user.expectedName);
        expect(data.email).toBe(user.email);
      }
    });
  }
});

三個用戶,每個 100 個請求,全部並行執行。如果任何請求返回了不同用戶的資料,測試就失敗。

Marcus: 而在 staging 的負載測試,我們用 k6:

javascript
// k6/cross-user-test.js
import http from 'k6/http';
import { check, fail } from 'k6';

export const options = {
  scenarios: {
    concurrent_users: {
      executor: 'per-vu-iterations',
      vus: 30,
      iterations: 1000,
    },
  },
};

const USERS = [
  { token: 'token-user-a', expectedId: 'user-a-id' },
  { token: 'token-user-b', expectedId: 'user-b-id' },
  { token: 'token-user-c', expectedId: 'user-c-id' },
];

export default function () {
  const user = USERS[__VU % USERS.length];
  const res = http.get('https://staging.example.com/api/proxy/user/profile', {
    cookies: { 'auth-token': user.token },
  });

  const ok = check(res, {
    'status is 200': (r) => r.status === 200,
    'correct user data': (r) => {
      const body = JSON.parse(r.body);
      return body.userId === user.expectedId;
    },
  });

  if (!ok) fail(`偵測到跨用戶洩漏,VU ${__VU}`);
}

30 個虛擬用戶,每個 1000 次迭代,全部用不同的 auth cookies 打同一個端點。任何不匹配 = 立即失敗。

David: 正式環境監控。每個 BFF 代理回應都應該包含運行時檢查:

typescript
// lib/monitoring.ts
export function assertUserIsolation(
  requestUserId: string,
  responseUserId: string,
  endpoint: string
) {
  if (requestUserId !== responseUserId) {
    // 這永遠不應該發生
    console.error('重大:偵測到跨用戶資料洩漏!', {
      requestUserId,
      responseUserId,
      endpoint,
      timestamp: new Date().toISOString(),
    });

    // 立即告警
    reportCriticalIncident({
      type: 'CROSS_USER_DATA_LEAK',
      requestUserId,
      responseUserId,
      endpoint,
    });

    // 返回空資料而非錯誤用戶的資料
    throw new Error('Data isolation violation');
  }
}

Elena: 以及完整的 next.config.ts

typescript
import type { NextConfig } from 'next';

const nextConfig: NextConfig = {
  // 多實例版本偏差保護
  deploymentId: process.env.DEPLOYMENT_VERSION,

  // 安全 headers
  async headers() {
    return [
      {
        source: '/api/:path*',
        headers: [
          {
            key: 'Cache-Control',
            value: 'private, no-cache, no-store, max-age=0, must-revalidate',
          },
          { key: 'CDN-Cache-Control', value: 'no-store' },
          { key: 'Vary', value: 'Cookie, Authorization' },
        ],
      },
      {
        source: '/(dashboard|profile|settings|admin)/:path*',
        headers: [
          {
            key: 'Cache-Control',
            value: 'private, no-cache, no-store, max-age=0, must-revalidate',
          },
        ],
      },
    ];
  },

  // 圖片最佳化安全
  images: {
    dangerouslyAllowLocalIP: false,
    maximumRedirects: 3,
  },
};

export default nextConfig;

Raj: 環境變數:

bash
# 多實例 Server Actions 必需
NEXT_SERVER_ACTIONS_ENCRYPTION_KEY=<base64-encoded-32-byte-key>

# 版本偏差保護的部署版本
DEPLOYMENT_VERSION=<unique-build-id>

# 後端 API URL(永遠不暴露給客戶端——不要加 NEXT_PUBLIC_ 前綴)
BACKEND_API_URL=https://internal-api.example.com

Ana: 讓我們彙整最終的檢查清單。

投票:「同意完整防禦檢查清單?」

全場一致同意。


Final Verdict: Threat Model and Risk Assessment

LayerCross-User Leak RiskWhyMitigation
AsyncLocalStorage / Request ContextEffectively zeroNode.js provides hard isolation per async chainNone needed — sound by design
React cache()Effectively zeroPer-request Map via AsyncLocalStorage, GC'd after renderNone needed — request-scoped by design
fetch() with no-store (Next.js 15+/16 default)Effectively zeroEach request fetches fresh dataDon't accidentally add force-cache to auth fetches
fetch() with force-cacheHIGHData Cache persists across requests; key may not differentiate usersNever use on user-specific fetches
unstable_cache without user scopingCRITICALSame key returns same data for all usersAlways include userId in key; prefer "use cache" in Next.js 16
"use cache" (Next.js 16)LowCompiler auto-generates keys from args; cannot access cookiesPass user-specific data as arguments
Full Route Cache / ISR on auth routesHIGH if misconfiguredStatic cache served to all usersNever use force-static or ISR on personalized routes
Image Optimization (pre-15.4.5)HIGH (patched)Cache key excluded auth headers; CVE-2025-57752Upgrade Next.js
CDN (CloudFront)CRITICAL if misconfiguredCDN caches by URL without considering cookiesCachingDisabled for BFF routes; no-store headers
Multi-instance Data CacheLow (default)Each instance has independent cacheIf using shared cache, include user scope in keys
Server ActionsLow (isolation); Medium (auth)Isolated context, but public endpointsVerify auth in every action; derive userId from token
Route HandlersLowEach invocation gets own contextEnsure GET handlers calling cookies() remain dynamic

The Bottom Line

For a Next.js 16 BFF with JWT tokens in httpOnly cookies, deployed on AWS ECS behind ALB:

  1. The runtime request isolation is sound. AsyncLocalStorage guarantees cookies() and headers() always return the correct request's data.

  2. The default caching behavior in Next.js 15+/16 is safe. Fetch defaults to uncached. Routes using cookies() are automatically dynamic.

  3. The real dangers are:

    • Explicitly opting into caching (force-cache, unstable_cache, force-static) on authenticated data without user scoping
    • CDN misconfiguration that caches authenticated responses
    • Unpatched Next.js versions with known CVEs
  4. Defense in depth: Even when defaults are safe, always set explicit Cache-Control: private, no-store headers, configure your CDN to never cache BFF routes, and run cross-user isolation tests in CI.

最終裁決:威脅模型與風險評估

層級跨用戶洩漏風險原因緩解措施
AsyncLocalStorage / 請求上下文實質上為零Node.js 為每個 async 鏈提供硬性隔離不需要——設計上健全
React cache()實質上為零透過 AsyncLocalStorage 的 per-request Map,render 後 GC不需要——設計上 request-scoped
fetch() 搭配 no-store(Next.js 15+/16 預設)實質上為零每個請求取最新資料不要意外在認證 fetch 加 force-cache
fetch() 搭配 force-cacheData Cache 跨請求持久化;key 可能不區分用戶永遠不要在用戶特定 fetch 上使用
unstable_cache 沒有用戶範圍重大相同 key 為所有用戶返回相同資料永遠在 key 中包含 userId;Next.js 16 中優先用 "use cache"
"use cache"(Next.js 16)編譯器從參數自動生成 key;不能存取 cookies將用戶特定資料作為參數傳入
Full Route Cache / ISR 在認證路由錯誤配置時靜態快取提供給所有用戶永遠不要在個人化路由用 force-static 或 ISR
Image Optimization(15.4.5 前)(已修補)Cache key 排除 auth headers;CVE-2025-57752升級 Next.js
CDN(CloudFront)錯誤配置時重大CDN 按 URL 快取不考慮 cookiesBFF 路由用 CachingDisabled;no-store headers
多實例 Data Cache低(預設)每個實例獨立快取若用共享快取,key 中包含用戶範圍
Server Actions隔離低;認證中等隔離上下文,但公開端點每個 action 驗證認證;從 token 推導 userId
Route Handlers每次呼叫有自己的上下文確保呼叫 cookies() 的 GET handlers 保持動態

結論

對於使用 httpOnly cookies 中 JWT token 的 Next.js 16 BFF,部署在 ALB 後面的 AWS ECS 上:

  1. 運行時的請求隔離是健全的。 AsyncLocalStorage 保證 cookies()headers() 永遠返回正確請求的資料。

  2. Next.js 15+/16 的預設快取行為是安全的。 Fetch 預設不快取。使用 cookies() 的路由自動成為動態。

  3. 真正的危險是:

    • 在認證資料上明確選擇快取(force-cacheunstable_cacheforce-static)而沒有用戶範圍
    • CDN 錯誤配置快取了認證回應
    • 未修補的 Next.js 版本存在已知 CVE
  4. 縱深防禦: 即使預設值安全,也永遠設定明確的 Cache-Control: private, no-store headers,配置 CDN 永遠不快取 BFF 路由,並在 CI 中執行跨用戶隔離測試。


The 10 Golden Rules

  1. Never use force-cache on authenticated fetch requests. Default no-store is correct. Write it explicitly for clarity.
  2. Never use unstable_cache without including userId in the cache key. Prefer "use cache" in Next.js 16, which generates keys from arguments automatically.
  3. Never use force-static or ISR on routes that serve user-specific data.
  4. Always verify authentication in every Server Action. They are public HTTP endpoints.
  5. Always derive userId from the server-side token, never from client input.
  6. Always set Cache-Control: private, no-store response headers on authenticated endpoints.
  7. Always configure CDN CachingDisabled for all BFF/API routes.
  8. Always set deploymentId and NEXT_SERVER_ACTIONS_ENCRYPTION_KEY for multi-instance deployments.
  9. Always run cross-user isolation tests in CI against every deployment.
  10. Always keep Next.js updated to the latest patch version. Subscribe to security advisories.

十條黃金準則

  1. 永遠不要在認證的 fetch 請求上使用 force-cache 預設 no-store 是正確的。為了清晰明確寫出來。
  2. 永遠不要使用 unstable_cache 而不在 cache key 中包含 userId。 Next.js 16 中優先使用 "use cache",它自動從參數生成 key。
  3. 永遠不要在提供用戶特定資料的路由上使用 force-static 或 ISR。
  4. 永遠在每個 Server Action 中驗證認證。 它們是公開的 HTTP 端點。
  5. 永遠從伺服器端的 token 推導 userId,永遠不要從客戶端輸入。
  6. 永遠在認證端點上設定 Cache-Control: private, no-store 回應 headers。
  7. 永遠為所有 BFF/API 路由配置 CDN CachingDisabled。
  8. 永遠為多實例部署設定 deploymentIdNEXT_SERVER_ACTIONS_ENCRYPTION_KEY
  9. 永遠在 CI 中對每次部署執行跨用戶隔離測試。
  10. 永遠保持 Next.js 更新到最新 patch 版本。 訂閱安全公告。