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 適合處理「路由層級」的初步判斷。
💡 Next16 已改為 proxy 這邊用大家比較習慣的 middleware
例如:
1// middleware.ts 或較新的 proxy.ts 概念
2import { NextRequest, NextResponse } from 'next/server'
3
4const protectedRoutes = ['/dashboard', '/settings']
5const publicRoutes = ['/login', '/signup']
6
7export function middleware(req: NextRequest) {
8 const path = req.nextUrl.pathname
9 const session = req.cookies.get('session')?.value
10
11 const isProtectedRoute = protectedRoutes.some((route) =>
12 path.startsWith(route)
13 )
14
15 const isPublicRoute = publicRoutes.includes(path)
16
17 if (isProtectedRoute && !session) {
18 return NextResponse.redirect(new URL('/login', req.nextUrl))
19 }
20
21 if (isPublicRoute && session) {
22 return NextResponse.redirect(new URL('/dashboard', req.nextUrl))
23 }
24
25 return NextResponse.next()
26}這段程式碼可以處理很常見的需求:
| 情境 | Middleware 適不適合? | 原因 |
|---|---|---|
| 沒登入不能進 dashboard | 適合 | 可以快速 redirect |
| 已登入不能進 login | 適合 | 使用者體驗處理 |
| 靜態付費頁面預先擋掉未登入者 | 適合 | 可作為第一層 route gate |
| 判斷某筆資料是否屬於目前使用者 | 不適合 | 太靠近商業邏輯與資料權限 |
| 刪除資料前檢查是否有權限 | 不適合 | 必須在 mutation 入口再次驗證 |
| 根據 DB 權限表判斷細部功能 | 通常不適合 | Middleware 每個 route 都可能跑,做 DB check 容易有效能問題 |
Middleware 最大的價值是:讓不該進來的人,盡早離開。
但它不應該負責全部權限邏輯。
假設你有一個 /dashboard/cards 頁面,用來顯示使用者的信用卡列表。
你可能會這樣做:
1export function middleware(req: NextRequest) {
2 const session = req.cookies.get('session')?.value
3
4 if (req.nextUrl.pathname.startsWith('/dashboard') && !session) {
5 return NextResponse.redirect(new URL('/login', req.nextUrl))
6 }
7
8 return NextResponse.next()
9}這可以確保未登入者無法進入 /dashboard/cards。
但真正危險的地方不是頁面本身,而是資料取得邏輯。
例如:
1// app/dashboard/cards/page.tsx
2import { db } from '@/lib/db'
3
4export default async function CardsPage() {
5 const cards = await db.paymentMethod.findMany()
6
7 return <CardsList cards={cards} />
8}這段程式碼的問題非常大。
Middleware 雖然擋住了未登入者,但 findMany() 本身沒有任何 user scope。只要未來有人重用這段邏輯,或是某個 route 沒有被 matcher 正確覆蓋,就可能把不該看的資料撈出來。
更好的做法是把資料存取集中到 DAL,也就是 Data Access Layer。
1// lib/dal/payment-method.ts
2import 'server-only'
3
4import { cache } from 'react'
5import { db } from '@/lib/db'
6import { verifySession } from '@/lib/dal/session'
7
8export const getMyPaymentMethods = cache(async () => {
9 const session = await verifySession()
10
11 return db.paymentMethod.findMany({
12 where: {
13 userId: session.userId,
14 },
15 select: {
16 id: true,
17 brand: true,
18 last4: true,
19 expMonth: true,
20 expYear: true,
21 isDefault: true,
22 },
23 })
24})然後頁面只呼叫這個安全入口:
1// app/dashboard/cards/page.tsx
2import { getMyPaymentMethods } from '@/lib/dal/payment-method'
3
4export default async function CardsPage() {
5 const cards = await getMyPaymentMethods()
6
7 return <CardsList cards={cards} />
8}這樣權限就不是靠「某個 route 有沒有被 Middleware 擋到」,而是資料查詢本身就內建授權邏輯。
Next.js 官方也建議在新專案建立 DAL,用來集中資料請求與 authorization logic,並回傳安全且最小化的 DTO。
App Router 很常用 Server Actions 處理表單提交、資料新增、刪除、更新。
例如刪除一張信用卡:
1// app/dashboard/cards/actions.ts
2'use server'
3
4import { db } from '@/lib/db'
5
6export async function deletePaymentMethod(id: string) {
7 await db.paymentMethod.delete({
8 where: { id },
9 })
10}如果頁面本身有 Middleware 保護,看起來好像安全。
但這段 Server Action 本身沒有驗證:
比較安全的寫法應該是:
1// app/dashboard/cards/actions.ts
2'use server'
3
4import { z } from 'zod'
5import { db } from '@/lib/db'
6import { verifySession } from '@/lib/dal/session'
7
8const DeletePaymentMethodSchema = z.object({
9 id: z.string().uuid(),
10})
11
12export async function deletePaymentMethod(input: unknown) {
13 const session = await verifySession()
14 const { id } = DeletePaymentMethodSchema.parse(input)
15
16 const paymentMethod = await db.paymentMethod.findFirst({
17 where: {
18 id,
19 userId: session.userId,
20 },
21 select: {
22 id: true,
23 },
24 })
25
26 if (!paymentMethod) {
27 throw new Error('Unauthorized')
28 }
29
30 await db.paymentMethod.delete({
31 where: {
32 id: paymentMethod.id,
33 },
34 })
35}重點不是只有「有登入才能刪」,而是:
只能刪除屬於自己的資料。
這就是 authorization,而不只是 authentication。
Next.js 文件也提醒,Server Action 即使是在受保護頁面中使用,也仍然是獨立入口;頁面層級的檢查不會自動延伸到 Server Action,所以 action 內部必須重新驗證 authentication 與 authorization。
如果你的專案有 API route,例如:
1// app/api/payment-methods/route.ts
2import { NextResponse } from 'next/server'
3import { db } from '@/lib/db'
4
5export async function GET() {
6 const data = await db.paymentMethod.findMany()
7
8 return NextResponse.json(data)
9}這也很危險。
因為 API route 本身就是一個可以被直接請求的入口。你不能假設它一定是從安全頁面呼叫過來。
應該改成:
1// app/api/payment-methods/route.ts
2import { NextResponse } from 'next/server'
3import { getMyPaymentMethods } from '@/lib/dal/payment-method'
4
5export async function GET() {
6 const data = await getMyPaymentMethods()
7
8 return NextResponse.json(data)
9}這樣 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 是實際執行動作前的身份確認。
你可以有警衛,但不能只有警衛。
可以把權限設計成這樣:
1app/
2 dashboard/
3 cards/
4 page.tsx
5 actions.ts
6
7lib/
8 dal/
9 session.ts
10 payment-method.ts
11
12middleware.ts1// lib/dal/session.ts
2import 'server-only'
3
4import { cache } from 'react'
5import { cookies } from 'next/headers'
6import { redirect } from 'next/navigation'
7import { decryptSession } from '@/lib/auth/session'
8
9export const verifySession = cache(async () => {
10 const cookieStore = await cookies()
11 const sessionCookie = cookieStore.get('session')?.value
12
13 const session = await decryptSession(sessionCookie)
14
15 if (!session?.userId) {
16 redirect('/login')
17 }
18
19 return {
20 userId: session.userId,
21 role: session.role,
22 }
23})1// lib/dal/payment-method.ts
2import 'server-only'
3
4import { cache } from 'react'
5import { db } from '@/lib/db'
6import { verifySession } from './session'
7
8export const getMyPaymentMethods = cache(async () => {
9 const session = await verifySession()
10
11 return db.paymentMethod.findMany({
12 where: {
13 userId: session.userId,
14 },
15 select: {
16 id: true,
17 brand: true,
18 last4: true,
19 expMonth: true,
20 expYear: true,
21 isDefault: true,
22 },
23 orderBy: {
24 createdAt: 'desc',
25 },
26 })
27})1// app/dashboard/cards/page.tsx
2import { getMyPaymentMethods } from '@/lib/dal/payment-method'
3import { CardsList } from './cards-list'
4
5export default async function CardsPage() {
6 const cards = await getMyPaymentMethods()
7
8 return <CardsList cards={cards} />
9}1// app/dashboard/cards/actions.ts
2'use server'
3
4import { z } from 'zod'
5import { db } from '@/lib/db'
6import { verifySession } from '@/lib/dal/session'
7
8const SetDefaultCardSchema = z.object({
9 id: z.string().uuid(),
10})
11
12export async function setDefaultPaymentMethod(input: unknown) {
13 const session = await verifySession()
14 const { id } = SetDefaultCardSchema.parse(input)
15
16 const paymentMethod = await db.paymentMethod.findFirst({
17 where: {
18 id,
19 userId: session.userId,
20 },
21 select: {
22 id: true,
23 },
24 })
25
26 if (!paymentMethod) {
27 throw new Error('Unauthorized')
28 }
29
30 await db.$transaction([
31 db.paymentMethod.updateMany({
32 where: {
33 userId: session.userId,
34 },
35 data: {
36 isDefault: false,
37 },
38 }),
39 db.paymentMethod.update({
40 where: {
41 id: paymentMethod.id,
42 },
43 data: {
44 isDefault: true,
45 },
46 }),
47 ])
48}這種設計的好處是,每個敏感操作都會重新確認目前使用者與資料的關係。
不是因為使用者看得到按鈕,所以他就有權限。 而是因為他真的通過 server-side authorization,所以才允許操作。
1{user.role === 'admin' && <DeleteButton />}這只能改善 UI,不能當成權限。
因為瀏覽器端的任何資料都不能完全信任。使用者可以改 request、改 payload、直接打 API,也可以繞過你的 UI 操作。
1if (path.startsWith('/admin') && session.role !== 'admin') {
2 return NextResponse.redirect(new URL('/403', req.nextUrl))
3}這可以做,但不夠。
如果 admin action 裡面沒有再檢查 role,那 Server Action 或 Route Handler 仍然可能變成缺口。
1return db.user.findUnique({
2 where: { id: userId },
3})如果這個 user model 裡有 phone、email、internal note、provider id、token、權限欄位,就可能不小心被傳到 Client Component。
比較好的做法是回傳 DTO:
1return {
2 id: user.id,
3 name: user.name,
4 avatarUrl: user.avatarUrl,
5}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 裡比較可靠的權限設計。