企业 MCP 部署首先要回答两个不同的问题:

  1. 谁在发起请求,哪个 Authorization Server 颁发了身份凭证?
  2. 这个 Principal 是否可以对这个 Tenant 中的这个 Resource 执行这次精确操作?

OAuth 和 OpenID Connect 主要帮助回答第一个问题,不能替代第二个问题。远程 MCP Server 的核心约束正是:一个有效的 Bearer Token 可以证明 Principal,却不能自动允许它读取其他租户的账单、向外部发送消息或删除对象。

本文专注于远程 MCP Server 周围的身份边界。关于 Transport、Session、大结果和协议级可靠性的生产设计,可参阅 MCP 生产实践指南。文中 TypeScript 代码是用于说明边界的核心片段,不承诺复制后即可部署;实际实现必须在仓库中固定 MCP 规范版本、SDK 版本和身份提供商 Profile。

核心结论

  • 把 MCP Server 视为 Protected Resource。Authorization Server 或企业 IdP 负责颁发凭证,MCP Server 负责校验凭证并执行资源策略。
  • 使用部署实际支持的 Authorization Server Metadata 与 Protected Resource Metadata,不要假定动态注册、Scope 名称或发现地址在所有 IdP 中相同。
  • 对面向用户的公开客户端,PKCE S256 可以降低授权码被拦截后的重放风险;它不能替代 Redirect URI、State、Nonce、Token 校验和 Tool 鉴权。
  • 严格校验 Issuer、Audience 或 Resource Indicator、允许的 Algorithm、签名、时间 Claims 与 Scopes,并把不同 IdP 的 Claim 映射隔离在认证适配层。
  • JWKS 缓存只是一种可用性优化,不是接受未知密钥或跳过验证的理由。
  • Microsoft Entra OBO 等下游委托交换属于提供商能力,不是 MCP 通用行为;只有在下游 API 和 IdP 都支持时才采用。
  • 认证之后、执行 Tool 之前,继续检查 Tenant、Subject、对象所有权、Purpose 和副作用策略。
  • 浏览器 CORS、mTLS、Workload Identity 和 Refresh Token 轮转是否需要,取决于客户端拓扑和 IdP 策略,不能写成所有部署的统一硬要求。
  • 将身份失败与授权失败分开测试和观测,同时避免泄露 Token 内容或敏感资源是否存在。

先画清信任拓扑

在选择授权流程或 SDK 之前,先列出 Principal 和 Token Audience:

text
用户或工作负载
    -> MCP Client / Host
    -> MCP Protected Resource
    -> 下游 API(可选)

对每条边界记录:

边界 Credential Subject 目标 Audience/Resource Server 决策
Client -> MCP 用户、工作负载或委托客户端 MCP Resource Identifier 认证,然后鉴权 Tool
MCP -> 下游 API 用户委托身份或服务身份 下游 API 只申请必要的窄能力
浏览器 -> Client 浏览器 Origin(如适用) 注册的 Redirect URI 应用浏览器控制,不推断对象权限

MCP Server 不能因为 Token 由熟悉的 IdP 签发就直接接受它。签名正确但面向另一个 API 的 Token,仍然不是本 Resource 的有效凭证。同样,只有 mcp.tools.read 的 Token 不能调用写操作,除非 Server 的策略明确允许。

Protected Resource Metadata 与发现

远程客户端需要知道哪个 Authorization Server 保护 MCP Resource。RFC 9728 定义了 Protected Resource Metadata。它只是发现文档,不是授权证明:

json
{
  "resource": "https://mcp.example.com",
  "authorization_servers": [
    "https://login.example.com"
  ],
  "scopes_supported": [
    "mcp.tools.read",
    "mcp.tools.execute"
  ],
  "bearer_methods_supported": ["header"],
  "resource_documentation": "https://docs.example.com/mcp"
}

实际端点和 Resource 值必须与部署采用的 MCP/OAuth Profile 一致。不要发布通配 Issuer,也不要声明策略引擎并未执行的 Scope。如果支持多个 Authorization Server,必须在接受 Token 前明确 Tenant 选择方式,以及 Issuer 与 Tenant 的映射规则。

发现流程也有失败模式:

  • 初始连接未认证时,攻击者可能替换发现地址;
  • Provider 可能返回另一个租户的端点;
  • Metadata 可以声明 Scope,却不能保证调用者真的拥有它;
  • 动态客户端注册若没有审批和生命周期管理,会制造无人管理的客户端。

应在配置中固定可信 Issuer 和 Resource Identifier。远程 Metadata 应作为受控注册流程的输入,而不是运行时权威。

Authorization Code 与 PKCE 解决什么问题

面向用户的公开客户端可以使用带 PKCE 的 Authorization Code 流程,将授权码交换绑定到只保存在客户端的 Verifier:

sequenceDiagram participant C as "MCP Client" participant R as "MCP Resource" participant A as "Authorization Server" participant U as "User" C->>R: 请求 Protected Metadata R-->>C: 返回 Issuer 与支持的 Resource C->>A: 发现授权端点 C->>C: 生成 Verifier 与 S256 Challenge C->>A: Authorization Request + State + Redirect URI A->>U: 用户认证并取得同意 A-->>C: Authorization Code C->>A: Code + Verifier A-->>C: Access Token C->>R: 使用 Bearer Token 发送 MCP 请求 R->>R: 校验 Token 并鉴权操作

PKCE 不能

  • 证明模型选对了 Tool;
  • 授予某个 Tenant 或对象的访问权限;
  • 阻止已经合法获得 Token 的受攻击客户端滥用该 Token;
  • 让 Bearer Token 自动具备发送者约束;
  • 替代精确 Redirect URI、State、Nonce 或 Token 校验。

对公开客户端使用 S256,保护 Verifier,并把 State 绑定到发起授权的浏览器会话;如果采用 OIDC 身份层,按照对应 Profile 使用 Nonce。机密客户端是否必须使用 PKCE,应由所采用的 Profile 与 Provider 策略决定,不要把不同 OAuth 部署的要求混写成单一绝对结论。

Token 校验应是窄而明确的契约

Resource Server 应把已验证 Token 转换为小型内部 Principal,而不是把原始 Claims 直接交给 Tool Handler:

typescript
type Principal = {
  subject: string;
  tenant: string;
  clientId?: string;
  scopes: ReadonlySet<string>;
  issuer: string;
  audience: string;
};

type TokenPolicy = {
  issuer: string;
  audience: string;
  algorithms: readonly string[];
  requiredScopes: readonly string[];
};

type VerifiedClaims = {
  sub?: unknown;
  iss?: unknown;
  aud?: unknown;
  exp?: unknown;
  nbf?: unknown;
  iat?: unknown;
  scope?: unknown;
  scp?: unknown;
  tid?: unknown;
  azp?: unknown;
  client_id?: unknown;
};

function toScopes(claims: VerifiedClaims): Set<string> {
  const value = claims.scope ?? claims.scp;
  if (typeof value === "string") return new Set(value.split(/\s+/).filter(Boolean));
  if (Array.isArray(value) && value.every((item) => typeof item === "string")) {
    return new Set(value);
  }
  return new Set();
}

function requirePrincipal(
  rawToken: string,
  policy: TokenPolicy,
  verifyJwt: (token: string, options: {
    issuer: string;
    audience: string;
    algorithms: readonly string[];
  }) => VerifiedClaims,
): Principal {
  const claims = verifyJwt(rawToken, {
    issuer: policy.issuer,
    audience: policy.audience,
    algorithms: policy.algorithms,
  });

  if (typeof claims.sub !== "string" || typeof claims.iss !== "string") {
    throw new Error("invalid_principal_claims");
  }

  const scopes = toScopes(claims);
  if (!policy.requiredScopes.every((scope) => scopes.has(scope))) {
    throw new Error("insufficient_scope");
  }

  const tenant = typeof claims.tid === "string" ? claims.tid : undefined;
  if (!tenant) throw new Error("tenant_context_required");

  return {
    subject: claims.sub,
    tenant,
    clientId: typeof claims.azp === "string"
      ? claims.azp
      : typeof claims.client_id === "string" ? claims.client_id : undefined,
    scopes,
    issuer: claims.iss,
    audience: policy.audience,
  };
}

verifyJwt 适配器必须使用可信 JWKS URI 做签名校验,拒绝允许列表之外的 Algorithm,并校验 issaud 或配置的 Resource Indicator,同时执行 expnbf 和可接受时钟偏差检查。用户委托操作通常还应要求 sub;如果没有可信的 Tenant 上下文,应拒绝请求。如果 Provider 使用 tid 之外的 Claim,应在认证适配层先归一化再调用该函数,不能使用占位租户。不同 Provider 可能使用 scpscope,也应在认证适配层统一,业务代码不应感知差异。

不要记录原始 Token、Authorization Header、Refresh Token 或完整 Claims。日志可以记录 Request ID、Issuer、Key ID、Token Hash 或脱敏 Subject、决策和策略版本。

JWKS 轮转不能通过 Fail Open 解决

JWKS 缓存可以降低延迟和依赖负载,却会在密钥轮转时产生短暂同步窗口。可靠的缓存应满足四点:

  1. 遵守 Provider 的 Cache-Control 指示,同时设置应用层最大缓存时间;
  2. 过期后刷新,并合并并发刷新请求;
  3. 遇到未知 kid 时,在分布式冷却机制下执行一次有界刷新;
  4. 如果密钥仍未知或验证仍失败,拒绝 Token。

不能在密钥端点不可用时接受未验证 Token。应观测缓存年龄、刷新错误、未知 Key 数量和验证失败。Provider SDK 可能已经实现安全的 Key 选择和缓存逻辑,封装前先确认其刷新语义。

Scope 之外仍需要对象级授权

Scope 只描述粗粒度能力,Tool Policy 必须把它收窄到本次请求:

typescript
type ToolCall = {
  name: string;
  resourceId?: string;
  arguments: Record<string, unknown>;
  sideEffect: "none" | "external_write" | "destructive";
};

type AuthorizationContext = {
  principal: Principal;
  tenant: string;
  requestId: string;
};

function authorizeTool(
  call: ToolCall,
  context: AuthorizationContext,
  policy: {
    requiredScope: string;
    authorizeResource: (tenant: string, subject: string, resourceId: string) => boolean;
    allowSideEffect: (subject: string, name: string) => boolean;
  },
): "allow" | "deny" | "confirm" {
  if (!context.principal.scopes.has(policy.requiredScope)) return "deny";
  if (context.principal.tenant !== context.tenant) return "deny";

  if (call.resourceId &&
      !policy.authorizeResource(context.tenant, context.principal.subject, call.resourceId)) {
    return "deny";
  }

  if (call.sideEffect === "destructive") return "confirm";
  if (call.sideEffect === "external_write" &&
      !policy.allowSideEffect(context.principal.subject, call.name)) {
    return "deny";
  }
  return "allow";
}

授权函数应查询权威的策略或 Resource Service,而不是相信模型传入的 tenant_idowner_id、价格、角色或 Resource ID。身份来自已验证 Principal,所有权来自数据库或策略引擎。Discovery 与执行应分离;只有当 Server 实际执行了区分,才值得使用不同 Scope。

下游委托访问:OBO 是 Provider 能力

当 MCP Tool 需要以用户身份调用下游 API 时,可以考虑委托 Token 交换。Microsoft Entra 的 On-Behalf-Of 是一种 Provider 实现;其他 Provider 可能支持不同的 Token Exchange Profile,也可能完全不支持委托。

可先在两种模式中做选择:

需求 Credential 主要风险
用户拥有的资源 Provider 支持的用户委托 Token Confused Deputy 与同意范围漂移
服务拥有的操作 Workload Identity 或 Client Credential 服务权限过大

无论选择哪种模式,都应:

  • 将下游 Audience 和 Scope 固定为服务端允许列表;
  • 不允许模型选择目标 Audience 或 Scope;
  • 在交换 Token 前检查 MCP Principal 和对象授权;
  • 只缓存短生命周期下游 Token,并以受保护的 Subject 与 Scope 摘要作为键;
  • 从日志中脱敏 Assertion 和下游 Token;
  • 传播原始 Subject 与 Request ID 以支持审计;
  • 拒绝试图改变当前授权决策的下游响应。

OBO 不能自动降低上游同意过大的权限,也不能证明下游对象属于当前用户;下游 API 仍需执行自己的授权。

浏览器、原生客户端与工作负载

客户端拓扑会改变控制重点:

客户端 重点控制
浏览器公开客户端 PKCE、精确 Redirect URI、State/Nonce、明确 CORS Origin、浏览器代码不放 Secret
原生桌面客户端 PKCE、声明式或 Loopback Redirect、OS 保护的 Token 存储
后端服务 Workload Identity 或机密客户端认证、Secret/Key 轮转、Egress Policy
本地 stdio 客户端 OS 进程隔离、文件和网络权限限制;可能不需要远程 OAuth

CORS 只约束浏览器。mTLS 可以为服务间调用认证网络对端,并与 OAuth 组合使用,但证书不能替代 Tool 或对象授权。不要在没有检查客户端和代理兼容性的情况下,把 TLS 1.3 写成所有应用的硬性规则;应根据部署选择现代 TLS 和批准的密码套件。

运行控制与失败语义

身份失败和授权失败在内部应区分,外部响应则应尽量少泄露信息:

  • 401:缺少、格式错误、过期或无效的 Access Token;
  • 403:Principal 有效,但缺少 Scope 或被策略拒绝;
  • 409 或领域错误:Resource Version 或幂等冲突;
  • 429:限流,返回不泄露敏感状态的重试信息。

限流维度不能只有 IP,还可能需要 Principal、Client、Tenant、Tool 和下游依赖的独立预算。Discovery、Token Exchange、初始化、Tool Call 和大结果下载应分别保护。取消请求时应尽可能停止下游工作;重试只能用于副作用已知安全的操作。

可观测指标包括:

  • 按 Issuer 和失败类别统计认证结果;
  • JWKS 缓存年龄、刷新延迟和未知 Key 数量;
  • 按策略版本和 Tool 统计授权决策,不记录原始参数;
  • 下游交换成功率和延迟;
  • 限流和取消数量;
  • 跨租户拒绝与重复无效 Token 模式。

不要在文章中给出适用于所有部署的告警阈值,应在固定策略版本的预发布回放中建立基线。

生产测试矩阵

身份边界和能力边界要分开测试:

场景 预期结果
错误 Issuer 或 Audience 在 Tool 执行前拒绝
禁止的 Algorithm 或未知 kid 拒绝,最多一次有界 JWKS 刷新
过期或尚未生效的 Token 拒绝,不返回敏感细节
Token 有效但缺少执行 Scope 拒绝,不调用 Tool
Scope 有效但访问其他 Tenant 的对象 拒绝且不泄露对象存在性
模型修改目标 Audience 或下游 Scope 忽略模型值,使用服务端策略
浏览器来自未注册 Origin 浏览器边界拒绝,Server 认证仍是权威
相同幂等键重复写入 返回已有结果或安全冲突
JWKS 刷新时 Provider 不可用 Fail Closed,并记录依赖错误指标
下游请求中途取消 按操作契约停止或补偿

在固定策略版本和合成 Token 的可回放环境中运行这些用例。Provider 集成测试可以覆盖 Azure、Okta 或其他实现差异,但核心授权测试不应绑定某一个 IdP。

上线迁移清单

  1. 定义 Protected Resource Identifier、可信 Issuer、Tenant 映射和支持的客户端拓扑。
  2. 只发布 Server 实际执行的发现 Metadata 与 Scope。
  3. 为公开客户端实现 PKCE、Redirect 和 State/Nonce 处理。
  4. 校验签名、Algorithm、Issuer、Audience/Resource、时间 Claims、Subject 和 Provider Scope Claims。
  5. 增加对象级授权、租户隔离、副作用确认和幂等。
  6. 配置有界 JWKS 刷新,并在依赖故障时 Fail Closed。
  7. 有意识地选择下游委托访问或 Workload Identity。
  8. 从日志和 Trace 中脱敏 Token 与敏感 Claims。
  9. 在负载测试和开放高影响 Tool 前完成失败矩阵。
  10. 升级 SDK 或 MCP 规范前重新核对 Provider 文档和固定版本。

常见问题

所有远程 MCP 部署都必须使用 OAuth 吗?

不必。必须有可验证的身份和授权边界。多客户端、用户同意和委托访问场景适合 OAuth;私有服务网格可以使用 Workload Identity 或 mTLS,但 Server 仍需执行应用层策略。

OAuth 2.1 能让 MCP Tool 调用天然安全吗?

不能。OAuth 保护授权流程并携带 Claims,Server 还必须鉴权精确的 Tool、Tenant、Resource、Purpose 和副作用。模型不能通过在参数中填写另一个 Subject 或 Owner 来授予自己权限。

什么时候应该使用 OBO?

只有当 IdP 和下游 API 支持兼容的委托交换,而且用户确实需要在下游边界使用自己的权限时才使用。否则应选择窄权限的 Workload Identity 和明确的服务策略。

JWKS 轮转如何处理?

遵守缓存响应头并限制缓存年龄;未知 kid 时在分布式冷却机制下刷新一次,验证仍失败就拒绝。不要 Fail Open。

每个 MCP Server 都需要 CORS 吗?

不需要。CORS 只对浏览器客户端重要;原生、本地进程和后端客户端需要自己的传输与凭证控制。在所有拓扑中,CORS 和 mTLS 都是补充边界,Token 与对象授权才是权威。

结语

企业 OAuth 集成的目标,是让权威关系清晰可审计:可信 Issuer 认证 Principal,Resource Server 验证凭证,应用策略决定这次精确 MCP 调用是否允许。分离这些职责,才能更容易处理 Provider 迁移、事故响应、下游委托和安全测试,也能避免 Agent 系统中最危险的捷径:把有效 Token 当成模型想做任何事情的许可。

一手来源