一、Scope 是什么
Scope(权限范围)是 OAuth 2.0(Open Authorization 2.0)授权框架中用于限定访问令牌(Access Token)权限边界 的机制。它回答的核心问题不是"你是谁",而是"这个令牌被允许做什么"。
OAuth 2.0 的核心思想是「最小权限授权」:第三方应用不应该拿到用户的密码,也不应该拿到无限权限的令牌,而只应拿到完成特定任务所必需的那部分权限。Scope 正是这部分权限的形式化描述。
传统模式:第三方拿到密码 → 无限权限,无法撤销,风险极高
OAuth 模式:第三方拿到受限令牌 → 权限由 scope 圈定,可独立撤销
需要厘清三个相邻概念的边界:
| 概念 | 回答的问题 | 归属阶段 |
|---|---|---|
| Authentication(认证) | 你是谁 | 由 OIDC(OpenID Connect)的 id_token 承载 |
| Authorization(授权) | 你被允许做什么 | 由 scope + 后端策略共同决定 |
| Scope(范围) | 令牌「请求/被授予」了哪些权限类别 | 贯穿授权流程 |
一个关键认知:Scope 不是完整的授权决策,它只是授权决策的输入之一。这一点是后文设计部分的基石。
二、Scope 在协议中的位置与流转
Scope 在 RFC 6749(OAuth 2.0 核心规范)中以一个空格分隔的字符串形式存在,区分大小写。
2.1 完整流转链路
以授权码模式(Authorization Code Flow)为例,scope 在四个节点出现:
资源服务器(Resource Server) 授权服务器(Authorization Server) 客户端(Client) 用户(Resource Owner) 资源服务器(Resource Server) 授权服务器(Authorization Server) 客户端(Client) 用户(Resource Owner) #mermaid-svg-8uvO6fEpQC3mUCU6{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-8uvO6fEpQC3mUCU6 .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-8uvO6fEpQC3mUCU6 .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-8uvO6fEpQC3mUCU6 .error-icon{fill:#552222;}#mermaid-svg-8uvO6fEpQC3mUCU6 .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-8uvO6fEpQC3mUCU6 .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-8uvO6fEpQC3mUCU6 .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-8uvO6fEpQC3mUCU6 .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-8uvO6fEpQC3mUCU6 .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-8uvO6fEpQC3mUCU6 .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-8uvO6fEpQC3mUCU6 .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-8uvO6fEpQC3mUCU6 .marker{fill:#333333;stroke:#333333;}#mermaid-svg-8uvO6fEpQC3mUCU6 .marker.cross{stroke:#333333;}#mermaid-svg-8uvO6fEpQC3mUCU6 svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-8uvO6fEpQC3mUCU6 p{margin:0;}#mermaid-svg-8uvO6fEpQC3mUCU6 .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-8uvO6fEpQC3mUCU6 text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-8uvO6fEpQC3mUCU6 .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-8uvO6fEpQC3mUCU6 .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-8uvO6fEpQC3mUCU6 .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-8uvO6fEpQC3mUCU6 .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-8uvO6fEpQC3mUCU6 #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-8uvO6fEpQC3mUCU6 .sequenceNumber{fill:white;}#mermaid-svg-8uvO6fEpQC3mUCU6 #sequencenumber{fill:#333;}#mermaid-svg-8uvO6fEpQC3mUCU6 #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-8uvO6fEpQC3mUCU6 .messageText{fill:#333;stroke:none;}#mermaid-svg-8uvO6fEpQC3mUCU6 .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-8uvO6fEpQC3mUCU6 .labelText,#mermaid-svg-8uvO6fEpQC3mUCU6 .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-8uvO6fEpQC3mUCU6 .loopText,#mermaid-svg-8uvO6fEpQC3mUCU6 .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-8uvO6fEpQC3mUCU6 .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-8uvO6fEpQC3mUCU6 .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-8uvO6fEpQC3mUCU6 .noteText,#mermaid-svg-8uvO6fEpQC3mUCU6 .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-8uvO6fEpQC3mUCU6 .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-8uvO6fEpQC3mUCU6 .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-8uvO6fEpQC3mUCU6 .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-8uvO6fEpQC3mUCU6 .actorPopupMenu{position:absolute;}#mermaid-svg-8uvO6fEpQC3mUCU6 .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-8uvO6fEpQC3mUCU6 .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-8uvO6fEpQC3mUCU6 .actor-man circle,#mermaid-svg-8uvO6fEpQC3mUCU6 line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-8uvO6fEpQC3mUCU6 :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 1. 授权请求 scope=read:profile write:posts2. 展示同意页(基于请求的 scope)3. 用户授权(可能裁剪部分 scope)4. 返回授权码 code5. code 换取令牌6. 返回 token + 实际授予的 scope7. 携带 Access Token 访问 API8. 校验 token 中的 scope 是否覆盖该 API9. 返回数据 或 403 insufficient_scope
2.2 四个关键节点
节点 1:请求 scope(客户端发起)
GET /authorize?
response_type=code
&client_id=s6BhdRkqt3
&scope=read:profile%20write:posts
&redirect_uri=https://app.example.com/cb
&state=xyz
节点 3:用户裁剪(重要但常被忽略)
用户在同意页(Consent Screen)有权只勾选部分 scope。因此请求的 scope ≠ 授予的 scope。
节点 6:返回实际授予的 scope(RFC 6749 §3.3)
如果授予的 scope 与请求的不一致,授权服务器必须 在令牌响应中返回 scope 字段告知客户端实际范围:
json
{
"access_token": "...",
"token_type": "Bearer",
"expires_in": 3600,
"scope": "read:profile"
}
这意味着客户端代码不能假设 自己请求的 scope 全部到手,必须读取响应中的 scope 做降级处理。
节点 8:资源服务器校验
权限不足时按 RFC 6750(Bearer Token 规范)返回:
http
HTTP/1.1 403 Forbidden
WWW-Authenticate: Bearer error="insufficient_scope",
scope="write:posts",
error_description="The request requires write:posts"
三、Scope 的承载方式:JWT vs 内省
Scope 信息最终要被资源服务器读取,有两种主流方式:
3.1 JWT(JSON Web Token)自包含
scope 直接编码进 Access Token 的 payload:
json
{
"sub": "user-12345",
"aud": "https://api.example.com",
"iss": "https://auth.example.com",
"exp": 1735689600,
"scope": "read:profile write:posts"
}
资源服务器本地验签即可读取 scope,无需远程调用------性能好,但 scope 在令牌有效期内无法实时撤销。
注意字段命名差异:RFC 9068(JWT Profile for Access Tokens)规定用
scope(单数、空格分隔字符串);但部分实现(如旧版 Spring Authorization Server)使用scp(数组)。设计时需统一约定。
3.2 Token Introspection(令牌内省,RFC 7662)
不透明令牌(Opaque Token)本身不含信息,资源服务器需回调授权服务器查询:
http
POST /introspect
Authorization: Basic <client_credentials>
token=mF_9.B5f-4.1JqM
json
{
"active": true,
"scope": "read:profile write:posts",
"sub": "user-12345",
"exp": 1735689600
}
可实时撤销,但每次都增加一跳延迟。
| 维度 | JWT 自包含 | 内省 |
|---|---|---|
| 校验性能 | 本地,快 | 远程调用,慢 |
| 实时撤销 | 难(需配黑名单) | 易 |
| scope 一致性 | 令牌签发时冻结 | 实时反映授权服务器状态 |
四、Scope 的命名与粒度设计
这是整篇文章的核心。Scope 设计本质上是一次 API 权限模型的领域建模,设计不当会导致后期权限体系僵化、难以演进。
4.1 命名风格
业界主流是 资源:操作 的二段式风格(Google、GitHub 普遍采用):
read:profile # 读取用户资料
write:posts # 创建/编辑文章
delete:comments # 删除评论
admin:billing # 账单管理
也有 服务.资源.操作 三段式(更适合大型多服务平台):
crm.contacts.read
crm.contacts.write
billing.invoices.export
设计原则:
- 可读性:用户在同意页要能看懂,所以 scope 名应当能直接映射成人话描述。
- 稳定性:scope 一旦发布并被第三方集成,几乎不可删除(破坏向后兼容),因此命名要慎重、预留演进空间。
- 一致性:动词、单复数、分隔符在整个体系内统一。
4.2 粒度:粗 vs 细的权衡
这是最难的决策点。
过粗(如只有 read / write):
✗ 违背最小权限,第三方拿到 write 就能改一切
✓ 同意页简单,集成方便
过细(如 read:profile:email、read:profile:phone......):
✓ 权限精确
✗ 同意页爆炸,用户疲劳(同意疲劳 consent fatigue)
✗ 第三方需请求一长串 scope,集成复杂
推荐的中庸策略------按「业务能力」而非「数据库表」划分:
| 反模式(按表/字段) | 推荐(按业务能力) |
|---|---|
read:user_table |
read:profile |
read:email_column |
read:contact_info |
write:posts_table write:post_tags_table |
write:posts |
经验法则:一个 scope 应对应用户能理解的一种「能力」,而不是一张表或一个 endpoint。
4.3 读写分离
强烈建议至少在读/写维度拆分。这是性价比最高的粒度划分:大量第三方(如数据分析、展示类应用)只需读权限,读写分离能显著降低用户授权的心理负担和泄露风险。
read:* → 只读类应用申请,风险低,同意页可弱化
write:* → 写入类需单独申请,同意页应强提示
delete:* → 删除类建议进一步独立,因其不可逆
五、Scope 与 RBAC/ABAC 的协作
一个常见的严重误区:把 scope 当成完整的权限系统。
核心原则:Scope 圈定的是「这个令牌最多能做什么」,而不是「这个用户实际能做什么」。最终授权 = Scope ∩ 用户实际权限(RBAC/ABAC)。
举例说明这个交集关系:
某令牌 scope = write:posts (客户端被授予了写文章的能力)
但该用户角色 = 普通读者 (RBAC 角色不允许写)
最终结果:拒绝。
因为:scope 只是「客户端被允许代表用户做的事的上界」,
它永远不能突破用户本身的权限。
正确的分层校验:
#mermaid-svg-pUcBcLKdtBCFnMu5{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-pUcBcLKdtBCFnMu5 .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-pUcBcLKdtBCFnMu5 .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-pUcBcLKdtBCFnMu5 .error-icon{fill:#552222;}#mermaid-svg-pUcBcLKdtBCFnMu5 .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-pUcBcLKdtBCFnMu5 .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-pUcBcLKdtBCFnMu5 .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-pUcBcLKdtBCFnMu5 .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-pUcBcLKdtBCFnMu5 .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-pUcBcLKdtBCFnMu5 .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-pUcBcLKdtBCFnMu5 .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-pUcBcLKdtBCFnMu5 .marker{fill:#333333;stroke:#333333;}#mermaid-svg-pUcBcLKdtBCFnMu5 .marker.cross{stroke:#333333;}#mermaid-svg-pUcBcLKdtBCFnMu5 svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-pUcBcLKdtBCFnMu5 p{margin:0;}#mermaid-svg-pUcBcLKdtBCFnMu5 .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-pUcBcLKdtBCFnMu5 .cluster-label text{fill:#333;}#mermaid-svg-pUcBcLKdtBCFnMu5 .cluster-label span{color:#333;}#mermaid-svg-pUcBcLKdtBCFnMu5 .cluster-label span p{background-color:transparent;}#mermaid-svg-pUcBcLKdtBCFnMu5 .label text,#mermaid-svg-pUcBcLKdtBCFnMu5 span{fill:#333;color:#333;}#mermaid-svg-pUcBcLKdtBCFnMu5 .node rect,#mermaid-svg-pUcBcLKdtBCFnMu5 .node circle,#mermaid-svg-pUcBcLKdtBCFnMu5 .node ellipse,#mermaid-svg-pUcBcLKdtBCFnMu5 .node polygon,#mermaid-svg-pUcBcLKdtBCFnMu5 .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-pUcBcLKdtBCFnMu5 .rough-node .label text,#mermaid-svg-pUcBcLKdtBCFnMu5 .node .label text,#mermaid-svg-pUcBcLKdtBCFnMu5 .image-shape .label,#mermaid-svg-pUcBcLKdtBCFnMu5 .icon-shape .label{text-anchor:middle;}#mermaid-svg-pUcBcLKdtBCFnMu5 .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-pUcBcLKdtBCFnMu5 .rough-node .label,#mermaid-svg-pUcBcLKdtBCFnMu5 .node .label,#mermaid-svg-pUcBcLKdtBCFnMu5 .image-shape .label,#mermaid-svg-pUcBcLKdtBCFnMu5 .icon-shape .label{text-align:center;}#mermaid-svg-pUcBcLKdtBCFnMu5 .node.clickable{cursor:pointer;}#mermaid-svg-pUcBcLKdtBCFnMu5 .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-pUcBcLKdtBCFnMu5 .arrowheadPath{fill:#333333;}#mermaid-svg-pUcBcLKdtBCFnMu5 .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-pUcBcLKdtBCFnMu5 .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-pUcBcLKdtBCFnMu5 .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-pUcBcLKdtBCFnMu5 .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-pUcBcLKdtBCFnMu5 .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-pUcBcLKdtBCFnMu5 .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-pUcBcLKdtBCFnMu5 .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-pUcBcLKdtBCFnMu5 .cluster text{fill:#333;}#mermaid-svg-pUcBcLKdtBCFnMu5 .cluster span{color:#333;}#mermaid-svg-pUcBcLKdtBCFnMu5 div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-pUcBcLKdtBCFnMu5 .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-pUcBcLKdtBCFnMu5 rect.text{fill:none;stroke-width:0;}#mermaid-svg-pUcBcLKdtBCFnMu5 .icon-shape,#mermaid-svg-pUcBcLKdtBCFnMu5 .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-pUcBcLKdtBCFnMu5 .icon-shape p,#mermaid-svg-pUcBcLKdtBCFnMu5 .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-pUcBcLKdtBCFnMu5 .icon-shape .label rect,#mermaid-svg-pUcBcLKdtBCFnMu5 .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-pUcBcLKdtBCFnMu5 .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-pUcBcLKdtBCFnMu5 .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-pUcBcLKdtBCFnMu5 :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 否
是
否
是
否
是
请求到达 API
Token 有效?
401 Unauthorized
Scope 覆盖该操作?
403 insufficient_scope
用户 RBAC/ABAC 允许?
403 Forbidden
放行
两层是**与(AND)**关系,缺一不可。scope 通过不代表业务授权通过。
六、特殊场景下的 Scope
6.1 OIDC 标准 scope
OIDC 在 OAuth 之上定义了一组保留 scope,用于身份认证:
| Scope | 含义 |
|---|---|
openid |
必需 ,声明这是一次 OIDC 请求,触发返回 id_token |
profile |
请求姓名、头像等基本资料 claim |
email |
请求邮箱及验证状态 |
offline_access |
请求返回 Refresh Token(刷新令牌) |
offline_access 尤其关键:它是「是否颁发 Refresh Token」的开关,决定了客户端能否在用户离线时持续访问。
6.2 客户端凭证模式(Client Credentials)
机器对机器(M2M)场景无用户参与,没有同意页。此时 scope 由授权服务器根据客户端注册时预配置的权限直接裁定,客户端请求的 scope 不能超出注册时的上限。
6.3 增量授权(Incremental Authorization)
不要在首次登录就索要全部 scope。最佳实践是用到时再要:
首次登录:scope=openid profile (仅登录所需)
用户点击"导出到云盘"时:再发起 scope=write:drive 的授权
这能显著提升首屏授权转化率,也更符合最小权限原则。Google 的 API 即采用此模式。
6.4 Resource Indicators(RFC 8707)
当一个授权服务器服务多个资源服务器(API)时,相同名字的 scope 可能在不同 API 下含义不同。RFC 8707 引入 resource 参数,让客户端声明令牌的目标受众(audience),授权服务器据此签发受众绑定的窄令牌,避免一个令牌被滥用于多个 API:
&scope=read:data
&resource=https://api-a.example.com
七、设计 Checklist(落地清单)
把前述原则浓缩为一份可执行清单:
- 命名 :确定统一风格(
资源:操作),动词/单复数/分隔符全局一致。 - 粒度:按「业务能力」而非数据表划分;至少做读写分离;删除类独立。
- 最小化:客户端按需申请;首登只要登录 scope,敏感能力走增量授权。
- 同意页:每个 scope 提供人话描述;写/删类强提示。
- 不要信任请求 scope :客户端必须读取令牌响应中的实际
scope并降级处理。 - 双层校验:资源服务器先查 scope(403 insufficient_scope),再查 RBAC/ABAC,二者为 AND。
- 承载方式 :JWT 自包含 vs 内省,按「性能 vs 实时撤销」需求选择;统一
scope字段格式。 - 演进:scope 几乎只增不减,发布即承诺,命名预留空间。
- M2M:客户端凭证模式下 scope 受注册上限约束。
- 多 API:用 Resource Indicators 做受众绑定,防止令牌跨 API 滥用。
八、总结
Scope 的设计看似只是定义几个字符串,实则是在为整个平台的授权语义 和第三方生态奠定基础。三个最值得记住的结论:
- Scope 是授权的上界,不是授权本身------它必须与 RBAC/ABAC 取交集才构成完整决策。
- 粒度按业务能力划分 ------既非粗到
read/write,也非细到字段级,而是用户能理解的「能力」单位。 - 请求 ≠ 授予------用户和授权服务器都可能裁剪 scope,客户端必须以令牌响应为准。
设计良好的 scope 体系,应当让用户在同意页一眼看懂自己授予了什么,让第三方能精确申请所需的最小权限,让资源服务器能高效校验,并能随业务平滑演进。