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

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

Next.js App Router认证指南:四层纵深防御2026

引言: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. 永远不信客户端——所有来自客户端的数据都要校验

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

关于作者 Hannah Lindqvist

Hannah is a performance-focused frontend engineer with nine years building React applications, the last five exclusively on Next.js. She spent three years at Spotify on the Web Player team optimizing TTI on low-end devices in emerging markets, then two years at a Stockholm-based e-commerce platform where she rebuilt the storefront on App Router and edge runtime, hitting sub-200ms TTFB across European POPs. She currently freelances for DTC brands and SaaS companies that need a Core Web Vitals rescue, and occasionally contributes patches to next/font and the Vercel Speed Insights SDK. She has presented at React Advanced London and React Summit Amsterdam on streaming rendering patterns. Her tutorials are the ones with Lighthouse screenshots and flame graphs, because she doesn't trust benchmarks she can't reproduce. Strong preference for Playwright over Cypress and Vitest over Jest.