客户端授权是运行在浏览器中、决定用户所见内容的访问逻辑;服务端授权是运行在服务器上、决定实际操作是否被允许的访问逻辑——无论 UI 层面设置了多少访问限制,都无法改变服务器的判断结果。这是两项不同的职责,分别对应两个不同的信任边界。缺少任何一层都会产生特定且可预见的故障:仅有客户端检查,任何打开 DevTools 的用户都可以绕过;仅有服务端检查,用户点击按钮后只会收到 403 错误,却完全不明白原因。
如果你曾在 React 或 Next.js 应用中用 if (user.role === 'admin') 这样的代码块来做权限判断,那你已经在编写客户端授权了——只是可能还不清楚真正的安全边界在哪里。本文将精确划定这条边界:为什么客户端是不可信的,为什么服务端才是唯一真正的守门人,客户端检查的合理用途是什么,以及如何使用同一个权限标识符在两层中同步执行授权逻辑。
核心要点
- 客户端是不可信的环境:任何用户都可以打开 DevTools,修改存储其角色的变量,并观察条件渲染如何响应——因此授权决策必须在服务端、网关或 Serverless 函数中强制执行(OWASP 授权速查表)。
- 客户端授权服务于用户体验,而非安全保障:它用于隐藏按钮、拦截路由、只渲染适用的菜单项,确保用户不会进入死胡同。
- 前端的权限状态是服务端所允许操作的缓存;一旦两者出现偏差,要么产生可被利用的工作流(UI 显示了一个服务端会返回 403 的按钮),要么造成能力丢失(服务端允许某操作,但 UI 从未将其呈现出来)。
- 在两层中都使用
tasks:delete这样的权限标识符,而非role === 'admin'这样的角色名称比较——权限名称在角色结构调整后依然有效,且与服务端实际执行的单元相匹配。 - 401 表示未认证,应重定向至登录页;403 表示已认证但未获授权,应呈现权限拒绝状态(RFC 9110 §15.5.2,§15.5.4)。
客户端是不可信的环境
你在 JavaScript 中编写的所有角色检查,都运行在用户可以控制的环境中:他们可以打开 DevTools,找到存储角色的变量,将其设置为 'admin',然后观察条件渲染如何响应——唯一不会改变的,是服务器对其下一次请求的响应。这是客户端授权本身不足以保障安全的根本原因。浏览器执行你的代码,但用户拥有浏览器,他们可以在页面加载到下一次网络请求之间修改任何内容。
OWASP 授权速查表直接给出了实践准则:客户端访问控制检查可以改善用户体验,但授权决策必须在服务端、网关或 Serverless 函数中强制执行——因为客户端逻辑很容易被绕过。这一概念框架早于现代单页应用(SPA)的出现;Static Apps 关于认证的指南将其表述为”终端用户可以在未经事先许可的情况下,在客户端执行任意代码”。
30 秒内用 DevTools 完成绕过
演示纯客户端授权为何会失效,最直观的方式就是亲自绕过它。在任何浏览器中打开 DevTools,针对任何将角色存储在组件状态中(而非在每次请求时从服务端验证的 token 中派生)的 React 应用,都可以复现以下步骤:
- 以普通(非管理员)用户身份登录。管理员面板处于隐藏状态——你的组件渲染了
{user.role === 'admin' && <AdminPanel />},而此时user.role的值为'user'。 - 打开 DevTools,找到存储
user的组件状态。使用 React DevTools 可以直接检查和编辑 Hook 状态;如果没有安装,任何将 user 对象暴露给可变引用的代码路径同样有效。 - 将
role设置为'admin'。React 重新渲染,管理员面板出现在 DOM 中。 - 点击面板上暴露的删除按钮,请求被发出。
- 如果服务端执行了授权检查,它会从 token 中读取身份信息——而非从你修改过的客户端状态中读取——并返回
403 Forbidden。数据不会发生任何变化。
攻击在第 3 步成功(UI 发生变化),在第 5 步失败(服务端拒绝请求)——但这仅限于存在服务端检查的情况。若没有服务端检查,第 4 步就会真实地修改数据。这种绕过之所以有效,正是因为角色存储在可变的客户端状态中;而当服务端在每次请求时都从已验证的 token 中重新推导授权结果时,这种绕过就无从奏效。
服务端才是唯一真正的守门人
Discover how at OpenReplay.com.
凡是涉及数据变更或访问受保护资源的授权决策,必须在服务端做出并强制执行——因为服务端是请求链路中唯一用户无法篡改的参与方。后端是最终的守门人:无论前端渲染、隐藏或禁用了什么,服务端对请求的评估才是最终裁决。前端控制无法替代后端强制执行。
以下是在 Next.js Route Handler 中对单个权限 tasks:delete 进行服务端强制执行的示例。权限从已认证的会话中读取,而非从客户端在请求体中发送的任何内容中读取:
// app/api/tasks/[id]/route.ts — Next.js App Router
import { NextRequest, NextResponse } from 'next/server';
import { getSessionPermissions } from '@/lib/auth';
import { deleteTask } from '@/lib/tasks';
export async function DELETE(
req: NextRequest,
{ params }: { params: { id: string } },
) {
const permissions = await getSessionPermissions(req); // 从已验证的 token/session 中派生
if (!permissions.includes('tasks:delete')) {
return NextResponse.json(
{ error: 'forbidden', permission: 'tasks:delete' },
{ status: 403 },
);
}
await deleteTask(params.id);
return new NextResponse(null, { status: 204 });
}
403 响应在响应体中携带了具体的权限名称,这一细节在后续处理中非常重要:它让客户端能够将权限拒绝与其他类型的失败区分开来,从而呈现有意义的提示信息,而不是一条通用的 Toast 消息。根据 RFC 9110 §15.5.4 的定义,403 Forbidden 是正确的语义选择——该状态码表示服务端理解了请求,但拒绝对其进行授权。
客户端授权服务于界面,而非安全门控
客户端授权的作用是塑造界面:对非管理员隐藏管理按钮、拦截路由以避免用户进入 403 页面、只展示适用的菜单项。它通过引导用户走向他们实际能完成的操作来改善体验——但它无法、也不应该承担任何安全职责。可以把前端理解为展示柜,而非保险箱:它负责安排哪些内容可见、可操作,而锁始终在服务端。
标准实现方式是从 context 中读取权限,并基于权限标识符进行条件渲染:
// components/TaskActions.tsx
'use client';
import { usePermissions } from '@/hooks/usePermissions';
export function TaskActions({ taskId }: { taskId: string }) {
const { can } = usePermissions();
return (
<div className="task-actions">
{can('tasks:delete') && (
<button onClick={() => deleteTask(taskId)}>Delete</button>
)}
</div>
);
}
注意这里使用的标识符:tasks:delete,而非 role === 'admin'。检查 user.role === 'admin' 会将 UI 与你当前的角色体系耦合;检查 can('tasks:delete') 则将 UI 与服务端实际执行的操作耦合——服务端并不关心是哪个角色授予了该权限。当你日后将 admin 拆分为 admin 和 billing-admin 时,基于权限名称的检查无需任何修改即可继续工作。OWASP 授权速查表正是出于这一原因,建议将角色与权限分离。RBAC 仍然是前端应用中常见的访问控制模型;关于 RBAC/ABAC/ACL/PBAC 的选型问题,LogRocket 的访问控制模型对比文章有详细介绍。
有一个工程细节值得注意:在对角色受限 UI 进行会话回放时,经常会捕捉到这样一个短暂的时刻——未授权用户在客户端权限检查完成并隐藏管理按钮之前,短暂看到了该按钮,这是由于权限状态在首次渲染后才到达所导致的”未授权 UI 闪现”。解决方法是在服务端渲染时就携带权限状态,或在渲染前预加载权限数据——下一节介绍的交接模式在设计上就解决了这个问题。
两层必须保持一致:权限漂移
前端对权限的视图是服务端所允许操作的缓存,两者必须保持同步。一旦前端的权限状态与服务端的执行规则出现偏差,就会产生两种故障之一:一个服务端会返回 403 的按钮(可被利用的工作流),或者一个服务端允许但 UI 从未呈现的操作(能力丢失)。前者既是安全问题也是用户体验问题——你展示了无法执行的操作,攻击者还可以借此探测服务端实际允许哪些操作。后者则是纯粹的能力损失——用户无法访问他们本有权限执行的功能。
防止漂移的方法在于结构设计:定义单一的事实来源,并让两层都从这一来源获取数据。
交接模式
交接模式(Handoff Pattern)是一种授权架构:服务端持有权限的单一事实来源,客户端持有该事实的缓存——在登录时填充,用于渲染界面,但仅作为参考而非权威依据。完整的流程如下:服务端决定用户的权限,以约定的数据格式发送给客户端,客户端将其缓存到 context 中,UI 根据缓存进行渲染,用户执行操作,服务端在请求中重新验证。同一个权限标识符贯穿每一个环节。
首先定义数据格式。在登录时(或在初始 Server Component 渲染中),服务端输出权限集合:
{
"userId": "u_8123",
"permissions": ["tasks:read", "tasks:create", "tasks:delete"]
}
如果在 JWT 中携带权限,它们以 claims 的形式存储在 token 的 payload 中。签名 JWT(JWS)将 claims 存储在经过 base64url 编码的 payload 中,该 payload 受完整性保护但并未加密——签名证明 claims 未被篡改,但 claims 本身并非机密,客户端可以读取。加密 JWT(JWE)是另一种不同的构造方式。这一区别来自 RFC 7519,即 JWT 规范。实际影响在于:JWT 的权限 claims 是用于渲染的良好缓存,但它们终究只是缓存。服务端应在每次请求时,根据应用架构所依赖的权威数据源来验证授权。
将缓存一次性注入 context,最好通过 Server Component 完成,以确保数据在首次渲染时就已就绪:
// app/layout.tsx — Server Component, Next.js App Router
import { getSessionPermissions } from '@/lib/auth';
import { PermissionsProvider } from '@/hooks/usePermissions';
export default async function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
const permissions = await getSessionPermissions(); // 与 API 使用相同的数据来源
return (
<html lang="en">
<body>
<PermissionsProvider permissions={permissions}>
{children}
</PermissionsProvider>
</body>
</html>
);
}
// hooks/usePermissions.tsx
'use client';
import { createContext, useContext } from 'react';
const PermissionsContext = createContext<string[]>([]);
export function PermissionsProvider({
permissions,
children,
}: {
permissions: string[];
children: React.ReactNode;
}) {
return (
<PermissionsContext.Provider value={permissions}>
{children}
</PermissionsContext.Provider>
);
}
export function usePermissions() {
const permissions = useContext(PermissionsContext);
return { can: (p: string) => permissions.includes(p) };
}
由于 getSessionPermissions() 与 Route Handler 调用的是同一个函数,UI 和服务端门控读取的是完全相同的数据,tasks:delete 在两端具有相同的含义。在 Server Component 中加载权限(而非在客户端的 useEffect 中获取),意味着权限集合在首次渲染前就已可用——不会出现错误 UI 的闪现,也不会有检查解析时的抖动。
React Server Components 与 Server Actions:边界是语法层面的
在 Next.js App Router 中,“客户端”和”服务端”描述的是代码的运行位置,而非你手动发起的网络调用——正是这一区别,使得 Server Actions 成为执行授权的合法场所。一个在变更数据前调用权限检查的 Next.js Server Action,就是服务端授权,无论它位于组件树的哪个位置:代码在服务端运行,用户无法修改它,检查也无法通过编辑客户端状态来绕过。
在当前的 Next.js App Router 文档中,'use server' 指令用于标记 Server Function;在 action/mutation 上下文中使用的 Server Function 也称为 Server Action。该指令是边界标记——被其标注的代码在服务端执行,即使它与客户端组件一同编写在 .tsx 文件中:
// app/tasks/actions.ts — Next.js App Router
'use server';
import { getSessionPermissions } from '@/lib/auth';
import { deleteTask } from '@/lib/tasks';
export async function deleteTaskAction(taskId: string) {
const permissions = await getSessionPermissions(); // 在服务端运行
if (!permissions.includes('tasks:delete')) {
return { ok: false as const, error: 'forbidden', permission: 'tasks:delete' };
}
await deleteTask(taskId);
return { ok: true as const };
}
这与 Route Handler 中的 tasks:delete 检查相同,处于同样的执行位置。在客户端修改状态来伪造 role === 'admin' 在这里毫无作用——该 action 会在服务端从会话中重新推导权限。Server Actions 并不消除门控的必要性;它们只是门控可以存在的另一个位置。关于该指令的完整语义,请参阅 Next.js use server 指令文档。
当你的 SPA 直接调用第三方 API 时
当你的 SPA 直接从浏览器调用第三方 API 时,该第三方在授权层面就是你的服务端——无论是支付服务商、Headless CMS 还是 BaaS,它就是门控,无论你是否编写了它。授权边界并不会因为你没有编写后端而消失;它只是转移到了评估请求的一方。你的客户端检查仍然只服务于用户体验,第三方的授权规则才是真正的门控。如果你无法在自己控制的代码中强制执行某个决策,就应该将请求通过你自己的后端进行中转。
故障模式:跳过任一层会发生什么
跳过任何一层都会产生特定且可预见的故障。纯客户端授权可被轻易绕过;纯服务端授权则会让用户感到困惑。两者单独使用都不可接受。
| 缺失的层 | 出现的问题 | 谁会利用它 | 用户体验 |
|---|---|---|---|
| 服务端(仅客户端) | 授权根本未被执行 | 任何使用 DevTools 或 curl 的用户 | 表面看起来正常——直到数据被无权限者修改 |
| 客户端(仅服务端) | UI 显示了服务端会拒绝的操作 | 无法被利用;这是用户体验缺陷 | 用户点击按钮,收到 403,再次点击,最终放弃 |
这条规则简短到足以记住:用客户端权限渲染,用服务端权限执行,并将相同的权限标识符从服务端的单一事实来源传递到两端。违反第一条,用户点击按钮只会收到 403。违反第二条,任何拥有 DevTools 的用户都可以提升自己的权限。
授权失败的用户体验:403 与 401
当服务端拒绝了 UI 认为应该被允许的操作时,响应状态码会精确告诉你应该向用户展示什么。401 表示用户未认证——重定向到登录页。403 表示用户已认证但未获授权——展示权限拒绝状态,而非通用错误,让用户明白操作失败的原因。这些语义由 RFC 9110 明确规定:§15.5.2 定义了 401 Unauthorized(需要认证但认证失败或未提供),§15.5.4 定义了 403 Forbidden(服务端理解请求但拒绝授权)。
async function deleteTask(taskId: string) {
const res = await fetch(`/api/tasks/${taskId}`, { method: 'DELETE' });
if (res.status === 401) {
window.location.assign('/login');
return;
}
if (res.status === 403) {
const body = await res.json();
showPermissionDenied(body.permission); // 例如:"您没有删除任务的权限。"
return;
}
if (!res.ok) {
showError('Something went wrong. Try again.');
return;
}
// 成功
}
对 403 进行专门处理,正是客户端/服务端权限漂移对用户可见的地方。对 403 响应的会话回放通常会显示这样的场景:用户点击按钮,收到通用错误提示,然后再次点击——这表明 UI 从未告知用户该操作因权限原因被拒绝。从响应体中提取权限名称(即服务端发送的 permission: 'tasks:delete' 字段)可以弥补这一缺口。RFC 定义了 403 的含义;如何呈现它由你决定,但 UI 应当反映这一含义。
结语
授权存在于两个边界,各司其职:客户端塑造界面,服务端决定实际发生的事情,相同的权限标识符从服务端的单一事实来源流向两端。审查你自己的代码,查找两种故障模式——搜索那些对数据变更操作进行门控、却没有对应服务端强制执行的角色或权限检查(这是等待被利用的绕过漏洞),以及那些与 API 实际返回内容出现偏差的客户端检查(这会让用户对 403 错误感到困惑)。凡是涉及数据变更的检查,都要确认服务端从已验证的会话中重新推导决策,而非依赖任何客户端可以篡改的内容。
常见问题
如果客户端可以读取 JWT,将用户权限存储在 JWT 中是否安全?
是的,只要你将 token 视为缓存而非安全边界。签名 JWT(JWS)将 claims 存储在经过 base64url 编码的 payload 中,该 payload 受完整性保护但未加密,因此客户端可以读取权限内容,但签名防止了篡改。风险不在于权限列表的暴露,而在于对其的信任。服务端应在每次请求时,根据应用所使用的授权模型验证授权。
如果页面已经对 UI 进行了门控,Next.js Server Action 是否还需要自己的授权检查?
需要。UI 门控只控制渲染内容,不保护数据变更操作。Server Action 在服务端运行,可以独立于渲染它的页面被调用,因此必须从已验证的会话中重新推导权限,并自行拒绝未授权的调用。'use server' 指令标记的是代码的运行位置,而非其是否已获授权。应将每个 Server Action 视为一个执行点,与 Route Handler 完全相同。
当用户已登录但缺少某操作的权限时,API 应返回什么状态码?
应返回 403 Forbidden,而非 401 Unauthorized。RFC 9110 将 401 定义为需要认证但认证失败或未提供,将 403 定义为服务端理解请求但拒绝授权。已登录但缺少权限的用户是已认证但未获授权,这正是 403 的适用场景。客户端应在收到 401 时重定向到登录页,在收到 403 时显示权限拒绝状态,从而使两种失败在视觉和行为上保持明确区分。
为什么要在前端检查 'tasks:delete' 而非 'role === admin'?
权限标识符在角色结构调整后依然有效,而角色名称比较则不然。检查 'role === admin' 会将 UI 与当前的角色体系耦合,因此日后将 admin 拆分为 admin 和 billing-admin 时,每一处检查都会失效。检查 'tasks:delete' 则将 UI 与服务端实际执行的操作耦合,角色重组时该操作不会改变。