12k
All articles

客户端授权与服务端授权:为何两者缺一不可

React和Next.js中的客户端与服务器端授权:在服务器强制权限,客户端只做UX,避免403权限偏差。

OpenReplay Team
OpenReplay Team
客户端授权与服务端授权:为何两者缺一不可

客户端授权是运行在浏览器中、决定用户所见内容的访问逻辑;服务端授权是运行在服务器上、决定实际操作是否被允许的访问逻辑——无论 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 应用,都可以复现以下步骤:

  1. 以普通(非管理员)用户身份登录。管理员面板处于隐藏状态——你的组件渲染了 {user.role === 'admin' && <AdminPanel />},而此时 user.role 的值为 'user'
  2. 打开 DevTools,找到存储 user 的组件状态。使用 React DevTools 可以直接检查和编辑 Hook 状态;如果没有安装,任何将 user 对象暴露给可变引用的代码路径同样有效。
  3. role 设置为 'admin'。React 重新渲染,管理员面板出现在 DOM 中。
  4. 点击面板上暴露的删除按钮,请求被发出。
  5. 如果服务端执行了授权检查,它会从 token 中读取身份信息——而非从你修改过的客户端状态中读取——并返回 403 Forbidden。数据不会发生任何变化。

攻击在第 3 步成功(UI 发生变化),在第 5 步失败(服务端拒绝请求)——但这仅限于存在服务端检查的情况。若没有服务端检查,第 4 步就会真实地修改数据。这种绕过之所以有效,正是因为角色存储在可变的客户端状态中;而当服务端在每次请求时都从已验证的 token 中重新推导授权结果时,这种绕过就无从奏效。

服务端才是唯一真正的守门人

凡是涉及数据变更或访问受保护资源的授权决策,必须在服务端做出并强制执行——因为服务端是请求链路中唯一用户无法篡改的参与方。后端是最终的守门人:无论前端渲染、隐藏或禁用了什么,服务端对请求的评估才是最终裁决。前端控制无法替代后端强制执行。

以下是在 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 拆分为 adminbilling-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 与服务端实际执行的操作耦合,角色重组时该操作不会改变。

Understand every bug

Uncover frustrations, understand bugs and fix slowdowns like never before with OpenReplay — self-hosted, with full data ownership.

Star on GitHub

We use cookies to improve your experience. By using our site, you accept cookies.