2026/07/07
在 Next.js App Router 裡做權限控管時,很多人第一個想到的是 Middleware。
例如:
使用者沒有登入,就導到 /login。 使用者不是 admin,就不要讓他進 /admin。 使用者已登入,就不要再進 /login。
這些需求看起來很適合放在 Middleware,因為它發生在 request 進入 route 之前,可以很早就把不合法的請求擋掉。
但問題是:Middleware 很適合做第一層篩選,卻不適合成為唯一的權限防線。
如果你的 App Router 專案只靠 Middleware 做權限判斷,很容易產生一種錯覺:
畫面進不去,所以資料應該也安全。
但在 App Router 裡,頁面、Server Components、Server Actions、Route Handlers、資料查詢邏輯,都是不同的執行入口。你擋住頁面,不代表你擋住了所有資料存取路徑。
Next.js 官方文件也把 authorization 拆成 optimistic check 與 secure check。前者可以根據 cookie 裡的 session 做快速判斷,例如 redirect 或顯示/隱藏 UI;後者則應該更靠近資料來源,例如透過資料庫 session 或 Data Access Layer 做真正的授權檢查。
Middleware 適合處理「路由層級」的初步判斷。
💡 Next 版本: 15
例如:
// middleware.ts
import { NextRequest, NextResponse } from 'next/server'
const protectedRoutes = ['/dashboard', '/settings']
const publicRoutes = ['/login', '/signup']
export function middleware(req: NextRequest) {
const path = req.nextUrl.pathname
const session = req.cookies.get('session')?.value
const isProtectedRoute = protectedRoutes.some((route) =>
path.startsWith(route)
)
const isPublicRoute = publicRoutes.includes(path)
if (isProtectedRoute && !session) {
return NextResponse.redirect(new URL('/login', req.nextUrl))
}
if (isPublicRoute && session) {
return NextResponse.redirect(new URL('/dashboard', req.nextUrl))
}
return NextResponse.next()
}這段程式碼可以處理很常見的需求:
| 情境 | Middleware 適不適合? | 原因 |
|---|---|---|
| 沒登入不能進 dashboard | 適合 | 可以快速 redirect |
| 已登入不能進 login | 適合 | 使用者體驗處理 |
| 靜態付費頁面預先擋掉未登入者 | 適合 | 可作為第一層 route gate |
| 判斷某筆資料是否屬於目前使用者 | 不適合 | 太靠近商業邏輯與資料權限 |
| 刪除資料前檢查是否有權限 | 不適合 | 必須在 mutation 入口再次驗證 |
| 根據 DB 權限表判斷細部功能 | 通常不適合 | Middleware 每個 route 都可能跑,做 DB check 容易有效能問題 |
Middleware 最大的價值是:讓不該進來的人,盡早離開。
但它不應該負責全部權限邏輯。
假設你有一個 /dashboard/cards 頁面,用來顯示使用者的信用卡列表。
你可能會這樣做:
export function middleware(req: NextRequest) {
const session = req.cookies.get('session')?.value
if (req.nextUrl.pathname.startsWith('/dashboard') && !session) {
return NextResponse.redirect(new URL('/login', req.nextUrl))
}
return NextResponse.next()
}這可以確保未登入者無法進入 /dashboard/cards。
但真正危險的地方不是頁面本身,而是資料取得邏輯。
例如:
// app/dashboard/cards/page.tsx
import { db } from '@/lib/db'
export default async function CardsPage() {
const cards = await db.paymentMethod.findMany()
return <CardsList cards={cards} />
}這段程式碼的問題非常大。
Middleware 雖然擋住了未登入者,但 findMany() 本身沒有任何 user scope。只要未來有人重用這段邏輯,或是某個 route 沒有被 matcher 正確覆蓋,就可能把不該看的資料撈出來。
更好的做法是把資料存取集中到 DAL,也就是 Data Access Layer。
// lib/dal/payment-method.ts
import 'server-only'
import { cache } from 'react'
import { db } from '@/lib/db'
import { verifySession } from '@/lib/dal/session'
export const getMyPaymentMethods = cache(async () => {
const session = await verifySession()
return db.paymentMethod.findMany({
where: {
userId: session.userId,
},
select: {
id: true,
brand: true,
last4: true,
expMonth: true,
expYear: true,
isDefault: true,
},
})
})然後頁面只呼叫這個安全入口:
// app/dashboard/cards/page.tsx
import { getMyPaymentMethods } from '@/lib/dal/payment-method'
export default async function CardsPage() {
const cards = await getMyPaymentMethods()
return <CardsList cards={cards} />
}這樣權限就不是靠「某個 route 有沒有被 Middleware 擋到」,而是資料查詢本身就內建授權邏輯。
Next.js 官方也建議在新專案建立 DAL,用來集中資料請求與 authorization logic,並回傳安全且最小化的 DTO。
App Router 很常用 Server Actions 處理表單提交、資料新增、刪除、更新。
例如刪除一張信用卡:
// app/dashboard/cards/actions.ts
'use server'
import { db } from '@/lib/db'
export async function deletePaymentMethod(id: string) {
await db.paymentMethod.delete({
where: { id },
})
}如果頁面本身有 Middleware 保護,看起來好像安全。
但這段 Server Action 本身沒有驗證:
比較安全的寫法應該是:
// app/dashboard/cards/actions.ts
'use server'
import { z } from 'zod'
import { db } from '@/lib/db'
import { verifySession } from '@/lib/dal/session'
const DeletePaymentMethodSchema = z.object({
id: z.string().uuid(),
})
export async function deletePaymentMethod(input: unknown) {
const session = await verifySession()
const { id } = DeletePaymentMethodSchema.parse(input)
const paymentMethod = await db.paymentMethod.findFirst({
where: {
id,
userId: session.userId,
},
select: {
id: true,
},
})
if (!paymentMethod) {
throw new Error('Unauthorized')
}
await db.paymentMethod.delete({
where: {
id: paymentMethod.id,
},
})
}重點不是只有「有登入才能刪」,而是:
只能刪除屬於自己的資料。
這就是 authorization,而不只是 authentication。
Next.js 文件也提醒,Server Action 即使是在受保護頁面中使用,也仍然是獨立入口;頁面層級的檢查不會自動延伸到 Server Action,所以 action 內部必須重新驗證 authentication 與 authorization。
如果你的專案有 API route,例如:
// app/api/payment-methods/route.ts
import { NextResponse } from 'next/server'
import { db } from '@/lib/db'
export async function GET() {
const data = await db.paymentMethod.findMany()
return NextResponse.json(data)
}這也很危險。
因為 API route 本身就是一個可以被直接請求的入口。你不能假設它一定是從安全頁面呼叫過來。
應該改成:
// app/api/payment-methods/route.ts
import { NextResponse } from 'next/server'
import { getMyPaymentMethods } from '@/lib/dal/payment-method'
export async function GET() {
const data = await getMyPaymentMethods()
return NextResponse.json(data)
}這樣 Route Handler 跟 Server Component 使用同一個 DAL,權限規則就不會散落在不同地方。
比較穩定的設計會長這樣:
| 層級 | 責任 | 範例 |
|---|---|---|
| Middleware / Proxy | 初步 route gate、redirect、UX | 未登入導到 /login |
| Page / Layout | 控制頁面是否可顯示 | admin page 不符合身份就 redirect |
| DAL | 集中資料查詢與權限檢查 | 只能查自己的 cards |
| DTO | 控制資料能暴露哪些欄位 | 只回傳 brand, last4,不回傳 provider token |
| Server Actions | mutation 前再次驗證 | 更新、刪除前確認 resource ownership |
| Route Handlers | API 入口驗證 | 每個 request 都透過 session / DAL |
這樣做的核心精神是:
權限檢查要越靠近資料與動作越好。
Middleware 是門口的警衛。 DAL 是金庫門的鎖。 Server Action / Route Handler 是實際執行動作前的身份確認。
你可以有警衛,但不能只有警衛。
可以把權限設計成這樣:
app/ dashboard/ cards/ page.tsx actions.ts lib/ dal/ session.ts payment-method.ts middleware.ts// lib/dal/session.ts
import 'server-only'
import { cache } from 'react'
import { cookies } from 'next/headers'
import { redirect } from 'next/navigation'
import { decryptSession } from '@/lib/auth/session'
export const verifySession = cache(async () => {
const cookieStore = await cookies()
const sessionCookie = cookieStore.get('session')?.value
const session = await decryptSession(sessionCookie)
if (!session?.userId) {
redirect('/login')
}
return {
userId: session.userId,
role: session.role,
}
})// lib/dal/payment-method.ts
import 'server-only'
import { cache } from 'react'
import { db } from '@/lib/db'
import { verifySession } from './session'
export const getMyPaymentMethods = cache(async () => {
const session = await verifySession()
return db.paymentMethod.findMany({
where: {
userId: session.userId,
},
select: {
id: true,
brand: true,
last4: true,
expMonth: true,
expYear: true,
isDefault: true,
},
orderBy: {
createdAt: 'desc',
},
})
})// app/dashboard/cards/page.tsx
import { getMyPaymentMethods } from '@/lib/dal/payment-method'
import { CardsList } from './cards-list'
export default async function CardsPage() {
const cards = await getMyPaymentMethods()
return <CardsList cards={cards} />
}// app/dashboard/cards/actions.ts
'use server'
import { z } from 'zod'
import { db } from '@/lib/db'
import { verifySession } from '@/lib/dal/session'
const SetDefaultCardSchema = z.object({
id: z.string().uuid(),
})
export async function setDefaultPaymentMethod(input: unknown) {
const session = await verifySession()
const { id } = SetDefaultCardSchema.parse(input)
const paymentMethod = await db.paymentMethod.findFirst({
where: {
id,
userId: session.userId,
},
select: {
id: true,
},
})
if (!paymentMethod) {
throw new Error('Unauthorized')
}
await db.$transaction([
db.paymentMethod.updateMany({
where: {
userId: session.userId,
},
data: {
isDefault: false,
},
}),
db.paymentMethod.update({
where: {
id: paymentMethod.id,
},
data: {
isDefault: true,
},
}),
])
}這種設計的好處是,每個敏感操作都會重新確認目前使用者與資料的關係。
不是因為使用者看得到按鈕,所以他就有權限。 而是因為他真的通過 server-side authorization,所以才允許操作。
{user.role === 'admin' && <DeleteButton />}這只能改善 UI,不能當成權限。
因為瀏覽器端的任何資料都不能完全信任。使用者可以改 request、改 payload、直接打 API,也可以繞過你的 UI 操作。
if (path.startsWith('/admin') && session.role !== 'admin') {
return NextResponse.redirect(new URL('/403', req.nextUrl))
}這可以做,但不夠。
如果 admin action 裡面沒有再檢查 role,那 Server Action 或 Route Handler 仍然可能變成缺口。
return db.user.findUnique({
where: { id: userId },
})如果這個 user model 裡有 phone、email、internal note、provider id、token、權限欄位,就可能不小心被傳到 Client Component。
比較好的做法是回傳 DTO:
return {
id: user.id,
name: user.name,
avatarUrl: user.avatarUrl,
}Next.js 官方 Data Security 文件也提醒,Server Components 可以安全存取 secrets、database、internal APIs,但傳給 Client Components 的資料仍需要過濾與最小化,否則還是可能不小心暴露敏感資料。
實作權限時,我會用這幾個問題檢查:
| 問題 | 如果答案是「沒有」就有風險 |
|---|---|
| 這個資料查詢有確認目前使用者嗎? | 可能查到別人的資料 |
| 這個 mutation 有重新驗證 session 嗎? | 可能被直接呼叫 |
| 這個 action 有確認 resource ownership 嗎? | 可能發生 IDOR |
| Client Component 收到的是 DTO 嗎? | 可能暴露過多欄位 |
| Route Handler 有自己的授權邏輯嗎? | 可能繞過頁面限制 |
| Middleware 只是第一層,而不是唯一防線嗎? | 權限可能過度集中 |
尤其是這一題最重要:
如果有人不透過 UI,而是直接呼叫這個 Server Action 或 API,還安全嗎?
如果不安全,就代表權限邏輯放錯地方了。
Middleware 很好用,但它不是權限系統的全部。
在 Next.js App Router 裡,比較安全的權限設計應該是分層的:
簡單來說:
Middleware 負責「你能不能進門」。 DAL 與 Server Actions 負責「你能不能看這筆資料、改這筆資料、刪這筆資料」。
只靠 Middleware 的系統,看起來有權限控管。 但真正安全的系統,應該讓每一個資料入口與操作入口,都有自己的 server-side authorization。
這才是 App Router 裡比較可靠的權限設計。