原文:How to think about data security in Next.js;作者:Next.js 文档团队。中文全文译写与编者审核:未完纪。核对日期:2026-10-05。
依据 2026-10-05 读取的 Next.js App Router 官方文档;页面标注最后更新为 2026-08-25。使用异步 cookies/params 的当前 API,本文讨论 Next.js 16。experimental.taint 仍属实验功能,不能视为生产安全保证。

React Server Components 改善了性能,也让取数更直接,但同时改变了数据在哪里访问、如何传递,以及传统前端应用中的若干安全假设。服务端能够访问私密数据,并不意味着这些数据不会通过组件参数或动作返回值流向浏览器。本指南围绕读取、写入和审计,说明在 Next.js 中如何建立清晰的数据安全边界。
一、先选择一种数据获取方式
官方建议按项目的规模和历史选择主要架构:已有的大型应用或组织采用外部 HTTP API;新项目采用独立数据访问层 DAL;原型和学习项目可先在组件中读取数据。尽量保持一种明确的主要方式,避免随意混合,让开发者和安全审计者都能预期数据流向。
外部 HTTP API:沿用已有后端边界
在现有项目引入 Server Components 时,应继续采用零信任思路。Server Component 可以像 Client Component 一样,通过 fetch 调用已有 REST 或 GraphQL 接口。下面从请求 cookie 中取得认证 token,并传给指定的 API。
app/page.tsx:服务端调用既有 API
import { cookies } from 'next/headers'
export default async function Page() {
const cookieStore = await cookies()
const token = cookieStore.get('AUTH_TOKEN')?.value
const res = await fetch('https://api.example.com/profile', {
headers: {
Cookie: `AUTH_TOKEN=${token}`,
// Other headers
},
})
// ....
}
如果组织已有成熟的安全实践,或者独立后端团队用其他语言维护 API,这一方案较合适。把调用移到服务端,不会自动赋予调用者新的业务权限;已有 API 仍应完成自己的认证和授权。
数据访问层:把授权与返回字段集中起来
新项目宜建立专门的 Data Access Layer。这是内部库,负责什么时候取数、怎样取数,以及哪些数据可以进入渲染上下文。它应满足三个条件:只运行在服务端;执行授权检查;返回安全且最小的数据传输对象 DTO。集中数据逻辑后,更容易统一访问规则,也能在单次请求的不同位置共享内存缓存。
首先把当前用户读取封装成缓存辅助函数。这样无需沿 Server Component 树反复传递用户对象,也就减少了误传给 Client Component 的机会。返回对象不应把 token 或私密信息放在可公开字段中;原文用 User 类帮助避免把整个对象意外序列化给客户端。
data/auth.ts:缓存当前请求中的用户读取
import { cache } from 'react'
import { cookies } from 'next/headers'
// Cached helper methods makes it easy to get the same value in many places
// without manually passing it around. This discourages passing it from Server
// Component to Server Component which minimizes risk of passing it to a Client
// Component.
export const getCurrentUser = cache(async () => {
const cookieStore = await cookies()
const token = cookieStore.get('AUTH_TOKEN')
const decodedToken = await decryptAndValidate(token)
// Don't include secret tokens or private information as public fields.
// Use classes to avoid accidentally passing the whole object to the client.
return new User(decodedToken.id)
})
随后在 server-only 的 DTO 模块集中判断字段能否被当前访问者看到。示例中用户名暂时公开,电话号码仅对管理员或同一团队成员可见。查询使用支持安全参数化模板的数据库 API,返回对象只留下这一次展示所需的字段。
data/user-dto.tsx:字段级授权与最小返回对象
import 'server-only'
import { getCurrentUser } from './auth'
function canSeeUsername(viewer: User) {
// Public info for now, but can change
return true
}
function canSeePhoneNumber(viewer: User, team: string) {
// Privacy rules
return viewer.isAdmin || team === viewer.team
}
export async function getProfileDTO(slug: string) {
// Don't pass values, read back cached values, also solves context and easier to make it lazy
// use a database API that supports safe templating of queries
const [rows] = await sql`SELECT * FROM user WHERE slug = ${slug}`
const userData = rows[0]
const currentUser = await getCurrentUser()
// only return the data relevant for this query and not everything
// <https://www.w3.org/2001/tag/doc/APIMinimization>
return {
username: canSeeUsername(currentUser) ? userData.username : null,
phonenumber: canSeePhoneNumber(currentUser, userData.team)
? userData.phonenumber
: null,
}
}
页面拿到的 profile 已经过筛选,可以在明确边界内使用:
app/page.tsx:使用经过筛选的 DTO
import { getProfileDTO } from '../../data/user-dto'
export default async function Page({ params }) {
const { slug } = await params
// This page can now safely pass around this profile knowing
// that it shouldn't contain anything sensitive.
const profile = await getProfileDTO(slug)
...
}
私钥应放在环境变量中,访问 process.env 的位置宜收敛到 DAL,减少密钥流向其他模块的机会。这里的“仅服务端”只是位置约束,字段是否可见仍由业务授权决定。
组件级数据访问:原型方便,字段更容易泄漏
快速原型可以把数据库查询直接放进 Server Component,但若把查到的完整记录作为 props 交给客户端,就会连同页面并不需要的私密字段一起暴露。以下是原文明确标为错误的例子:
反例:把整条 userData 传入 Client Component
import Profile from './components/profile.tsx'
export default async function Page({ params }) {
const { slug } = await params
const [rows] = await sql`SELECT * FROM user WHERE slug = ${slug}`
const userData = rows[0]
// EXPOSED: This exposes all the fields in userData to the client because
// we are passing the data from the Server Component to the Client.
return <Profile user={userData} />
}
反例:过宽的 User props 接口鼓励传入完整对象
'use client'
// BAD: This is a bad props interface because it accepts way more data than the
// Client Component needs and it encourages server components to pass all that
// data down. A better solution would be to accept a limited object with just
// the fields necessary for rendering the profile.
export default async function Profile({ user }: { user: User }) {
return (
<div>
<h1>{user.name}</h1>
...
</div>
)
}
应先清理返回数据,只选公开字段。原文把 getUser 的返回值缩为 name,然后由服务端页面把这个窄对象传给 Profile。
data/user.ts:只返回公开字段
import { sql } from './db'
export async function getUser(slug: string) {
const [rows] = await sql`SELECT * FROM user WHERE slug = ${slug}`
const user = rows[0]
// Return only the public fields
return {
name: user.name,
}
}
app/page.tsx:传递 publicProfile
import { getUser } from '../data/user'
import Profile from './ui/profile'
export default async function Page({
params,
}: {
params: Promise<{ slug: string }>
}) {
const { slug } = await params
const publicProfile = await getUser(slug)
return <Profile user={publicProfile} />
}
二、读取数据:不要混淆“在服务端渲染”与“拥有服务端特权”
首次加载时,Server Components 与 Client Components 都可能参与服务端 HTML 生成,但它们运行在隔离的模块系统中。Server Components 只在服务端运行,可访问环境变量、秘密、数据库和内部 API。Client Components 在预渲染阶段虽然也在服务端执行,却必须遵守浏览器代码的安全假设,不能访问特权数据或 server-only 模块。
这种隔离提供了默认边界,却无法阻止开发者主动把私密数据放进 props。审查数据究竟如何取得、如何传给组件,仍是必需工作。
Taint:帮助阻止敏感对象被传给客户端
React 的实验 Taint API 可以标记不应发送到客户端的数据:experimental_taintObjectReference 面向对象引用,experimental_taintUniqueValue 面向具体值。在 Next.js 中可通过 experimental.taint 启用支持。
next.config.js:启用实验 taint 支持
module.exports = {
experimental: {
taint: true,
},
}
标记可以阻止对应对象或值被传到客户端,但只是额外保护。DAL 仍必须在数据进入 React 渲染上下文之前主动过滤和清理。环境变量默认只在服务端可用,而 NEXT_PUBLIC_ 前缀会明确把变量暴露给客户端,因此秘密不可使用此前缀。普通函数和类也不能随意序列化为客户端 props;Server Action 的专门引用机制应与普通函数区分。
用 server-only 防止服务端模块进入客户端
在只能由服务端使用的模块顶部加入标记:
lib/data.ts:仅服务端模块
import 'server-only'
//...
若客户端环境导入该模块,构建会报错,从而帮助把内部业务逻辑留在服务端。Next.js 内部处理 server-only 导入,并不使用 NPM 包的运行内容;若 lint 规则认为依赖未声明,可以安装该包满足依赖检查。
仅在依赖检查需要时安装 server-only
pnpm add server-only
三、写入数据:每个 Server Action 都是独立入口
内置保护能做什么
Next.js 使用 Server Actions 处理写入。已创建并导出的动作,应按能够被直接 POST 调用的公开入口对待,不能以“用户界面没有按钮”或“别处没有显式导入”为权限依据。框架提供两项辅助机制:为动作生成加密且非确定性的安全 ID,使客户端能引用它;在构建时移除未使用的动作及其公开入口。
ID 在编译期间创建,最长缓存 14 天;重新构建或构建缓存失效会重新生成。不可预测 ID 与死代码消除能减少暴露面,但不会代替动作内部的身份验证和授权。下面是原文用于说明已使用、未使用动作差异的示例。
动作 ID 与构建时未使用代码移除
// app/actions.js
'use server'
// If this action **is** used in our application, Next.js
// will create a secure ID to allow the client to reference
// and call the Server Action.
export async function updateUserAction(formData) {}
// If this action **is not** used in our application, Next.js
// will automatically remove this code during `next build`
// and will not create a public endpoint.
export async function deleteUserAction(formData) {}
验证全部客户端输入
表单、URL 参数、请求头和 searchParams 都可以被调用者修改。不能因为参数名称叫 isAdmin,就把字符串 true 当作管理员身份。原文先展示错误路径,再展示从 cookie 取 token 并重新验证管理员资格的做法。
反例与修正:管理员资格由可信服务端验证
// BAD: Trusting searchParams directly
export default async function Page({ searchParams }) {
const isAdmin = (await searchParams).isAdmin
if (isAdmin === 'true') {
// Vulnerable: relies on untrusted client data
return <AdminPanel />
}
}
// GOOD: Re-verify every time
import { cookies } from 'next/headers'
import { verifyAdmin } from './auth'
export default async function Page() {
const cookieStore = await cookies()
const token = cookieStore.get('AUTH_TOKEN')
const isAdmin = await verifyAdmin(token)
if (isAdmin) {
return <AdminPanel />
}
}
动作内部重新验证身份,再检查资源权限
页面层的登录检查不会自动保护其中定义的动作。下面页面未通过检查时会跳转登录页,但动作被调用时仍再次调用 auth,重新确认管理员身份。两次检查保护的是两个不同入口。
app/admin/page.tsx:页面与动作分别认证
import { auth } from '@/lib/auth'
import { redirect } from 'next/navigation'
export default async function AdminPage() {
const session = await auth()
if (!session?.user?.isAdmin) {
redirect('/login')
}
return (
<form
action={async () => {
'use server'
const session = await auth()
if (!session?.user?.isAdmin) {
throw new Error('Unauthorized')
}
await db.record.deleteMany()
}}
>
<button>Delete Records</button>
</form>
)
}
认证回答“有没有登录”,授权回答“能不能操作这一项资源”。仅检查登录而信任 postId,会导致不安全的直接对象引用 IDOR。下面在删除帖子前比较帖子的 authorId 与当前用户 ID。
app/actions.ts:资源归属检查
'use server'
import { auth } from '@/lib/auth'
import { db } from '@/lib/db'
export async function deletePost(postId: string) {
const session = await auth()
if (!session?.user) {
throw new Error('Unauthorized')
}
const post = await db.post.findUnique({ where: { id: postId } })
// Check that the user owns this resource
if (post.authorId !== session.user.id) {
throw new Error('Forbidden')
}
await db.post.delete({ where: { id: postId } })
}
把写入也放进 DAL
读取与写入可以使用同一组织原则:身份验证、资源授权和数据库逻辑放入 server-only 模块;带 'use server' 的动作尽量只负责调用。以下先把 deletePost 移进数据层。
data/posts.ts:集中写入授权与数据逻辑
import 'server-only'
import { auth } from '@/lib/auth'
import { db } from '@/lib/db'
export async function deletePost(postId: string) {
const session = await auth()
if (!session?.user) {
throw new Error('Unauthorized')
}
const post = await db.post.findUnique({ where: { id: postId } })
if (post.authorId !== session.user.id) {
throw new Error('Forbidden')
}
await db.post.delete({ where: { id: postId } })
}
动作只委托给 DAL,并在成功后按需要刷新页面缓存:
app/actions.ts:薄动作委托数据层
'use server'
import { deletePost } from '@/data/posts'
import { revalidatePath } from 'next/cache'
export async function deletePostAction(postId: string) {
await deletePost(postId) // Auth + authz happen inside the DAL
revalidatePath('/posts')
}
server-only 可以同时放在 DAL 文件与 'use server' 文件中。即使客户端导入动作引用用于 useActionState,两者仍可工作,因为 'use server' 模块会在仅服务端的 webpack layer 中解析。前一节指出的空值和并发检查,在移动代码后同样需要补齐。
限制动作返回值
动作返回值会被序列化并发给客户端。不要直接返回可能含有内部字段的数据库记录,只返回 UI 需要的信息。原文以更新用户资料为例:第一段直接返回 update 的完整结果,第二段完成更新后只返回 { success: true }。
反例与改进:收窄动作返回值
'use server'
import { auth } from '@/lib/auth'
import { db } from '@/lib/db'
// BAD: Returns the full database record, which may include
// internal fields the client should not see.
export async function updateUser(data: FormData) {
const session = await auth()
if (!session?.user) {
throw new Error('Unauthorized')
}
return db.user.update({
where: { id: session.user.id },
data: { name: data.get('name') as string },
})
}
// GOOD: Returns only what the client needs.
export async function updateUserSafe(data: FormData) {
const session = await auth()
if (!session?.user) {
throw new Error('Unauthorized')
}
await db.user.update({
where: { id: session.user.id },
data: { name: data.get('name') as string },
})
return { success: true }
}
给昂贵操作设置调用限制
发送邮件、写数据库等成本较高的操作,应考虑限流以减少滥用。限流的身份维度、配额、存储和错误响应需要结合业务实现;原文将具体示例指向 Backend for Frontend 指南,而不是在这里给出通用配置。
四、闭包与加密
在组件内部定义动作会创建闭包,使动作能访问外部作用域。下例在页面渲染时取得 publishVersion,动作稍后执行时再次读取最新版本,并拒绝版本已变化的发布。闭包适合捕获渲染时的状态快照。
捕获版本快照的 publish 动作
export default async function Page() {
const publishVersion = await getLatestVersion();
async function publish() {
"use server";
if (publishVersion !== await getLatestVersion()) {
throw new Error('The version has changed since pressing publish');
}
...
}
return (
<form>
<button formAction={publish}>Publish</button>
</form>
);
}
为实现这一机制,闭包捕获的变量需要传到客户端,再在动作调用时传回服务端。Next.js 自动加密这些变量;每次应用构建时会为动作生成新私钥,因此动作与特定构建关联。即使如此,也不应仅依赖加密来防止秘密泄露,更不能把版本比较视为身份授权。
多实例部署时覆盖加密密钥
自托管的多服务器环境可能各自生成不同密钥,造成动作调用不一致。高级部署可通过 NEXT_SERVER_ACTIONS_ENCRYPTION_KEY 提供一致的密钥,使各实例和构建使用受控的相同值。
值必须采用 Base64 编码,解码后长度应为合法 AES 密钥长度:16、24 或 32 字节。Next.js 默认生成 32 字节密钥。原文给出的生成命令如下,仅作为格式示例保留。
生成 32 字节随机材料的原文命令(未执行)
openssl rand -base64 32
此机制适用于必须跨实例、跨部署维持一致加密行为的高级场景。应使用密钥管理机制,并安排轮换及部署协调,参考自托管指南的版本相关说明。不要把实际密钥放进源码、公开环境变量、日志或本文配置示例。
五、允许来源与 CSRF 边界
Server Actions 可以由 form 触发,因此需要考虑 CSRF。Next.js 只允许通过 POST 调用动作,这与现代浏览器的 SameSite cookie 行为共同降低常见 CSRF 风险。框架还会比较 Origin 与 Host,或 X-Forwarded-Host;不匹配时中止请求,默认让动作来自承载页面的同一主机。
大型应用若有反向代理或多层后端,API 所在主机可能不同于生产域名。可以通过 serverActions.allowedOrigins 明确指定可信来源,值为字符串数组。原文配置位于 experimental 下:
next.config.js:反向代理场景允许来源
/** @type {import('next').NextConfig} */
module.exports = {
experimental: {
serverActions: {
allowedOrigins: ['my-proxy.com', '*.my-proxy.com'],
},
},
}
六、渲染期间不要产生写入副作用
注销用户、更新数据库、让缓存失效等操作,不应作为 Server Component 或 Client Component 渲染的副作用。Next.js 明确阻止在渲染方法中设置 cookie 或触发缓存重验证,以避免意外写入。以下反例把 URL 查询参数 logout 当作触发条件,试图在渲染时删 cookie:
反例:渲染时注销用户
// BAD: Triggering a mutation during rendering
export default async function Page({ searchParams }) {
if ((await searchParams).logout) {
const cookieStore = await cookies()
cookieStore.delete('AUTH_TOKEN')
}
return <UserProfile />
}
正确的组织方式是由显式的 Server Action 处理写入,并通过表单提交触发:
通过 logout 动作处理注销
// GOOD: Using Server Actions to handle mutations
import { logout } from './actions'
export default function Page() {
return (
<>
<UserProfile />
<form action={logout}>
<button type="submit">Logout</button>
</form>
</>
)
}
Next.js 用 POST 处理写入,可避免 GET 请求带来意外副作用,也降低 CSRF 风险。此示例没有展示 logout 实现,其身份和 cookie 处理仍需在对应动作内正确完成。
七、审计 Next.js 项目时重点检查什么
- 数据访问层:是否有隔离且统一的 DAL?数据库包和环境变量是否被其他层随意导入?
'use client'文件:props 是否期待私密数据?类型是否过宽,鼓励把整个用户或数据库对象传下去?'use server'文件:参数是否在动作或 DAL 中验证?每次是否重新认证与授权?是否检查资源归属,而不仅仅是已登录?返回值是否仅含客户端所需字段?数据库访问是否委托给 server-only DAL?- /[param]/ 路径目录:动态路由参数来自用户,是否验证类型、范围和业务含义?
- proxy.ts 与 route.ts:这两类入口权限较强,应投入更多传统安全审查,并按团队开发周期定期做渗透测试或漏洞扫描。
原文的后续阅读包括 Authentication、Content Security Policy、Forms 和 Server Actions。把这些主题与数据最小化结合起来,才能让读取与写入使用同一套清楚的权限边界。本文完成的是源文和示例的静态审核,未对具体项目进行渗透或运行验证。
来源、署名与许可
原文出自 Next.js 官方文档与 vercel/next.js 仓库。仓库许可为 MIT,版权声明为 Copyright (c) 2025 Vercel, Inc.;本篇保留该版权与许可通知,中文译写和编者修正已明确标注。完整 MIT 许可附于本文末尾。
代码保留原有技术结构,英文标识符、代码注释和演示文本保留,编者补充已明确标注。原创流程图用于解释正文,不代表实测结果。
版权与许可全文
以下保留本页涉及的来源材料或示例代码的版权、许可条件与免责声明;各自适用范围依原声明。中文翻译及编辑标注:未完纪,2026-10-05。
license.txt
The MIT License (MIT) Copyright (c) 2025 Vercel, Inc. Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.












暂无评论内容