企业 MCP 部署首先要回答两个不同的问题:
- 谁在发起请求,哪个 Authorization Server 颁发了身份凭证?
- 这个 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:
用户或工作负载
-> 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。它只是发现文档,不是授权证明:
{
"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:
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:
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,并校验 iss、aud 或配置的 Resource Indicator,同时执行 exp、nbf 和可接受时钟偏差检查。用户委托操作通常还应要求 sub;如果没有可信的 Tenant 上下文,应拒绝请求。如果 Provider 使用 tid 之外的 Claim,应在认证适配层先归一化再调用该函数,不能使用占位租户。不同 Provider 可能使用 scp 或 scope,也应在认证适配层统一,业务代码不应感知差异。
不要记录原始 Token、Authorization Header、Refresh Token 或完整 Claims。日志可以记录 Request ID、Issuer、Key ID、Token Hash 或脱敏 Subject、决策和策略版本。
JWKS 轮转不能通过 Fail Open 解决
JWKS 缓存可以降低延迟和依赖负载,却会在密钥轮转时产生短暂同步窗口。可靠的缓存应满足四点:
- 遵守 Provider 的
Cache-Control指示,同时设置应用层最大缓存时间; - 过期后刷新,并合并并发刷新请求;
- 遇到未知
kid时,在分布式冷却机制下执行一次有界刷新; - 如果密钥仍未知或验证仍失败,拒绝 Token。
不能在密钥端点不可用时接受未验证 Token。应观测缓存年龄、刷新错误、未知 Key 数量和验证失败。Provider SDK 可能已经实现安全的 Key 选择和缓存逻辑,封装前先确认其刷新语义。
Scope 之外仍需要对象级授权
Scope 只描述粗粒度能力,Tool Policy 必须把它收窄到本次请求:
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_id、owner_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。
上线迁移清单
- 定义 Protected Resource Identifier、可信 Issuer、Tenant 映射和支持的客户端拓扑。
- 只发布 Server 实际执行的发现 Metadata 与 Scope。
- 为公开客户端实现 PKCE、Redirect 和 State/Nonce 处理。
- 校验签名、Algorithm、Issuer、Audience/Resource、时间 Claims、Subject 和 Provider Scope Claims。
- 增加对象级授权、租户隔离、副作用确认和幂等。
- 配置有界 JWKS 刷新,并在依赖故障时 Fail Closed。
- 有意识地选择下游委托访问或 Workload Identity。
- 从日志和 Trace 中脱敏 Token 与敏感 Claims。
- 在负载测试和开放高影响 Tool 前完成失败矩阵。
- 升级 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 当成模型想做任何事情的许可。