Next.js App Router 认证实战:用四层纵深防御让你的应用真正安全

仅靠中间件做认证?CVE-2025-29927漏洞给了我们血的教训。本文手把手教你在Next.js App Router中搭建四层纵深防御架构,从中间件到数据访问层,结合Auth.js v5实战代码,构建真正扛得住攻击的认证系统。

引言:App Router 时代,认证这件事变复杂了

说实话,当我第一次从 Pages Router 切换到 App Router 的时候,最让我头疼的不是路由语法的变化,而是突然意识到——我之前的认证方案可能根本不够安全。

在 Pages Router 的年代,事情相对简单。我们在 getServerSideProps 里做一次验证,或者在页面级别加个认证检查就完事了。这种"守好大门就行"的思路(也叫边界防御 Perimeter Security),在那个时代勉强说得过去。

但 App Router 把一切都搞复杂了。

服务器组件(Server Components)可以独立获取数据,一个页面里可能有好几个组件各自去查数据库。服务器操作(Server Actions)让客户端可以直接调用服务器端函数,完全绕过传统 API 路由。再加上流式渲染(Streaming),请求和响应的边界已经变得模糊了。在这种新架构下,光守大门肯定是不够的。

而 2025 年初曝出的 CVE-2025-29927 漏洞,更是给了所有人一记响亮的耳光——攻击者只需要加一个特殊的 HTTP 头部,就能彻底绕过中间件的认证保护。如果你的应用只靠中间件来做认证(老实说,很多人都是这么做的),那你的用户数据基本上就是在裸奔。

所以我们需要换一种思路:纵深防御(Defense-in-Depth)。简单来说就是别把鸡蛋放在一个篮子里,在中间件、服务器组件、数据访问层、API 路由等多个层面都做认证检查。即使某一层出了问题,其他层还能兜底。

这篇文章会带你从头到尾实现一套完整的纵深防御认证架构。代码都是可以直接用的,不是那种"示意一下"的伪代码。那么,让我们开始吧。

CVE-2025-29927:一个让整个社区都紧张的漏洞

在讲怎么防御之前,我们得先搞清楚为什么要这么做。CVE-2025-29927 是一个非常好的反面教材。

这个漏洞的原理其实不算复杂:攻击者在 HTTP 请求里加一个 x-middleware-subrequest 头部,Next.js 服务器就会以为这是一个内部子请求,然后直接跳过中间件执行。就这么简单,就这么致命。

哪些版本受影响

受影响的范围相当广:

  • 12.x:12.3.5 之前的版本
  • 13.x:13.5.9 之前的版本
  • 14.x:14.2.25 之前的版本
  • 15.x:15.2.3 之前的版本

如果你还在用这些版本,先别往下读了,赶紧升级。真的,这不是建议,这是紧急事项。

漏洞长什么样

来看一个典型的"自以为安全"的中间件写法:

// middleware.ts - 看起来没问题,但其实很脆弱
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';

export function middleware(request: NextRequest) {
  const token = request.cookies.get('auth-token')?.value;

  // 仅在中间件中验证 token
  if (!token || !isValidToken(token)) {
    return NextResponse.redirect(new URL('/login', request.url));
  }

  return NextResponse.next();
}

export const config = {
  matcher: ['/dashboard/:path*', '/admin/:path*'],
};

看起来挺合理的对吧?但攻击者只需要这样做:

# 正常访问——会被中间件拦下来
curl https://example.com/dashboard
# → 重定向到 /login

# 加一个头部,中间件直接被跳过了
curl -H "x-middleware-subrequest: 1" https://example.com/dashboard
# → 直接看到 /dashboard 的内容,仿佛中间件不存在

想想看,如果你的仪表板页面、管理后台、用户数据全都只靠中间件保护……后果不堪设想。

这件事教会了我们什么

CVE-2025-29927 给我的最大感触是:

  1. 中间件不是保险箱——它更像是门口的保安,有用但不是万无一失的。它做的事应该是改善体验(比如快速重定向未登录用户),而不是承担全部安全责任。
  2. 任何单一防线都可能被突破——这在安全领域其实是常识,但我们开发者(包括我自己)经常会犯懒。
  3. 纵深防御不是"高级需求",是基本要求——每一层都要独立做验证,不能假设前面已经验证过了。
  4. 安全检查越靠近数据越可靠——中间件可以被绕过,但数据访问层(DAL)是最后一道关卡。

纵深防御架构:四层安全模型

好了,道理讲完了,来看具体怎么做。我推荐的架构是四层安全模型,每一层各司其职,互相补位。

整体架构一览

┌─────────────────────────────────────────────────────────┐
│  Layer 1: Edge/Middleware Layer                         │
│  职责:快速过滤、重定向、性能优化                         │
│  ✓ 未登录用户快速重定向                                  │
│  ✓ 基础的 token 存在性检查                               │
│  ✗ 不作为唯一安全防线                                    │
└─────────────────────────────────────────────────────────┘
                          ↓
┌─────────────────────────────────────────────────────────┐
│  Layer 2: Server Component Layer                        │
│  职责:页面和组件级别的认证                               │
│  ✓ 获取完整的用户会话                                    │
│  ✓ 基于角色的条件渲染                                    │
│  ✓ 布局和页面级别的访问控制                              │
└─────────────────────────────────────────────────────────┘
                          ↓
┌─────────────────────────────────────────────────────────┐
│  Layer 3: Data Access Layer (DAL)                       │
│  职责:数据级别的细粒度授权                               │
│  ✓ 每次数据访问都验证权限                                │
│  ✓ DTO 模式防止数据泄漏                                  │
│  ✓ 资源级别的访问控制                                    │
│  ⭐ 这是最关键的安全层                                   │
└─────────────────────────────────────────────────────────┘
                          ↓
┌─────────────────────────────────────────────────────────┐
│  Layer 4: API Route/Server Action Layer                 │
│  职责:外部 API 和操作的认证与授权                        │
│  ✓ 服务器操作的独立验证                                  │
│  ✓ API 路由的 token 验证                                │
│  ✓ 输入验证和速率限制                                    │
│  ✓ CSRF 保护                                            │
└─────────────────────────────────────────────────────────┘

这四层是怎么配合的

第一层(中间件)跑在边缘,速度最快。它的活儿就是把明显没登录的用户赶紧打发走,别让他们白白消耗服务器资源。但记住,这层是"锦上添花",不是"安全基石"。

第二层(服务器组件)在服务端渲染时做更详细的检查。这里能拿到完整的用户 session,可以做角色判断、条件渲染这些比较复杂的逻辑。

第三层(数据访问层 DAL)是整个架构的核心。所有数据库操作都走这一层,每次都要重新验证权限。即使前面三层全被突破了,DAL 依然能挡住未授权的数据访问。说白了,这才是真正的安全底线。

第四层(API 路由/服务器操作)处理来自外部的请求,包括 Server Actions。每个请求都要独立验证身份,还要加上输入校验、速率限制等额外保护。

"DAL 已经很强了,为什么还要其他层?"

这个问题我被问过很多次。答案很简单:

  • 性能:中间件能在边缘就拦住未登录用户,省掉后续的服务器渲染和数据库查询
  • 体验:用户没登录时直接跳转登录页,总比渲染完页面再告诉他"你没权限"要好得多
  • 纵深:多一层就多一道保险,攻击者得逐层突破才行
  • 代码质量:每层职责清晰,维护和审计起来都更轻松

中间件层:当好"门口保安"

虽然我一直在说中间件不能当唯一防线,但这不代表它没用。恰恰相反,用好中间件能大幅提升用户体验和性能。关键是摆正它的定位:它是保安,不是保险柜。

中间件该干什么

在纵深防御架构里,中间件应该只负责这些事:

  • 把没有 session token 的用户快速重定向到登录页
  • 做基础的 token 存在性检查(注意,是"存在性"检查,不是完整验证)
  • 设置安全相关的 HTTP 头部
  • 记录访问日志
  • 做基础的速率限制

实现代码

// middleware.ts
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';

// 不需要认证的路由
const publicRoutes = ['/login', '/register', '/forgot-password', '/'];

// API 有自己的认证逻辑,不走中间件
const apiRoutes = ['/api'];

export function middleware(request: NextRequest) {
  const { pathname } = request.nextUrl;

  const isPublicRoute = publicRoutes.some(route =>
    pathname === route || pathname.startsWith(`${route}/`)
  );

  const isApiRoute = apiRoutes.some(route => pathname.startsWith(route));

  if (isPublicRoute || isApiRoute) {
    return NextResponse.next();
  }

  // 只检查 token 存不存在,不做完整验证
  const sessionToken = request.cookies.get('authjs.session-token')?.value ||
                       request.cookies.get('__Secure-authjs.session-token')?.value;

  if (!sessionToken) {
    const loginUrl = new URL('/login', request.url);
    loginUrl.searchParams.set('callbackUrl', pathname);
    return NextResponse.redirect(loginUrl);
  }

  const response = NextResponse.next();

  // 顺手设置一些安全头部,举手之劳
  response.headers.set('X-Frame-Options', 'DENY');
  response.headers.set('X-Content-Type-Options', 'nosniff');
  response.headers.set('Referrer-Policy', 'strict-origin-when-cross-origin');

  return response;
}

export const config = {
  matcher: [
    '/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)',
  ],
};

中间件的天花板在哪

用中间件的时候,心里得清楚这几个限制:

  1. 它可以被绕过——CVE-2025-29927 已经证明了这一点。就算当前版本没漏洞,谁能保证以后不会有?
  2. Edge Runtime 的限制——中间件跑在边缘运行时,很多 Node.js API 用不了,做不了太复杂的验证
  3. 性能压力——中间件每个请求都会执行,如果在里面查数据库,那你的响应速度就别想快了
  4. 管不到 Server Actions——客户端直接调用的服务器操作,中间件基本拦不住

Auth.js v5 集成实战

认证库的选择上,我个人比较推荐 Auth.js v5(就是以前的 NextAuth.js)。它是专门为 App Router 重新设计的,跟服务器组件和服务器操作的集成都做得很好。

安装

# 安装 Auth.js v5
npm install next-auth@beta

# 如果用 Prisma 做数据库适配
npm install @auth/prisma-adapter

核心配置

这个配置文件会比较长,但每一部分都很重要,别跳着看:

// lib/auth.ts
import NextAuth from "next-auth";
import { PrismaAdapter } from "@auth/prisma-adapter";
import Credentials from "next-auth/providers/credentials";
import { prisma } from "@/lib/prisma";
import { compare } from "bcryptjs";
import type { NextAuthConfig } from "next-auth";

// 扩展类型定义,把 role 加进去
declare module "next-auth" {
  interface Session {
    user: {
      id: string;
      email: string;
      name: string;
      role: string;
    };
  }

  interface User {
    id: string;
    email: string;
    name: string;
    role: string;
  }
}

export const authConfig: NextAuthConfig = {
  adapter: PrismaAdapter(prisma),

  session: {
    strategy: "jwt",
    maxAge: 30 * 24 * 60 * 60, // 30 天
  },

  pages: {
    signIn: '/login',
    signOut: '/logout',
    error: '/auth/error',
  },

  providers: [
    Credentials({
      name: "credentials",
      credentials: {
        email: { label: "Email", type: "email" },
        password: { label: "Password", type: "password" }
      },
      async authorize(credentials) {
        if (!credentials?.email || !credentials?.password) {
          throw new Error("请提供邮箱和密码");
        }

        const user = await prisma.user.findUnique({
          where: { email: credentials.email as string },
          select: {
            id: true,
            email: true,
            name: true,
            password: true,
            role: true,
          }
        });

        if (!user || !user.password) {
          throw new Error("用户不存在或密码未设置");
        }

        const isPasswordValid = await compare(
          credentials.password as string,
          user.password
        );

        if (!isPasswordValid) {
          throw new Error("密码错误");
        }

        return {
          id: user.id,
          email: user.email,
          name: user.name,
          role: user.role,
        };
      }
    })
  ],

  callbacks: {
    async jwt({ token, user, trigger, session }) {
      if (user) {
        token.id = user.id;
        token.role = user.role;
      }

      if (trigger === "update" && session) {
        token.name = session.name;
      }

      return token;
    },

    async session({ session, token }) {
      if (token && session.user) {
        session.user.id = token.id as string;
        session.user.role = token.role as string;
      }
      return session;
    },
  },
};

export const { handlers, auth, signIn, signOut } = NextAuth(authConfig);

在服务器组件里用

// app/dashboard/page.tsx
import { auth } from "@/lib/auth";
import { redirect } from "next/navigation";

export default async function DashboardPage() {
  const session = await auth();

  // 第二层防御——即使中间件被绕过,这里还会挡一下
  if (!session?.user) {
    redirect('/login');
  }

  return (
    

欢迎, {session.user.name}

角色: {session.user.role}

); }

在服务器操作里用

// app/actions/user-actions.ts
'use server';

import { auth } from "@/lib/auth";
import { z } from "zod";

const updateProfileSchema = z.object({
  name: z.string().min(2, "名称至少 2 个字符"),
  bio: z.string().max(500, "简介不能超过 500 字符").optional(),
});

export async function updateProfile(formData: FormData) {
  // 第四层防御——Server Action 必须自己验证身份
  const session = await auth();

  if (!session?.user) {
    throw new Error("未授权:请先登录");
  }

  const rawData = {
    name: formData.get('name'),
    bio: formData.get('bio'),
  };

  const result = updateProfileSchema.safeParse(rawData);

  if (!result.success) {
    return { error: result.error.flatten().fieldErrors };
  }

  // 最终还是通过 DAL 来执行更新
  await updateUser(session.user.id, result.data);

  return { success: true };
}

数据访问层(DAL):真正的安全底线

终于来到了整篇文章最重要的部分。说实话,如果你只能从这篇文章里带走一个概念,那就是 DAL。

数据访问层的核心理念是:不管请求是怎么到达这里的,不管前面的层做了什么检查(或者压根没检查),在碰数据之前,我都要自己验一遍。

DAL 的几条铁律

  1. 所有数据库操作必须走 DAL——绝对不允许在组件或 Server Action 里直接写 prisma.xxx.findMany()
  2. DAL 不信任任何调用者——每次都自己验证身份和权限
  3. 最小权限——只返回用户该看到的数据,用 DTO 过滤掉敏感字段
  4. 只在服务端运行——用 'server-only' 包确保代码不会被打包到客户端
  5. cache() 优化性能——同一请求内多次调用不会重复查数据库

基础结构

// lib/dal.ts
import 'server-only';

import { cache } from 'react';
import { auth } from '@/lib/auth';
import { prisma } from '@/lib/prisma';

/**
 * 验证用户身份——这是 DAL 的门卫
 */
export const verifySession = cache(async () => {
  const session = await auth();

  if (!session?.user?.id) {
    throw new Error('未授权访问');
  }

  return {
    isAuth: true,
    userId: session.user.id,
    role: session.user.role
  };
});

type UserDTO = {
  id: string;
  name: string;
  email: string;
  avatar?: string;
  bio?: string;
  createdAt: Date;
  // 注意看——password、resetToken 这些不在这里
};

/**
 * 获取当前用户信息(已过滤敏感字段)
 */
export const getCurrentUser = cache(async (): Promise => {
  const session = await verifySession();

  const user = await prisma.user.findUnique({
    where: { id: session.userId },
    select: {
      id: true,
      name: true,
      email: true,
      avatar: true,
      bio: true,
      createdAt: true,
    },
  });

  return user;
});

资源级别的权限检查

光验证"你是谁"还不够,还得检查"你能不能碰这个东西"。这就是资源级别的授权:

// lib/dal.ts(续)

/**
 * 获取文章详情——未发布的只有作者和管理员能看
 */
export const getArticleById = cache(async (articleId: string) => {
  const session = await verifySession();

  const article = await prisma.article.findUnique({
    where: { id: articleId },
    include: {
      author: {
        select: { id: true, name: true, avatar: true },
      },
    },
  });

  if (!article) {
    return null;
  }

  // 关键:资源级别的权限检查
  if (!article.published) {
    const isAuthor = article.authorId === session.userId;
    const isAdmin = session.role === 'ADMIN';

    if (!isAuthor && !isAdmin) {
      throw new Error('无权访问此文章');
    }
  }

  return article;
});

/**
 * 创建文章——注意 authorId 是强制设置的,不接受外部传入
 */
export const createArticle = async (data: {
  title: string;
  content: string;
  published: boolean;
}) => {
  const session = await verifySession();

  const article = await prisma.article.create({
    data: {
      ...data,
      authorId: session.userId, // 这里很重要:永远用当前用户的 ID
    },
  });

  return article;
};

/**
 * 更新文章——只有作者或管理员才行
 */
export const updateArticle = async (
  articleId: string,
  data: { title?: string; content?: string; published?: boolean; }
) => {
  const session = await verifySession();

  const existingArticle = await prisma.article.findUnique({
    where: { id: articleId },
    select: { authorId: true },
  });

  if (!existingArticle) {
    throw new Error('文章不存在');
  }

  const isAuthor = existingArticle.authorId === session.userId;
  const isAdmin = session.role === 'ADMIN';

  if (!isAuthor && !isAdmin) {
    throw new Error('无权编辑此文章');
  }

  return await prisma.article.update({
    where: { id: articleId },
    data,
  });
};

DTO:别让敏感数据溜出去

你可能觉得"我查数据库的时候用了 select,已经过滤了啊"。没错,但如果哪天有人不小心改了查询呢?DTO 模式就是双重保险:

// lib/dto.ts
import 'server-only';

export function toUserDTO(user: any): UserDTO {
  return {
    id: user.id,
    name: user.name,
    email: user.email,
    avatar: user.avatar,
    bio: user.bio,
    createdAt: user.createdAt,
    // password、resetToken?它们在这里根本不存在
  };
}

export function toAdminUserDTO(user: any) {
  return {
    ...toUserDTO(user),
    emailVerified: user.emailVerified,
    role: user.role,
    lastLoginAt: user.lastLoginAt,
    // 即使是管理员视图,也不包含 password
  };
}

服务器组件中的认证模式

服务器组件在服务端渲染,能直接访问数据库和敏感 API。在纵深防御里,它们承担的是第二层的角色。

在布局层做认证

一个常见的做法是在认证路由组的 layout 里统一检查:

// app/(authenticated)/layout.tsx
import { auth } from "@/lib/auth";
import { redirect } from "next/navigation";
import { getCurrentUser } from "@/lib/dal";

export default async function AuthenticatedLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  const session = await auth();

  if (!session?.user) {
    redirect('/login');
  }

  // 通过 DAL 获取用户信息(顺便又验证了一次)
  const user = await getCurrentUser();

  if (!user) {
    redirect('/login');
  }

  return (
    
{children}
); }

传用户信息给客户端组件——千万小心

有时候客户端组件需要知道当前用户是谁。但你必须只传非敏感信息,而且要通过一个专门的 Provider 来传:

// components/UserProvider.tsx
'use client';

import { createContext, useContext } from 'react';

// 注意这个类型——只包含可以公开的字段
type SafeUser = {
  id: string;
  name: string;
  avatar?: string;
};

const UserContext = createContext(null);

export function UserProvider({
  user,
  children
}: {
  user: SafeUser;
  children: React.ReactNode;
}) {
  return (
    
      {children}
    
  );
}

export function useUser() {
  const context = useContext(UserContext);
  if (!context) {
    throw new Error('useUser must be used within UserProvider');
  }
  return context;
}

性能不用担心

你可能在想:"每个组件都调一次 getCurrentUser(),数据库扛得住吗?"放心,我们在 DAL 里用了 React 的 cache(),同一个请求里不管调多少次,实际只查一次数据库:

// 同一个请求里,这三次调用只会触发一次数据库查询
// layout.tsx
const user1 = await getCurrentUser(); // 查数据库

// page.tsx
const user2 = await getCurrentUser(); // 用缓存

// component.tsx
const user3 = await getCurrentUser(); // 还是用缓存

服务器操作的安全防护

Server Actions 可能是 App Router 里最容易被忽略安全性的地方了。很多开发者把它当成"内部函数"来用,但实际上它可以被客户端直接调用——本质上就是一个公开的 API 端点。必须按 API 的标准来保护它。

完整的安全写法

// app/actions/article-actions.ts
'use server';

import { z } from 'zod';
import { revalidatePath } from 'next/cache';
import { auth } from '@/lib/auth';
import { createArticle, updateArticle } from '@/lib/dal';

const createArticleSchema = z.object({
  title: z.string().min(5, '标题至少 5 个字符').max(200, '标题不能超过 200 字符').trim(),
  content: z.string().min(10, '内容至少 10 个字符').max(50000, '内容不能超过 50000 字符'),
  published: z.boolean().default(false),
});

type ActionResponse =
  | { success: true; data: T }
  | { success: false; error: string; fieldErrors?: Record };

export async function createArticleAction(
  formData: FormData
): Promise> {
  try {
    // 第一步:验证身份
    const session = await auth();

    if (!session?.user) {
      return { success: false, error: '未授权:请先登录' };
    }

    // 第二步:验证输入
    const rawData = {
      title: formData.get('title'),
      content: formData.get('content'),
      published: formData.get('published') === 'true',
    };

    const result = createArticleSchema.safeParse(rawData);

    if (!result.success) {
      return {
        success: false,
        error: '输入数据无效',
        fieldErrors: result.error.flatten().fieldErrors,
      };
    }

    // 第三步:通过 DAL 操作数据(DAL 内部还会再验一次)
    const article = await createArticle(result.data);

    revalidatePath('/dashboard');

    return { success: true, data: { articleId: article.id } };

  } catch (error) {
    console.error('创建文章失败:', error);
    // 注意:不要把具体错误信息返回给客户端
    return { success: false, error: '创建文章失败,请重试' };
  }
}

别忘了速率限制

没有速率限制的 API 就是在邀请攻击者来暴力破解。用 Upstash 的方案可以很方便地实现:

// lib/ratelimit.ts
import { Redis } from '@upstash/redis';
import { Ratelimit } from '@upstash/ratelimit';

export const redis = new Redis({
  url: process.env.UPSTASH_REDIS_REST_URL!,
  token: process.env.UPSTASH_REDIS_REST_TOKEN!,
});

// 常规操作:每 10 秒最多 5 个请求
export const ratelimit = new Ratelimit({
  redis,
  limiter: Ratelimit.slidingWindow(5, '10 s'),
  prefix: 'ratelimit:server-action',
});

// 敏感操作(改密码、删账号等):每小时最多 3 次
export const strictRatelimit = new Ratelimit({
  redis,
  limiter: Ratelimit.slidingWindow(3, '1 h'),
  prefix: 'ratelimit:sensitive',
});

API 路由的认证策略

如果你的应用还需要提供 RESTful API(比如给移动端或者第三方用),API 路由就需要自己的认证机制了。

基于 Session 的认证

// app/api/articles/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { auth } from '@/lib/auth';
import { getArticlesByUserId } from '@/lib/dal';

export async function GET(request: NextRequest) {
  try {
    const session = await auth();

    if (!session?.user) {
      return NextResponse.json({ error: '未授权访问' }, { status: 401 });
    }

    // 还是走 DAL——保持一致性
    const articles = await getArticlesByUserId(session.user.id);

    return NextResponse.json({ articles });
  } catch (error) {
    return NextResponse.json({ error: '服务器错误' }, { status: 500 });
  }
}

基于 JWT Bearer Token 的认证

给外部系统用的话,JWT Bearer Token 更合适:

// lib/jwt-auth.ts
import 'server-only';
import { jwtVerify, SignJWT } from 'jose';

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

export async function generateToken(payload: {
  userId: string;
  email: string;
  role: string;
}) {
  return await new SignJWT(payload)
    .setProtectedHeader({ alg: 'HS256' })
    .setIssuedAt()
    .setExpirationTime('7d')
    .sign(secret);
}

export async function verifyToken(token: string) {
  try {
    const { payload } = await jwtVerify(token, secret);
    return {
      userId: payload.userId as string,
      email: payload.email as string,
      role: payload.role as string,
    };
  } catch {
    throw new Error('无效的 Token');
  }
}

通用的认证封装

每个 API 路由都写一遍认证逻辑太啰嗦了,封装一个高阶函数:

// lib/api-middleware.ts
import { NextRequest, NextResponse } from 'next/server';
import { verifyToken } from '@/lib/jwt-auth';

type ApiHandler = (
  request: NextRequest,
  context: { params?: any; auth: any }
) => Promise;

export function withAuth(handler: ApiHandler) {
  return async (request: NextRequest, context: any) => {
    try {
      const authHeader = request.headers.get('Authorization');

      if (!authHeader || !authHeader.startsWith('Bearer ')) {
        return NextResponse.json({ error: '缺少认证 Token' }, { status: 401 });
      }

      const token = authHeader.substring(7);
      const auth = await verifyToken(token);

      return await handler(request, { ...context, auth });
    } catch {
      return NextResponse.json({ error: '认证失败' }, { status: 401 });
    }
  };
}

会话管理:那些容易忽略的细节

认证做得再好,如果会话管理有漏洞,也白搭。会话劫持、会话固定这些攻击听起来离我们很远,但实际上比想象中常见。

Cookie 的安全配置

// 在 Auth.js 配置里
cookies: {
  sessionToken: {
    name: `${process.env.NODE_ENV === 'production' ? '__Secure-' : ''}authjs.session-token`,
    options: {
      httpOnly: true,        // JS 访问不了,防 XSS
      sameSite: 'lax',       // 防 CSRF
      path: '/',
      secure: process.env.NODE_ENV === 'production',  // 生产环境强制 HTTPS
    },
  },
},

会话失效和 Token 轮换

用户改了密码,或者你怀疑 token 泄漏了,需要能一键让所有会话失效:

// lib/session.ts
import 'server-only';
import { prisma } from '@/lib/prisma';

/**
 * 让某个用户的所有会话失效
 * 原理:给 sessionVersion 加 1,旧 token 里的版本号就对不上了
 */
export async function invalidateAllUserSessions(userId: string) {
  await prisma.user.update({
    where: { id: userId },
    data: { sessionVersion: { increment: 1 } },
  });
}

/**
 * 在 JWT 回调里检查版本号是否匹配
 */
export async function isSessionValid(
  userId: string,
  tokenSessionVersion?: number
) {
  const user = await prisma.user.findUnique({
    where: { id: userId },
    select: { sessionVersion: true },
  });

  if (!user) return false;

  return tokenSessionVersion === user.sessionVersion;
}

实战总结:完整的请求流程

最后来看看一个完整的请求是怎么经过四层防御的。假设用户要访问 /dashboard/articles/123 并编辑文章:

用户请求: GET /dashboard/articles/123
    ↓
┌──────────────────────────────────────────┐
│ Layer 1: Middleware                      │
│ ✓ session token 存在吗?                  │
│ ✓ 不存在 → 重定向到 /login               │
└──────────────────────────────────────────┘
    ↓
┌──────────────────────────────────────────┐
│ Layer 2: Server Component (page.tsx)     │
│ ✓ auth() 拿到完整的 session              │
│ ✓ 无效 → 重定向或报错                    │
│ ✓ 调 DAL 拿文章数据                      │
└──────────────────────────────────────────┘
    ↓
┌──────────────────────────────────────────┐
│ Layer 3: Data Access Layer               │
│ ✓ 再次验证用户身份                       │
│ ✓ 这篇文章你有权看吗?                   │
│ ✓ 返回 DTO(敏感字段已过滤)              │
└──────────────────────────────────────────┘
    ↓
用户提交编辑: Server Action
    ↓
┌──────────────────────────────────────────┐
│ Layer 4: Server Action                   │
│ ✓ 再验一次身份                           │
│ ✓ Zod 校验输入                           │
│ ✓ 速率限制检查                           │
│ ✓ 调 DAL 执行更新                        │
└──────────────────────────────────────────┘
    ↓
┌──────────────────────────────────────────┐
│ Layer 3: DAL(又来了)                    │
│ ✓ 再次验证身份                           │
│ ✓ 你有权编辑这篇文章吗?                  │
│ ✓ 通过后执行数据库更新                    │
└──────────────────────────────────────────┘

看起来验了很多次?没错,这就是纵深防御。每一层都不假设前面已经验过了,所以即使某一层出了问题,数据依然是安全的。

项目文件结构参考

next-app/
├── app/
│   ├── (authenticated)/          # 需要认证的路由组
│   │   ├── layout.tsx            # 认证布局(Layer 2)
│   │   ├── dashboard/
│   │   │   └── page.tsx
│   │   └── admin/
│   │       └── page.tsx
│   ├── (public)/                 # 公开路由组
│   │   ├── login/page.tsx
│   │   └── register/page.tsx
│   ├── actions/                  # 服务器操作(Layer 4)
│   │   ├── auth-actions.ts
│   │   └── article-actions.ts
│   ├── api/
│   │   ├── auth/[...nextauth]/route.ts
│   │   └── v1/articles/route.ts
│   └── middleware.ts             # 中间件(Layer 1)
├── lib/
│   ├── auth.ts                   # Auth.js 配置
│   ├── dal.ts                    # 数据访问层(Layer 3)
│   ├── dto.ts                    # DTO 定义
│   ├── prisma.ts
│   ├── ratelimit.ts
│   └── jwt-auth.ts
└── components/
    └── UserProvider.tsx

安全检查清单

最后给你一份可以直接拿去用的检查清单。新项目上线前、老项目做安全审计的时候,对着过一遍。

架构层面

  • 实施了纵深防御,不依赖单一安全层
  • 中间件、服务器组件、DAL、Server Actions/API 都有独立的认证检查
  • 所有数据访问都通过 DAL 集中管理
  • 用了 server-only 保护服务端代码

中间件层

  • 中间件只做快速过滤和重定向,不承担核心安全职责
  • Next.js 已升级到修复 CVE-2025-29927 的版本

认证与数据层

  • 使用了成熟的认证库(推荐 Auth.js v5)
  • 服务器组件中独立验证 session
  • 用 React cache() 优化了重复调用的性能
  • DAL 实施了资源级别的权限检查
  • DTO 模式防止敏感数据泄漏

操作与 API 层

  • 每个 Server Action 都独立验证身份
  • 用 Zod 做了严格的输入验证
  • 实施了速率限制
  • 错误信息不暴露内部细节

会话管理

  • Cookie 配置了 HttpOnly、Secure、SameSite
  • 有 token 轮换机制
  • 有会话失效功能(比如改密码后踢掉所有旧会话)

记住这五条原则

  1. 永远不要信任边界——每一层都要自己验证
  2. 最小权限原则——只给用户该看到的数据
  3. 纵深优于边界——多层防护比单层铜墙铁壁更可靠
  4. 安全检查靠近数据——DAL 是最后也是最重要的防线
  5. 永远不信客户端——所有来自客户端的数据都要校验

安全这件事没有"做完了"的那一天。新的漏洞会不断出现,攻击手法也在持续进化。但只要你的架构是纵深的、分层的,就算某一层出了新漏洞,你的应用也不会一溃千里。这就是纵深防御的价值所在。

关于作者 Editorial Team

Our team of expert writers and editors.