Skip to content

Life After Login in Next.js 16

How to Split Public / Private Routes — Five Experts Debate

Next.js 16 登入後的世界

Public / Private Routes 怎麼切?五位專家圓桌激辯

"We do not recommend Middleware to be the sole method of protecting routes in your application." — Vercel, CVE-2025-29927 Postmortem

「我們不建議將 Middleware 作為保護應用程式路由的唯一方法。」—— Vercel,CVE-2025-29927 事後檢討報告


The Setup

You have a Next.js 16 app. It has a login page. Some pages should be visible to everyone (landing, pricing, login, signup). Other pages require authentication (dashboard, settings, profile). Some of those require specific roles (admin panel).

This is the most common architecture problem in Next.js, and there is no single "correct answer" — there are trade-offs at every layer. We brought together five engineers who've shipped production auth in Next.js to debate each decision point.

問題設定

你有一個 Next.js 16 應用。它有登入頁面。某些頁面所有人都能看(首頁、價格頁、登入、註冊)。其他頁面需要認證(儀表板、設定、個人資料)。其中一些需要特定角色(管理後台)。

這是 Next.js 中最常見的架構問題,而且沒有單一的「正確答案」——每一層都有取捨。我們召集了五位在 Next.js 上出過生產環境認證的工程師來辯論每個決策點。


Roundtable Participants

  • Lina Torres — Staff Engineer at a fintech startup. Got burned by CVE-2025-29927 on self-hosted Next.js. Her mantra: "Three layers or zero layers."
  • Kevin Wu — Performance engineer, ex-Vercel. Obsessed with TTFB. Wants proxy.ts to do as little as humanly possible.
  • Priya Sharma — Full-stack lead at a multi-tenant SaaS. Runs 200+ tenants with subdomain routing. Believes Route Groups are overrated.
  • Ryan O'Brien — Open source maintainer of a popular Next.js auth library. Thinks the single-file proxy.ts limitation makes composition a nightmare.
  • Tomás García — DevRel engineer and educator. Has reviewed hundreds of student/junior codebases. Knows exactly where beginners go wrong.

圓桌會議參與者

  • Lina Torres — 金融科技新創 Staff Engineer。在自架 Next.js 上被 CVE-2025-29927 燒傷過。她的信條:「三層或零層。」
  • Kevin Wu — 效能工程師,前 Vercel 員工。對 TTFB 極度執著。希望 proxy.ts 盡可能什麼都不做。
  • Priya Sharma — 多租戶 SaaS 全端 lead。用子域名路由管理 200 多個租戶。認為 Route Groups 被高估了。
  • Ryan O'Brien — 熱門 Next.js 認證庫的開源維護者。認為 proxy.ts 的單一檔案限制讓組合變成噩夢。
  • Tomás García — DevRel 工程師兼教育者。審查過數百個學生和初級工程師的 codebase。精確知道初學者會在哪裡出錯。

Round 1: The Three-Layer Architecture — Is It Overkill?

Moderator: After CVE-2025-29927, the consensus became "three-layer defense" for auth. Proxy.ts, layout, Data Access Layer. Is this overkill for a typical app?

Lina: I'll tell this story once. March 2025. We were self-hosting Next.js. An attacker sent a crafted x-middleware-subrequest header and bypassed our entire middleware — auth check, CSP headers, rate limiting, everything. Our admin dashboard was accessible for 48 hours before we noticed. The only thing that saved us was that our database queries had their own auth verification. Three layers is not overkill. It's the minimum.

Kevin: I agree with three layers in principle, but let's be precise about what each layer does. People hear "three layers" and think they need to do full JWT verification three times. That's wasteful. Here's what I mean:

Layer 1: proxy.ts     → Does the cookie EXIST? (1ms check)
Layer 2: Layout        → Is the session VALID? (decrypt + verify, 5-10ms)
Layer 3: DAL           → Is this user AUTHORIZED for this data? (DB query, 20-50ms)

Each layer answers a different question. Layer 1 is a fast reject. Layer 2 is session validation. Layer 3 is authorization. They're not redundant — they're progressive.

Priya: For multi-tenant apps, I'd add that Layer 1 also resolves the tenant. My proxy.ts reads the subdomain, verifies the session cookie exists, and rewrites to the tenant path. That's three concerns in one file but each is one line of logic.

Tomás: Here's where beginners go wrong. They read "three layers" and implement it as the same if (!session) redirect('/login') check copy-pasted into proxy.ts, the layout, and every page component. That's not defense-in-depth — that's code duplication. Each layer needs to answer a different question.

Ryan: And the tooling makes this confusing. If you use Auth.js v5, auth() is the same function called in proxy.ts and in Server Components. Beginners don't understand that calling auth() in proxy.ts gives you an optimistic check (JWT decode), while calling it in a Server Component can do a full database session lookup.

Lina: Let me draw the architecture one more time, because I want everyone to be crystal clear:

proxy.ts:
  "Is there a session cookie?" → No → redirect to /login
                                → Yes → let the request through

Layout (Server Component):
  "Is the session token valid?" → No → redirect to /login
                                 → Yes → render the page shell

Data Access Layer:
  "Is this user allowed to read THIS data?" → No → throw 403
                                              → Yes → return data

Kevin: And critically, if proxy.ts is bypassed (like CVE-2025-29927), the layout still catches it. If the layout is bypassed (Partial Rendering on client navigation), the DAL still catches it. Every layer is a fallback for the one above it.

第一回合:三層架構——是不是殺雞用牛刀?

主持人: CVE-2025-29927 之後,共識變成了認證的「三層防禦」。Proxy.ts、layout、Data Access Layer。對一般應用來說是不是殺雞用牛刀?

Lina: 我講一次這個故事。2025 年 3 月。我們自架 Next.js。攻擊者發送了一個特製的 x-middleware-subrequest 標頭,繞過了我們整個 middleware——認證檢查、CSP 標頭、速率限制,全部。我們的管理後台被公開存取了 48 小時才被發現。唯一救了我們的是資料庫查詢有自己的認證驗證。三層不是殺雞用牛刀,而是最低要求。

Kevin: 我原則上同意三層,但讓我們精確定義每層做什麼。人們聽到「三層」就以為需要做三次完整的 JWT 驗證。那很浪費。我的意思是:

第一層:proxy.ts     → cookie 存在嗎?(1ms 檢查)
第二層:Layout        → session 有效嗎?(解密 + 驗證,5-10ms)
第三層:DAL           → 這個使用者有權存取這筆資料嗎?(DB 查詢,20-50ms)

每層回答不同的問題。第一層是快速拒絕。第二層是 session 驗證。第三層是授權。它們不是冗餘——而是漸進式的。

Priya: 對多租戶應用,我要補充第一層也解析租戶。我的 proxy.ts 讀取子域名、驗證 session cookie 存在、然後 rewrite 到租戶路徑。一個檔案裡三個關注點,但每個都只有一行邏輯。

Tomás: 這就是初學者出錯的地方。他們讀到「三層」就把同一個 if (!session) redirect('/login') 檢查複製貼上到 proxy.ts、layout 和每個頁面元件。那不是縱深防禦——那是程式碼重複。每一層需要回答不同的問題。

Ryan: 而且工具讓這件事更令人困惑。如果你用 Auth.js v5,auth() 在 proxy.ts 和 Server Components 裡呼叫的是同一個函式。初學者不理解在 proxy.ts 裡呼叫 auth() 給你的是樂觀檢查(JWT 解碼),而在 Server Component 裡呼叫它可以做完整的資料庫 session 查找。

Lina: 讓我再畫一次架構圖,因為我要每個人都清清楚楚:

proxy.ts:
  「有 session cookie 嗎?」→ 沒有 → 重導向到 /login
                            → 有 → 放行請求

Layout(Server Component):
  「session token 有效嗎?」→ 無效 → 重導向到 /login
                            → 有效 → 渲染頁面外殼

Data Access Layer:
  「這個使用者被允許讀取這筆資料嗎?」→ 不允許 → 拋出 403
                                      → 允許 → 返回資料

Kevin: 而且關鍵是,如果 proxy.ts 被繞過(像 CVE-2025-29927),layout 仍然會攔住。如果 layout 被繞過(客戶端導航時的 Partial Rendering),DAL 仍然會攔住。每一層都是上一層的後備。


Vote: Is Three-Layer Defense Mandatory for Production Apps?

VoterPositionReasoning
LinaYes, mandatory"I have the scars to prove it"
KevinYes, mandatory"But each layer should answer a different question"
PriyaYes, mandatory"Multi-tenant makes it even more critical"
RyanYes, mandatory"Library defaults should enforce this"
TomásYes, mandatory"But we need better education on what each layer does"

Result: 5-0. Three-layer defense is unanimously mandatory. The debate is about WHAT each layer does, not WHETHER to have three layers.

投票:三層防禦對生產應用是否為強制性?

投票者立場理由
Lina是,強制性「我有傷疤為證」
Kevin是,強制性「但每層應該回答不同的問題」
Priya是,強制性「多租戶讓它更加關鍵」
Ryan是,強制性「庫的預設值應該強制這個模式」
Tomás是,強制性「但我們需要更好的教育說明每層做什麼」

結果:5-0。三層防禦一致認為是強制性的。辯論在於每層做什麼,而不是要不要有三層。


Round 2: Route Groups — (public) vs (authenticated)?

Moderator: Next.js App Router has Route Groups — parenthesized folders that don't affect URLs. Should we split into (public) and (authenticated) groups?

Tomás: Yes, and I'll show why with the file structure every Next.js app with login should start with:

app/
├── (public)/
│   ├── layout.tsx           ← No auth. Maybe just a simple header.
│   ├── login/page.tsx
│   ├── signup/page.tsx
│   └── pricing/page.tsx
├── (authenticated)/
│   ├── layout.tsx           ← Auth check: verifySession() + redirect
│   ├── dashboard/page.tsx
│   ├── settings/page.tsx
│   └── admin/
│       ├── layout.tsx       ← Role check: must be admin
│       └── page.tsx
├── layout.tsx               ← Root layout: providers, fonts, global CSS
└── page.tsx                 ← Landing page (public)

The beauty is structural clarity. A new developer joining the team immediately understands which routes are public and which require auth. It's self-documenting.

Priya: Shakes head. I disagree. For multi-tenant apps, this structure falls apart. My app has routes like /dashboard, /settings, /billing — all authenticated — but also /[tenant]/public-page which is public. Route Groups assume a clean binary split. Real apps are messier.

Kevin: And there's a performance nuance. Route Groups with different layouts mean different layout trees. If a user navigates from a (public) page to an (authenticated) page, the entire layout tree unmounts and remounts. That's a full page transition, not a smooth client-side navigation. For apps where users frequently cross the boundary (think: unauthenticated landing page → authenticated dashboard), this can feel jarring.

Ryan: But the alternative is worse. Without Route Groups, you either put the auth check in every page component (massive duplication) or you put it in the root layout (but then it runs on public pages too, which is wasteful and breaks public pages if the auth service is down).

Tomás: There's a middle ground. Use Route Groups for the structural split, but don't rely on the layout auth check as your security layer. It's a UX layer — it shows the right navigation, renders the right shell. The actual security is in the DAL.

Lina: Exactly. The authenticated layout should:

typescript
// app/(authenticated)/layout.tsx
import { redirect } from 'next/navigation'
import { verifySession } from '@/lib/dal'

export default async function AuthLayout({
  children,
}: {
  children: React.ReactNode
}) {
  const session = await verifySession()
  if (!session) redirect('/login')

  return (
    <div className="app-shell">
      <Sidebar user={session.user} />
      <main>{children}</main>
    </div>
  )
}

But this has a critical flaw that most people miss.

Kevin: Partial Rendering.

Lina: Partial Rendering. In Next.js App Router, layouts do not re-render on client-side navigation. If a user navigates from /dashboard to /settings — both under (authenticated) — the layout function does NOT re-execute. The auth check in the layout runs once on the first page load, then never again for same-group navigations.

Tomás: Stunned silence from the audience. This is the number one thing I wish the docs made more prominent. Beginners put auth in the layout and think every page is protected. It's not. If the session expires while the user is on /dashboard, they can still navigate to /settings without being redirected because the layout doesn't re-run.

Ryan: This is precisely why Layer 3 (DAL) exists. When the /settings page fetches data via getUser() in the DAL, that function calls verifySession(), which checks the actual session state in the database. If the session expired, the data fetch fails, and the page either shows an error or redirects.

Priya: So the layout auth check is basically a UX optimization — it shows the right UI shell — not a security mechanism?

Lina: Correct. It prevents the user from seeing the authenticated UI shell (sidebar, nav) if they're not logged in. But it doesn't prevent them from accessing data if they've managed to stay on the page after their session expired. That's the DAL's job.

第二回合:Route Groups —— (public) vs (authenticated)?

主持人: Next.js App Router 有 Route Groups——括號資料夾不影響 URL。我們應該分成 (public)(authenticated) 群組嗎?

Tomás: 應該,讓我展示為什麼。這是每個有登入功能的 Next.js 應用都應該從這個檔案結構開始:

app/
├── (public)/
│   ├── layout.tsx           ← 不做認證。可能只有簡單的 header。
│   ├── login/page.tsx
│   ├── signup/page.tsx
│   └── pricing/page.tsx
├── (authenticated)/
│   ├── layout.tsx           ← 認證檢查:verifySession() + redirect
│   ├── dashboard/page.tsx
│   ├── settings/page.tsx
│   └── admin/
│       ├── layout.tsx       ← 角色檢查:必須是 admin
│       └── page.tsx
├── layout.tsx               ← 根 layout:providers、字型、全域 CSS
└── page.tsx                 ← 首頁(公開)

它的美在於結構清晰。一個加入團隊的新開發者能立刻理解哪些路由是公開的、哪些需要認證。它是自文件化的。

Priya: 搖頭。 我不同意。對多租戶應用,這個結構會崩解。我的應用有 /dashboard/settings/billing 這些路由——全部需要認證——但也有 /[tenant]/public-page 是公開的。Route Groups 假設一個乾淨的二元分割。真實應用更混亂。

Kevin: 而且有一個效能細節。不同 layout 的 Route Groups 意味著不同的 layout 樹。如果使用者從 (public) 頁面導航到 (authenticated) 頁面,整個 layout 樹會卸載然後重新掛載。那是一次完整的頁面轉場,不是平滑的客戶端導航。對於使用者頻繁跨越邊界的應用(想想:未認證的首頁 → 認證的儀表板),這可能感覺很突兀。

Ryan: 但替代方案更糟。沒有 Route Groups,你要麼在每個頁面元件放認證檢查(大量重複),要麼放在根 layout(但它也會在公開頁面上執行,既浪費又在認證服務掛掉時破壞公開頁面)。

Tomás: 有一個折中方案。用 Route Groups 做結構分割,但不要依賴 layout 的認證檢查作為安全層。它是 UX 層——它顯示正確的導航列、渲染正確的外殼。實際的安全在 DAL。

Lina: 正是如此。認證 layout 應該:

typescript
// app/(authenticated)/layout.tsx
import { redirect } from 'next/navigation'
import { verifySession } from '@/lib/dal'

export default async function AuthLayout({
  children,
}: {
  children: React.ReactNode
}) {
  const session = await verifySession()
  if (!session) redirect('/login')

  return (
    <div className="app-shell">
      <Sidebar user={session.user} />
      <main>{children}</main>
    </div>
  )
}

但這有一個大多數人會忽略的致命缺陷。

Kevin: Partial Rendering。

Lina: Partial Rendering。在 Next.js App Router 中,layout 在客戶端導航時不會重新渲染。 如果使用者從 /dashboard 導航到 /settings——兩者都在 (authenticated) 下——layout 函式不會重新執行。Layout 中的認證檢查在第一次頁面載入時執行一次,然後對同群組導航再也不會執行。

Tomás: 觀眾震驚沈默。 這是我最希望文件能更顯著說明的第一件事。初學者在 layout 裡放認證然後以為每個頁面都受保護了。不是的。如果 session 在使用者在 /dashboard 時過期了,他們仍然可以導航到 /settings 而不被重導向,因為 layout 不會重新執行。

Ryan: 這正是第三層(DAL)存在的原因。當 /settings 頁面透過 DAL 中的 getUser() 獲取資料時,那個函式呼叫 verifySession(),它會檢查資料庫中的實際 session 狀態。如果 session 過期了,資料獲取會失敗,頁面要麼顯示錯誤要麼重導向。

Priya: 所以 layout 的認證檢查基本上是 UX 優化——顯示正確的 UI 外殼——而不是安全機制?

Lina: 正確。它防止使用者在未登入時看到認證 UI 外殼(側邊欄、導航列)。但它不能防止他們在 session 過期後仍留在頁面上時存取資料。那是 DAL 的工作。


Vote: Should Every App Use Route Groups for Auth?

VoterPositionReasoning
TomásYes"Structural clarity is worth it for every team"
LinaYes"Separating layouts is a UX best practice"
KevinYes, with caveats"Be aware of layout remount cost on boundary crossing"
RyanYes"Better than the alternatives"
PriyaNo"Multi-tenant apps need a more flexible structure"

Result: 4-1 in favor. Route Groups are recommended for most apps. Exception: apps with complex routing like multi-tenant architectures may need a different approach.

投票:每個應用都應該用 Route Groups 做認證嗎?

投票者立場理由
Tomás「結構清晰對每個團隊都值得」
Lina「分離 layout 是 UX 最佳實踐」
Kevin是,有附帶條件「注意跨邊界時的 layout 重掛載成本」
Ryan「比替代方案好」
Priya「多租戶應用需要更靈活的結構」

結果:4-1 贊成。Route Groups 對大多數應用是建議的。例外:多租戶架構等複雜路由的應用可能需要不同方法。


Round 3: What proxy.ts Should Actually Contain

Moderator: Let's get concrete. Show me the code. What does a production proxy.ts look like?

Kevin: I'll go first because I want to show how minimal it should be:

typescript
// proxy.ts — THE ENTIRE FILE
import { NextRequest, NextResponse } from 'next/server'

const protectedPrefixes = ['/dashboard', '/settings', '/admin', '/api/private']
const authPages = ['/login', '/signup']

export default function proxy(req: NextRequest) {
  const { pathname } = req.nextUrl
  const hasSession = req.cookies.has('session')

  // Redirect unauthenticated users away from protected routes
  if (!hasSession && protectedPrefixes.some(p => pathname.startsWith(p))) {
    const url = new URL('/login', req.url)
    url.searchParams.set('returnTo', pathname)
    return NextResponse.redirect(url)
  }

  // Redirect authenticated users away from auth pages
  if (hasSession && authPages.some(p => pathname.startsWith(p))) {
    return NextResponse.redirect(new URL('/dashboard', req.url))
  }

  return NextResponse.next()
}

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

That's it. No await. No decrypt(). No jose. Just cookies.has(). A 1ms operation. The actual verification happens in the layout and DAL.

Ryan: Immediately. That's too minimal. You're checking if a cookie exists, not if it's valid. A user with an expired or corrupted cookie will pass proxy.ts, hit the layout, get rejected, and see a brief flash of the authenticated shell before being redirected. Poor UX.

Kevin: The redirect in the layout happens during server-side rendering. There's no flash — the user never sees the authenticated shell because the redirect happens before the HTML is sent to the browser.

Ryan: True for full page loads. But what about client-side navigation? If a user is on the public landing page and clicks a link to /dashboard, the RSC request goes through, and there can be a flicker depending on your loading state implementation.

Lina: I do a lightweight decrypt in proxy.ts. Not a full database lookup — just checking if the JWT structure is valid and not expired:

typescript
import { jwtVerify } from 'jose'

const secret = new TextEncoder().encode(process.env.JWT_SECRET)

export default async function proxy(req: NextRequest) {
  const { pathname } = req.nextUrl
  const sessionCookie = req.cookies.get('session')?.value

  let isAuthenticated = false
  if (sessionCookie) {
    try {
      await jwtVerify(sessionCookie, secret)
      isAuthenticated = true
    } catch {
      // Token expired or invalid — treat as unauthenticated
      // Optionally clear the bad cookie here
    }
  }

  if (!isAuthenticated && protectedPrefixes.some(p => pathname.startsWith(p))) {
    return NextResponse.redirect(new URL('/login', req.url))
  }

  return NextResponse.next()
}

Kevin: That adds 2-3ms per request. For 10 million monthly requests, that's 5-8 hours of cumulative compute time per month.

Lina: For 10 million requests, I'm also getting 10 million fewer "flash of wrong content" incidents. That's worth 2-3ms.

Tomás: Both are valid. Let me frame it as a decision rule:

  • Cookie existence check (Kevin's approach): Best for apps where client-side navigation to protected routes is rare. The layout redirect handles the UX.
  • Lightweight JWT verify (Lina's approach): Best for apps with frequent public→protected navigation where UX flicker matters.
  • Full database session check: Never do this in proxy.ts. Even with Node.js runtime in v16, a database query per request is architecturally wrong here.

Priya: I'll add one thing about the returnTo parameter in Kevin's code. Always validate the returnTo URL. Without validation, it's an open redirect vulnerability:

typescript
// DANGEROUS: attacker can set returnTo=https://evil.com
const returnTo = req.nextUrl.searchParams.get('returnTo')

// SAFE: only allow relative paths
const returnTo = req.nextUrl.searchParams.get('returnTo')
const safeReturnTo = returnTo?.startsWith('/') ? returnTo : '/dashboard'

第三回合:proxy.ts 實際該放什麼

主持人: 讓我們具體點。給我看程式碼。生產環境的 proxy.ts 長什麼樣?

Kevin: 我先來,因為我要展示它該有多精簡:

typescript
// proxy.ts —— 整個檔案
import { NextRequest, NextResponse } from 'next/server'

const protectedPrefixes = ['/dashboard', '/settings', '/admin', '/api/private']
const authPages = ['/login', '/signup']

export default function proxy(req: NextRequest) {
  const { pathname } = req.nextUrl
  const hasSession = req.cookies.has('session')

  // 將未認證使用者從受保護路由重導向
  if (!hasSession && protectedPrefixes.some(p => pathname.startsWith(p))) {
    const url = new URL('/login', req.url)
    url.searchParams.set('returnTo', pathname)
    return NextResponse.redirect(url)
  }

  // 將已認證使用者從認證頁面重導向
  if (hasSession && authPages.some(p => pathname.startsWith(p))) {
    return NextResponse.redirect(new URL('/dashboard', req.url))
  }

  return NextResponse.next()
}

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

就這樣。沒有 await。沒有 decrypt()。沒有 jose。只有 cookies.has()。1ms 的操作。實際驗證在 layout 和 DAL 裡發生。

Ryan: 立刻反駁。 太精簡了。你只檢查 cookie 存在,不是它有效。一個帶有過期或損壞 cookie 的使用者會通過 proxy.ts、打到 layout、被拒絕,在被重導向之前短暫看到認證外殼的閃爍。糟糕的 UX。

Kevin: Layout 中的重導向發生在伺服器端渲染期間。沒有閃爍——使用者永遠不會看到認證外殼,因為重導向在 HTML 送到瀏覽器之前就發生了。

Ryan: 對完整頁面載入來說是對的。但客戶端導航呢?如果使用者在公開首頁,點擊連結到 /dashboard,RSC 請求通過後,取決於你的 loading state 實作可能會有閃爍。

Lina: 我在 proxy.ts 中做輕量解密。不是完整的資料庫查找——只是檢查 JWT 結構是否有效且未過期:

typescript
import { jwtVerify } from 'jose'

const secret = new TextEncoder().encode(process.env.JWT_SECRET)

export default async function proxy(req: NextRequest) {
  const { pathname } = req.nextUrl
  const sessionCookie = req.cookies.get('session')?.value

  let isAuthenticated = false
  if (sessionCookie) {
    try {
      await jwtVerify(sessionCookie, secret)
      isAuthenticated = true
    } catch {
      // Token 過期或無效——視為未認證
      // 可選擇在此清除壞 cookie
    }
  }

  if (!isAuthenticated && protectedPrefixes.some(p => pathname.startsWith(p))) {
    return NextResponse.redirect(new URL('/login', req.url))
  }

  return NextResponse.next()
}

Kevin: 每個請求增加 2-3ms。對 1000 萬月請求量,每月就是 5-8 小時的累積計算時間。

Lina: 對 1000 萬請求,我也減少了 1000 萬次「錯誤內容閃爍」事件。值得 2-3ms。

Tomás: 兩者都合理。讓我把它框架為一個決策規則:

  • Cookie 存在性檢查(Kevin 的方法):最適合客戶端導航到受保護路由很少的應用。Layout 重導向處理 UX。
  • 輕量 JWT 驗證(Lina 的方法):最適合頻繁 public → protected 導航且 UX 閃爍很重要的應用。
  • 完整資料庫 session 檢查永遠不要在 proxy.ts 中做。 即使 v16 有 Node.js runtime,每請求一次資料庫查詢在架構上就是錯的。

Priya: 我補充一件關於 Kevin 程式碼中 returnTo 參數的事。永遠驗證 returnTo URL。 沒有驗證,它就是一個 open redirect 漏洞:

typescript
// 危險:攻擊者可以設定 returnTo=https://evil.com
const returnTo = req.nextUrl.searchParams.get('returnTo')

// 安全:只允許相對路徑
const returnTo = req.nextUrl.searchParams.get('returnTo')
const safeReturnTo = returnTo?.startsWith('/') ? returnTo : '/dashboard'

VoterPositionReasoning
KevinCookie existence only"1ms vs 3ms matters at scale"
LinaLightweight JWT verify"Prevents UX flicker for invalid tokens"
RyanLightweight JWT verify"Library defaults should include token structure validation"
PriyaLightweight JWT verify"Multi-tenant apps need to verify tenant claim early"
TomásDepends on the app"Teach existence check first, upgrade to verify when needed"

Result: 3-1-1. Lightweight JWT verify wins for production apps. Cookie existence check is acceptable for simpler apps or when performance is the top priority.

投票者立場理由
Kevin只做 cookie 存在性檢查「1ms vs 3ms 在規模化時很重要」
Lina輕量 JWT 驗證「防止無效 token 的 UX 閃爍」
Ryan輕量 JWT 驗證「庫的預設值應該包含 token 結構驗證」
Priya輕量 JWT 驗證「多租戶應用需要提早驗證 tenant claim」
Tomás取決於應用「先教存在性檢查,需要時再升級到驗證」

結果:3-1-1。輕量 JWT 驗證在生產應用中勝出。Cookie 存在性檢查對較簡單的應用或效能為首要考量時是可接受的。


Round 4: The Data Access Layer — What Does It Actually Look Like?

Moderator: Everyone keeps saying "DAL." Show me the code.

Lina: Here's the complete implementation we use in production. Two files:

typescript
// lib/session.ts — Session management
import 'server-only'
import { SignJWT, jwtVerify } from 'jose'
import { cookies } from 'next/headers'

const secret = new TextEncoder().encode(process.env.JWT_SECRET)
const SESSION_DURATION = 7 * 24 * 60 * 60 * 1000 // 7 days

export async function createSession(userId: string, role: string) {
  const expires = new Date(Date.now() + SESSION_DURATION)
  const token = await new SignJWT({ userId, role })
    .setProtectedHeader({ alg: 'HS256' })
    .setExpirationTime(expires)
    .sign(secret)

  const cookieStore = await cookies()
  cookieStore.set('session', token, {
    httpOnly: true,
    secure: process.env.NODE_ENV === 'production',
    sameSite: 'lax',
    expires,
    path: '/',
  })
}

export async function verifySession() {
  const cookieStore = await cookies()
  const token = cookieStore.get('session')?.value
  if (!token) return null

  try {
    const { payload } = await jwtVerify(token, secret)
    return { userId: payload.userId as string, role: payload.role as string }
  } catch {
    return null
  }
}

export async function deleteSession() {
  const cookieStore = await cookies()
  cookieStore.delete('session')
}
typescript
// lib/dal.ts — Data Access Layer
import 'server-only'
import { cache } from 'react'
import { redirect } from 'next/navigation'
import { verifySession } from './session'
import { db } from './db'

// cache() deduplicates calls within a single request
export const getUser = cache(async () => {
  const session = await verifySession()
  if (!session) redirect('/login')

  const user = await db.user.findUnique({
    where: { id: session.userId },
    select: { id: true, name: true, email: true, role: true },
    // ^ NEVER select password hash or sensitive fields
  })

  if (!user) redirect('/login')
  return user
})

export const requireAdmin = cache(async () => {
  const user = await getUser()
  if (user.role !== 'admin') redirect('/dashboard')
  return user
})

Ryan: The import 'server-only' at the top is critical. It ensures these files can never be accidentally imported in a Client Component, which would leak your JWT secret to the browser.

Kevin: And cache() from React is doing deduplication. If three different components on the same page all call getUser(), the actual function only executes once. The other two calls get the cached result. This is essential — without it, you'd hit the database three times per page render.

Tomás: Now here's how you use it everywhere:

typescript
// In a page component
// app/(authenticated)/settings/page.tsx
import { getUser } from '@/lib/dal'

export default async function SettingsPage() {
  const user = await getUser() // Verified + cached
  return <SettingsForm user={user} />
}
typescript
// In a Server Action
// app/actions/updateProfile.ts
'use server'
import { getUser } from '@/lib/dal'
import { z } from 'zod'

const schema = z.object({ name: z.string().min(1) })

export async function updateProfile(formData: FormData) {
  const user = await getUser() // Auth verified HERE, not in the form
  const { name } = schema.parse(Object.fromEntries(formData))

  await db.user.update({
    where: { id: user.id },
    data: { name },
  })
}
typescript
// In a Route Handler
// app/api/private/profile/route.ts
import { verifySession } from '@/lib/session'

export async function GET() {
  const session = await verifySession()
  if (!session) {
    return Response.json({ error: 'Unauthorized' }, { status: 401 })
  }
  // ... fetch and return data
}

Priya: Notice the pattern. Every entry point — page, Server Action, Route Handler — starts with getUser() or verifySession(). There's no path to data that doesn't go through auth verification. That's what "Data Access Layer" means. It's not a library — it's a discipline.

Lina: And it's why CVE-2025-29927 didn't destroy our app. When middleware was bypassed, every database query still verified the session. The attacker could see HTML shells but couldn't access any real data.

第四回合:Data Access Layer——到底長什麼樣?

主持人: 大家一直說「DAL」。給我看程式碼。

Lina: 這是我們在生產環境使用的完整實作。兩個檔案:

typescript
// lib/session.ts — Session 管理
import 'server-only'
import { SignJWT, jwtVerify } from 'jose'
import { cookies } from 'next/headers'

const secret = new TextEncoder().encode(process.env.JWT_SECRET)
const SESSION_DURATION = 7 * 24 * 60 * 60 * 1000 // 7 天

export async function createSession(userId: string, role: string) {
  const expires = new Date(Date.now() + SESSION_DURATION)
  const token = await new SignJWT({ userId, role })
    .setProtectedHeader({ alg: 'HS256' })
    .setExpirationTime(expires)
    .sign(secret)

  const cookieStore = await cookies()
  cookieStore.set('session', token, {
    httpOnly: true,
    secure: process.env.NODE_ENV === 'production',
    sameSite: 'lax',
    expires,
    path: '/',
  })
}

export async function verifySession() {
  const cookieStore = await cookies()
  const token = cookieStore.get('session')?.value
  if (!token) return null

  try {
    const { payload } = await jwtVerify(token, secret)
    return { userId: payload.userId as string, role: payload.role as string }
  } catch {
    return null
  }
}

export async function deleteSession() {
  const cookieStore = await cookies()
  cookieStore.delete('session')
}
typescript
// lib/dal.ts — Data Access Layer
import 'server-only'
import { cache } from 'react'
import { redirect } from 'next/navigation'
import { verifySession } from './session'
import { db } from './db'

// cache() 在單一請求中去重複呼叫
export const getUser = cache(async () => {
  const session = await verifySession()
  if (!session) redirect('/login')

  const user = await db.user.findUnique({
    where: { id: session.userId },
    select: { id: true, name: true, email: true, role: true },
    // ^ 永遠不要 select 密碼雜湊或敏感欄位
  })

  if (!user) redirect('/login')
  return user
})

export const requireAdmin = cache(async () => {
  const user = await getUser()
  if (user.role !== 'admin') redirect('/dashboard')
  return user
})

Ryan: 頂部的 import 'server-only' 至關重要。它確保這些檔案永遠不會被意外地在 Client Component 中匯入,那會把你的 JWT secret 洩漏到瀏覽器。

Kevin: 而且 React 的 cache() 在做去重複。如果同一頁面的三個不同元件都呼叫 getUser(),實際函式只執行一次。其他兩次呼叫得到快取結果。這很關鍵——沒有它,你每次頁面渲染會打三次資料庫。

Tomás: 現在看看怎麼在各處使用它:

typescript
// 在頁面元件中
// app/(authenticated)/settings/page.tsx
import { getUser } from '@/lib/dal'

export default async function SettingsPage() {
  const user = await getUser() // 已驗證 + 已快取
  return <SettingsForm user={user} />
}
typescript
// 在 Server Action 中
// app/actions/updateProfile.ts
'use server'
import { getUser } from '@/lib/dal'
import { z } from 'zod'

const schema = z.object({ name: z.string().min(1) })

export async function updateProfile(formData: FormData) {
  const user = await getUser() // 認證在這裡驗證,不是在表單裡
  const { name } = schema.parse(Object.fromEntries(formData))

  await db.user.update({
    where: { id: user.id },
    data: { name },
  })
}
typescript
// 在 Route Handler 中
// app/api/private/profile/route.ts
import { verifySession } from '@/lib/session'

export async function GET() {
  const session = await verifySession()
  if (!session) {
    return Response.json({ error: 'Unauthorized' }, { status: 401 })
  }
  // ... 獲取並返回資料
}

Priya: 注意這個模式。每個入口點——page、Server Action、Route Handler——都從 getUser()verifySession() 開始。沒有任何路徑可以不經過認證驗證就存取資料。這就是「Data Access Layer」的意義。它不是一個函式庫——它是一種紀律。

Lina: 而且這就是為什麼 CVE-2025-29927 沒有摧毀我們的應用。當 middleware 被繞過時,每個資料庫查詢仍然驗證 session。攻擊者可以看到 HTML 外殼但無法存取任何真實資料。


Vote: Should the DAL Pattern Be the Default in New Projects?

VoterPosition
LinaYes — it saved us during a real attack
KevinYescache() makes it zero-cost after the first call
PriyaYes — essential for multi-tenant data isolation
RyanYes — auth libraries should ship DAL helpers
TomásYes — it should be in the official create-next-app template

Result: 5-0. Unanimous. The DAL pattern should be the default starting point for every Next.js app with authentication.

投票:DAL 模式是否應該成為新專案的預設?

投票者立場
Lina——它在真實攻擊中救了我們
Kevin——cache() 讓它在第一次呼叫後零成本
Priya——對多租戶資料隔離至關重要
Ryan——認證庫應該附帶 DAL 輔助工具
Tomás——它應該在官方 create-next-app 模板裡

結果:5-0。全票通過。DAL 模式應該成為每個有認證的 Next.js 應用的預設起點。


Round 5: Auth Library Choice — Auth.js v5 vs Clerk vs Custom JWT

Moderator: Final round. If you're starting a new Next.js 16 project today, which auth approach?

Ryan: Auth.js v5 (formerly NextAuth) if you want open source control and self-hosting. It has a native auth() function that works in proxy.ts and Server Components. The critical detail: you need two config files.

typescript
// auth.config.ts — proxy-safe config (no database adapter)
export const authConfig = {
  pages: { signIn: '/login' },
  callbacks: {
    authorized({ auth, request }) {
      const isLoggedIn = !!auth?.user
      const isProtected = request.nextUrl.pathname.startsWith('/dashboard')
      if (isProtected && !isLoggedIn) return false // triggers redirect
      return true
    },
  },
}
typescript
// auth.ts — full config (with database adapter)
import NextAuth from 'next-auth'
import { authConfig } from './auth.config'
import { PrismaAdapter } from '@auth/prisma-adapter'

export const { auth, signIn, signOut } = NextAuth({
  ...authConfig,
  adapter: PrismaAdapter(prisma),
  providers: [Google, GitHub],
})

The split exists because proxy.ts shouldn't import database adapters — it would make the proxy bundle huge and add unnecessary DB connections.

Priya: Clerk if you want managed auth and don't want to maintain session infrastructure. Their proxy.ts integration is clean:

typescript
// proxy.ts
import { clerkMiddleware, createRouteMatcher } from '@clerk/nextjs/server'

const isPublicRoute = createRouteMatcher([
  '/',
  '/login(.*)',
  '/signup(.*)',
  '/pricing(.*)',
  '/api/webhooks(.*)',
])

export default clerkMiddleware(async (auth, req) => {
  if (!isPublicRoute(req)) {
    await auth.protect()
  }
})

One function. No session management code. No JWT signing. Clerk handles everything. The trade-off is vendor lock-in and cost.

Kevin: Custom JWT if you want full control and minimal dependencies. jose for JWT operations, your own session management, your own login flow. It's more code, but you understand every line. Here's the full login flow:

typescript
// app/actions/auth.ts
'use server'
import { createSession } from '@/lib/session'
import { redirect } from 'next/navigation'
import { db } from '@/lib/db'
import bcrypt from 'bcryptjs'

export async function login(formData: FormData) {
  const email = formData.get('email') as string
  const password = formData.get('password') as string

  const user = await db.user.findUnique({ where: { email } })
  if (!user || !await bcrypt.compare(password, user.passwordHash)) {
    return { error: 'Invalid credentials' }
  }

  await createSession(user.id, user.role)

  const returnTo = formData.get('returnTo') as string
  const safeReturn = returnTo?.startsWith('/') ? returnTo : '/dashboard'
  redirect(safeReturn)
}

Lina: My recommendation matrix:

ScenarioRecommendation
Side project, solo developerCustom JWT — learn the fundamentals
Startup MVP, small teamAuth.js v5 — fast setup, open source
Enterprise / high securityClerk or Auth0 — managed, SOC 2 compliant
Multi-tenant SaaSClerk (built-in org support) or custom
Self-hosted / air-gappedAuth.js v5 or custom — no external dependencies

Tomás: The one thing I'd add: whichever you choose, the three-layer architecture stays the same. Auth.js, Clerk, and custom JWT all need proxy.ts → layout → DAL. The library handles the "how do I verify a session" question. The architecture handles the "where do I verify" question. They're orthogonal.

第五回合:認證庫選擇——Auth.js v5 vs Clerk vs 自建 JWT

主持人: 最後一回合。如果你今天要開始一個新的 Next.js 16 專案,選哪個認證方案?

Ryan: Auth.js v5(前身為 NextAuth),如果你想要開源控制和自架。它有一個原生的 auth() 函式,在 proxy.ts 和 Server Components 中都能用。關鍵細節:你需要兩個設定檔。

typescript
// auth.config.ts — proxy 安全的設定(沒有資料庫 adapter)
export const authConfig = {
  pages: { signIn: '/login' },
  callbacks: {
    authorized({ auth, request }) {
      const isLoggedIn = !!auth?.user
      const isProtected = request.nextUrl.pathname.startsWith('/dashboard')
      if (isProtected && !isLoggedIn) return false // 觸發重導向
      return true
    },
  },
}
typescript
// auth.ts — 完整設定(帶資料庫 adapter)
import NextAuth from 'next-auth'
import { authConfig } from './auth.config'
import { PrismaAdapter } from '@auth/prisma-adapter'

export const { auth, signIn, signOut } = NextAuth({
  ...authConfig,
  adapter: PrismaAdapter(prisma),
  providers: [Google, GitHub],
})

分開是因為 proxy.ts 不應該匯入資料庫 adapter——那會讓 proxy bundle 變得巨大並增加不必要的 DB 連線。

Priya: Clerk,如果你想要託管認證且不想維護 session 基礎設施。它的 proxy.ts 整合很乾淨:

typescript
// proxy.ts
import { clerkMiddleware, createRouteMatcher } from '@clerk/nextjs/server'

const isPublicRoute = createRouteMatcher([
  '/',
  '/login(.*)',
  '/signup(.*)',
  '/pricing(.*)',
  '/api/webhooks(.*)',
])

export default clerkMiddleware(async (auth, req) => {
  if (!isPublicRoute(req)) {
    await auth.protect()
  }
})

一個函式。不需要 session 管理程式碼。不需要 JWT 簽署。Clerk 處理一切。代價是供應商鎖定和費用。

Kevin: 自建 JWT,如果你想要完全控制和最少依賴。用 jose 做 JWT 操作,自己的 session 管理,自己的登入流程。程式碼更多,但你理解每一行。這是完整的登入流程:

typescript
// app/actions/auth.ts
'use server'
import { createSession } from '@/lib/session'
import { redirect } from 'next/navigation'
import { db } from '@/lib/db'
import bcrypt from 'bcryptjs'

export async function login(formData: FormData) {
  const email = formData.get('email') as string
  const password = formData.get('password') as string

  const user = await db.user.findUnique({ where: { email } })
  if (!user || !await bcrypt.compare(password, user.passwordHash)) {
    return { error: 'Invalid credentials' }
  }

  await createSession(user.id, user.role)

  const returnTo = formData.get('returnTo') as string
  const safeReturn = returnTo?.startsWith('/') ? returnTo : '/dashboard'
  redirect(safeReturn)
}

Lina: 我的建議矩陣:

場景建議
副專案、個人開發者自建 JWT——學習基礎原理
創業 MVP、小團隊Auth.js v5——快速設定、開源
企業 / 高安全性Clerk 或 Auth0——託管、SOC 2 合規
多租戶 SaaSClerk(內建組織支援)或自建
自架 / 離線環境Auth.js v5 或自建——無外部依賴

Tomás: 我要補充一件事:無論你選哪個,三層架構都是一樣的。 Auth.js、Clerk 和自建 JWT 都需要 proxy.ts → layout → DAL。庫處理的是「我怎麼驗證 session」的問題。架構處理的是「我在哪裡驗證」的問題。它們是正交的。


Final Verdict: The Complete Implementation Guide

app/
├── (public)/
│   ├── layout.tsx                ← Public layout (no auth)
│   ├── login/page.tsx
│   └── signup/page.tsx
├── (authenticated)/
│   ├── layout.tsx                ← Auth layout (verifySession + UI shell)
│   ├── dashboard/page.tsx
│   ├── settings/page.tsx
│   └── admin/
│       ├── layout.tsx            ← Role check (requireAdmin)
│       └── page.tsx
├── layout.tsx                    ← Root layout (providers, global CSS)
└── page.tsx                      ← Landing page
lib/
├── session.ts                    ← JWT sign/verify, cookie management
├── dal.ts                        ← Data Access Layer (getUser, requireAdmin)
└── db.ts                         ← Database client
proxy.ts                          ← Network boundary (cookie check + redirect)

The Three Layers — What Each One Does

LayerQuestionSpeedRuns WhenBypassed By
proxy.tsDoes the cookie exist / is token structurally valid?1-3msEvery matched requestHeader manipulation (CVE-2025-29927 style)
LayoutIs the session valid?5-10msFirst page load only (Partial Rendering!)Client-side navigation within same group
DALIs this user authorized for THIS data?20-50msEvery data accessNothing — this is the final gate

The Golden Rules

  1. proxy.ts is a fast reject. Cookie existence or lightweight JWT check. No database queries. No heavy computation. 30 lines max.

  2. Route Groups separate structure, not security. (public) and (authenticated) are for different layouts and UX, not for access control.

  3. Layouts are a UX layer, not a security layer. They don't re-run on client-side navigation. Never trust a layout as your auth gate.

  4. The DAL is the actual security boundary. getUser() with cache() and 'server-only'. Every page, every Server Action, every Route Handler starts here.

  5. import 'server-only' on every file that touches secrets or database. Non-negotiable.

  6. Validate returnTo URLs. Always check that redirect targets are relative paths to prevent open redirect attacks.

  7. The auth library is orthogonal to the architecture. Auth.js, Clerk, or custom JWT — the three-layer pattern is the same.

Complete Vote Summary

TopicResultScore
Three-layer defense mandatory?Yes5-0
Use Route Groups for public/private split?Yes (most apps)4-1
Cookie check vs JWT verify in proxy.ts?JWT verify for production3-1-1
DAL as default pattern?Yes5-0
Layout is security layer?No — UX layer only5-0

最終裁決:完整實作指南

建議的檔案結構

app/
├── (public)/
│   ├── layout.tsx                ← 公開 layout(不做認證)
│   ├── login/page.tsx
│   └── signup/page.tsx
├── (authenticated)/
│   ├── layout.tsx                ← 認證 layout(verifySession + UI 外殼)
│   ├── dashboard/page.tsx
│   ├── settings/page.tsx
│   └── admin/
│       ├── layout.tsx            ← 角色檢查(requireAdmin)
│       └── page.tsx
├── layout.tsx                    ← 根 layout(providers、全域 CSS)
└── page.tsx                      ← 首頁
lib/
├── session.ts                    ← JWT 簽署/驗證、cookie 管理
├── dal.ts                        ← Data Access Layer(getUser、requireAdmin)
└── db.ts                         ← 資料庫客戶端
proxy.ts                          ← 網路邊界(cookie 檢查 + 重導向)

三層——各層做什麼

層級回答的問題速度何時執行被什麼繞過
proxy.tscookie 存在嗎 / token 結構有效嗎?1-3ms每個匹配的請求Header 操作(CVE-2025-29927 風格)
Layoutsession 有效嗎?5-10ms僅在首次頁面載入(Partial Rendering!)同群組內的客戶端導航
DAL這個使用者有權存取這筆資料嗎?20-50ms每次資料存取無——這是最終閘門

黃金法則

  1. proxy.ts 是快速拒絕。 Cookie 存在性或輕量 JWT 檢查。不做資料庫查詢。不做繁重計算。最多 30 行。

  2. Route Groups 分離的是結構,不是安全性。 (public)(authenticated) 是給不同 layout 和 UX 用的,不是用來做存取控制的。

  3. Layout 是 UX 層,不是安全層。 它們在客戶端導航時不會重新執行。永遠不要信任 layout 作為你的認證閘門。

  4. DAL 才是真正的安全邊界。cache()'server-only'getUser()。每個 page、每個 Server Action、每個 Route Handler 都從這裡開始。

  5. import 'server-only' 放在每個碰到密鑰或資料庫的檔案上。沒有商量餘地。

  6. 驗證 returnTo URL。 始終檢查重導向目標是相對路徑,防止 open redirect 攻擊。

  7. 認證庫與架構正交。 Auth.js、Clerk 或自建 JWT——三層模式都一樣。

完整投票總結

議題結果比分
三層防禦強制性?5-0
用 Route Groups 做 public/private 分割?是(多數應用)4-1
proxy.ts 中 cookie 檢查 vs JWT 驗證?生產環境用 JWT 驗證3-1-1
DAL 作為預設模式?5-0
Layout 是安全層?不是——僅是 UX 層5-0