OAuth 2.0 深入理解:授权码、令牌、PKCE、JWT、OIDC 与开放平台实践

OAuth 2.0 的价值并不只是“生成一个 Token”,而是建立一套能够在不向第三方暴露用户凭据的前提下,将有限资源访问权限安全委托出去的授权体系。本文从 OAuth 2.0 为什么出现开始,系统梳理四种角色、授权码流程、Scope、访问令牌与刷新令牌、JWT、PKCE、OIDC、SSO 以及开放平台架构,并结合当前安全最佳实践说明哪些早期授权模式已经不应继续使用。

为什么需要 OAuth 2.0

理解 OAuth 2.0,最重要的不是先背 response_typegrant_typeclient_id 这些参数,而是先回答一个问题:

为什么不能直接把用户名和密码交给第三方应用?

假设一个商家在电商平台拥有自己的订单数据,又购买了一套第三方打单软件。打单软件需要读取商家的订单才能完成打印,但订单属于商家,并不属于打单软件。

最直接的方案似乎是:

1
2
3
4
5
6
7
8
9
商家
|
| 用户名 + 密码
v
第三方打单软件
|
| 用户名 + 密码
v
电商开放平台 API

这个方案能工作,却带来了非常严重的问题。

第三方应用获得的是用户完整凭据,而不是完成某项业务所需要的有限权限。一旦第三方应用数据库、日志、服务器或研发流程中的某个环节泄露凭据,攻击者获得的可能不是“查询订单”这一项权限,而是整个用户账户。

而且每调用一个订单 API、商品 API、用户 API,都携带用户名和密码,也意味着敏感凭据在更多系统、更多网络链路和更多日志之间流动,攻击面会迅速扩大。

OAuth 2.0 的思路是把这种关系改成:

1
2
3
4
5
6
7
8
9
10
11
12
13
用户名 + 密码
|
| 只交给平台自己的认证系统
v
Authorization Server
|
| 颁发有限权限 Token
v
第三方应用
|
| Access Token
v
Resource Server

第三方应用不再持有用户密码,而是持有一个权限有限、生命周期有限、可以撤销的访问凭据

这个凭据就是 Access Token。

因此,与其把 OAuth 2.0 简单理解成“Token 登录”,不如记住一个更准确的定义:

OAuth 2.0 是一个授权框架,用于让资源拥有者把对受保护资源的一部分访问权限委托给客户端,而无需把自己的凭据交给客户端。

它解决的核心问题是 Delegated Authorization(委托授权)

OAuth 2.0 关注的是:

1
这个应用可不可以访问这些资源?

而不是:

1
当前登录的人到底是谁?

后一个问题属于 Authentication(身份认证),后面介绍 OIDC 时还会回到这个区别。


OAuth 2.0 的四个核心角色

OAuth 2.0 的整个模型建立在几个逻辑角色之上。课程资料中经常使用“小明、小兔软件、开放平台、订单服务”帮助理解,转换成标准术语后大致如下。

OAuth 角色 含义 电商场景
Resource Owner 有权授予资源访问权限的主体 商家小明
Client 希望访问资源的应用 小兔打单软件
Authorization Server 负责授权并签发 Token 开放平台授权服务
Resource Server 保存并提供受保护资源 订单服务

这里还有一个容易混淆的地方:

Protected Resource(受保护资源)本身并不是一个服务器角色。

例如:

1
2
3
4
5
订单
商品
用户资料
照片
文件

这些才是 Resource。

真正提供这些资源 API 的系统是 Resource Server。

在生产环境中,Authorization Server 和 Resource Server 通常也是不同服务:

1
2
3
4
5
6
7
8
9
                 +----------------------+
| Authorization Server |
+----------------------+
|
| Access Token
v
+--------+ +-------------+ +-----------------+
| User | -------> | Client | -------> | Resource Server |
+--------+ +-------------+ +-----------------+

OAuth 2.0 真正的核心:颁发令牌和使用令牌

OAuth 2.0 中存在不少参数和流程,但最终可以压缩成两个动作:

1
2
3
获取 Access Token

使用 Access Token

第三方应用真正需要的不是 Authorization Code,也不是 client_secret,最终目标始终是 Access Token。

例如:

1
2
3
GET /api/orders HTTP/1.1
Host: api.example.com
Authorization: Bearer eyJ...

Resource Server 根据 Token 判断:

1
2
3
4
5
6
7
8
9
Token 是否有效?

是谁获得了这个 Token?

允许访问什么资源?

允许执行什么操作?

最终返回数据

这也是为什么 Token 可以看作授权结果的载体。


Authorization Code:为什么还需要一个授权码

授权码模式最容易让人困惑的问题是:

用户已经同意授权了,Authorization Server 为什么不直接把 Access Token 返回给 Client?

答案的关键,不只是“两次重定向”,而是 Front Channel 和 Back Channel 的隔离

在典型 Web 授权过程中,存在两条完全不同的通信路径。

第一条是经过浏览器或其他 User Agent 的前端通道:

1
2
3
4
5
Client

Browser

Authorization Server

第二条是服务器之间的后端通道:

1
2
3
Client Backend

Authorization Server

Access Token 是可以直接调用资源 API 的凭据,因此不应该在浏览器跳转 URL 中到处流动。

Authorization Code 就成为了中间凭据:

1
2
3
4
5
6
7
用户授权

Authorization Code

Client Backend

使用 Code 换 Access Token

授权码本身具有几个非常重要的属性:

1
2
3
4
5
6
生命周期很短
只能使用一次
与 Client 绑定
与用户授权结果绑定
与 Scope 绑定
通常还与 Redirect URI / PKCE 事务绑定

所以,即便它短暂经过浏览器,其风险也明显低于直接让 Access Token 出现在授权响应中。


授权码模式完整流程

将整个过程串起来,可以得到下面的时序。

sequenceDiagram
    actor User as Resource Owner
    participant Client as Client
    participant AS as Authorization Server
    participant RS as Resource Server

    User->>Client: 访问第三方应用
    Client-->>User: 重定向到 Authorization Endpoint
    User->>AS: 登录并确认授权
    AS-->>User: 重定向回 Callback,携带 code
    User->>Client: 请求 callback?code=...
    Client->>AS: 使用 code 请求 Token
    AS-->>Client: access_token / refresh_token
    Client->>RS: Authorization: Bearer access_token
    RS-->>Client: 返回受保护资源

注意其中发生了两种通信:

1
2
3
4
Authorization Endpoint
Client <-> Browser <-> Authorization Server

Front Channel

以及:

1
2
3
4
Token Endpoint
Client Backend <-> Authorization Server

Back Channel

Authorization Code 正是连接这两个阶段的临时凭据。


Authorization Server 内部到底做了什么

授权服务是整个 OAuth 2.0 体系里复杂度最高的组件。

它承担的大量工作可以分成两个阶段:

1
2
3
阶段一:颁发 Authorization Code

阶段二:用 Authorization Code 换 Access Token

Client 必须先完成注册

第三方应用参与 OAuth 流程之前,Authorization Server 首先得知道:

1
2
3
4
你是谁?
允许回调到哪里?
允许申请哪些权限?
你属于哪种 Client?

所以 Client 通常需要提前注册。

课程资料中使用:

1
2
3
4
app_id
app_secret
redirect_uri
scope

在 OAuth 标准术语中,更常见的是:

资料中的称呼 标准 OAuth 称呼
app_id client_id
app_secret client_secret
回调地址 redirect_uri
权限范围 scope

例如可以抽象成:

1
2
3
4
5
6
Map<String, String> client = new HashMap<>();

client.put("client_id", "RABBIT_CLIENT");
client.put("client_secret", "RABBIT_SECRET");
client.put("redirect_uri", "https://rabbit.example.com/oauth/callback");
client.put("scope", "order.read order.print");

生产环境当然不会这样用 Map 存储,这里的代码只是为了说明数据之间的关系。


阶段一:颁发 Authorization Code

Authorization Endpoint 收到请求后,首先进行一系列验证。

一个典型授权请求类似:

1
2
3
4
5
6
GET /authorize?
response_type=code&
client_id=rabbit-client&
redirect_uri=https://rabbit.example.com/oauth/callback&
scope=order.read&
state=RANDOM_STATE

现代 OAuth 通常还会增加 PKCE:

1
2
code_challenge=...
code_challenge_method=S256

校验 Client

Authorization Server 首先检查:

1
2
3
client_id 是否存在
Client 是否处于正常状态
redirect_uri 是否属于该 Client

redirect_uri 是 OAuth 安全体系里极其重要的一环。

否则攻击者完全可以构造:

1
redirect_uri=https://evil.example.com

诱导 Authorization Server 把 Code 发给攻击者。

当前 OAuth 安全最佳实践要求授权服务器对预注册 Redirect URI 使用严格的精确匹配,原生应用使用 localhost loopback redirect 时端口是特殊例外。


Scope:OAuth 中真正的权限边界

授权并不应该是:

1
2
允许
拒绝

这样简单的二元关系。

真正的授权应该回答:

1
2
3
4
允许访问什么?
允许执行什么动作?
允许访问哪些字段?
允许访问哪个用户的数据?

这就是 Scope 的作用。

例如 Client 在平台注册时拥有:

1
2
3
order.read
order.history.read
order.print

但用户实际可能只同意:

1
order.read

那么最终 Access Token 的权限必须是:

1
2
3
Client 可以申请的 Scope

用户实际同意的 Scope

而不能简单采用 Client 注册的全部权限。

可以理解成:

1
2
3
4
5
6
7
8
9
10
11
12
Registered Scope
|
| 取交集
v
Requested Scope
|
| 用户再次缩减
v
Granted Scope
|
v
Access Token

这体现的是典型的 Least Privilege(最小权限原则)


Scope 可以控制三个不同维度

权限设计并不只有“能不能访问某个 URL”。

课程资料把它总结成了三个非常有工程价值的维度。

不同权限对应不同操作

例如:

1
2
3
goods.read
goods.create
goods.delete

那么只有:

1
2
goods.read
goods.create

的 Token 就不能调用:

1
DELETE /goods/{id}

不同权限对应不同数据

即使调用同一个 API,也可能只允许访问部分字段。

例如用户资料 API 返回:

1
2
3
4
5
6
{
"nickname": "Mario",
"age": 30,
"email": "example@example.com",
"phone": "138****0000"
}

Scope 可以进一步拆成:

1
2
3
profile.basic
profile.email
profile.phone

于是 Token 即便有访问 User API 的权限,也不代表可以获得全部字段。


不同用户对应不同数据

这是企业系统中特别容易出现漏洞的一层。

即便用户 A 和用户 B 都拥有:

1
order.read

也绝不能出现:

1
2
3
用户 A 的 Token

查询用户 B 的订单

所以真实权限判断更接近:

1
2
3
4
5
Action Permission
+
Resource Permission
+
Resource Owner / Tenant Constraint

这也是 OAuth Scope 与 RBAC、ABAC、数据权限之间的关系:

OAuth 解决“授权如何被委托以及如何携带”,业务系统仍然需要完成真正的数据级权限控制。


用户确认之后生成 Code

用户点击“允许”之后,Authorization Server 才真正产生 Authorization Code。

服务端通常至少需要保存类似关系:

1
2
3
4
5
6
7
code
-> client_id
-> resource_owner
-> granted_scope
-> redirect_uri
-> expire_time
-> used

Code 必须短期有效,并且只能兑换一次。

兑换成功后应立即作废:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
AuthorizationCode authorizationCode = repository.find(code);

if (authorizationCode == null) {
throw new InvalidGrantException();
}

if (authorizationCode.isExpired()) {
throw new InvalidGrantException();
}

if (authorizationCode.isUsed()) {
throw new InvalidGrantException();
}

authorizationCode.markUsed();
repository.save(authorizationCode);

真实系统中还要考虑并发兑换问题,因此“查询再删除”不能只依赖普通业务代码,而需要数据库原子更新、Redis Lua、CAS 等机制保证 Code 真正只能消费一次。


阶段二:用 Code 换 Access Token

Client Backend 得到 Code 后,请求 Token Endpoint:

1
2
3
4
5
6
7
POST /token HTTP/1.1
Host: auth.example.com
Content-Type: application/x-www-form-urlencoded

grant_type=authorization_code&
code=AUTHORIZATION_CODE&
client_id=rabbit-client

对于能够安全保存凭据的 Confidential Client,还会进行 Client Authentication。

如果使用 PKCE,则还会携带:

1
code_verifier=...

Authorization Server 此时需要验证:

1
2
3
4
5
6
7
Client 是否合法
Authorization Code 是否存在
Code 是否过期
Code 是否已经使用
Code 是否属于当前 Client
PKCE 是否验证通过
授权范围是否合法

验证完成后才签发:

1
2
3
4
5
6
7
{
"access_token": "...",
"token_type": "Bearer",
"expires_in": 3600,
"refresh_token": "...",
"scope": "order.read"
}

Access Token、Refresh Token、Authorization Code、ID Token 不要混在一起

OAuth/OIDC 中存在多个 Token,它们用途完全不同。

凭据 主要使用方 发给谁 用在哪里 是否应该被 Client 解析
Authorization Code Client Client Token Endpoint
Access Token Client Resource Server Resource API 不应依赖其内部结构
Refresh Token Client Authorization Server Token Endpoint
ID Token OIDC Client / RP Client 身份认证

其中最重要的一条是:

Access Token 是访问资源的凭据,而不是用户登录状态。

它和传统 Web Session 的语义不同。


Access Token 应该怎么传

Bearer Token 最典型的使用方式是:

1
2
3
GET /api/orders HTTP/1.1
Host: api.example.com
Authorization: Bearer <access_token>

RFC 6750 曾定义三种携带方式:

1
2
3
Authorization Header
Form-Encoded Body
URI Query Parameter

早期资料中会看到通过表单提交 Access Token 的实现方式,但今天更稳妥、也更通用的工程实践是:

1
Authorization: Bearer <token>

RFC 6750 本身就规定 Client 应优先使用 Authorization Header,并要求 Resource Server 支持这种方式;表单 Body 只有满足特定条件时才能使用。

尤其不要再写:

1
https://api.example.com/orders?access_token=xxxxxx

Token 一旦出现在 URL 中,就可能进入:

1
2
3
4
5
6
Browser History
Web Server Access Log
Reverse Proxy Log
Referer
APM
监控平台

RFC 9700 已进一步明确要求 Client 不得通过 URI Query Parameter 传递 Access Token。


Refresh Token:解决 Access Token 生命周期与用户体验的矛盾

Access Token 不应该长期有效。

Token 生命周期越长:

1
2
3
4
5
被盗后的利用窗口

|
|
生命周期越长

但如果 Access Token 五分钟过期一次,每次都要求用户重新:

1
2
3
4
5
6
7
登录

确认授权

生成 Code

换 Token

用户基本可以直接卸载应用了。

Refresh Token 就是为解决这个矛盾而存在。

sequenceDiagram
    participant Client
    participant AS as Authorization Server
    participant RS as Resource Server

    Client->>RS: 使用 Access Token
    RS-->>Client: Token expired
    Client->>AS: refresh_token
    AS-->>Client: 新 Access Token
    Client->>RS: 使用新 Access Token
    RS-->>Client: 返回资源

Refresh Token 只应该提交给 Authorization Server:

1
2
3
Refresh Token

Token Endpoint

而不是:

1
2
3
Refresh Token

Resource Server

后者没有意义。


Refresh Token 并不意味着永久授权

一个很常见的误解是:

1
2
3
4
5
有 Refresh Token

永远能刷新 Access Token

永远不用重新授权

这并不成立。

Refresh Token 本身也存在生命周期和撤销机制。

更安全的实现还会使用 Refresh Token Rotation

1
2
3
4
5
6
7
8
Refresh Token A
|
| 使用
v
Access Token B
Refresh Token B

Refresh Token A -> 立即失效

这样一旦系统再次发现已经使用过的旧 Refresh Token:

1
Refresh Token A 再次出现

就可能意味着 Token 已经泄漏。

RFC 9700 对 Public Client 要求 Refresh Token 必须使用 Sender-Constrained 机制或者 Refresh Token Rotation,以降低 Refresh Token 被盗后的重放风险。

所以“刷新令牌每次一定都必须换一个”并不是 OAuth 2.0 最初规范对所有场景的统一要求,但 Rotation 已经成为非常重要的安全实践。


Token 还需要主动撤销

Token 并不总是自然过期。

例如:

1
2
3
4
5
6
用户注销账户
用户修改密码
管理员禁用账户
用户撤销第三方授权
第三方应用被封禁
发现 Token 泄露

这些事件发生后,平台通常不能继续等待 Access Token 自然过期。

开放平台可以采用事件驱动方式处理:

flowchart LR
    UC[用户中心] -->|修改密码 / 注销 / 禁用| MQ[消息队列]
    MQ --> AS[Authorization Server]
    AS --> TS[Token Store]
    AS --> RC[撤销相关 Token]

这类设计在大型开放平台中尤其重要。

因为 Authorization Server 虽然负责颁发 Token,却不能脱离用户生命周期和 Client 生命周期独立存在。


Opaque Token 与 JWT Token

Access Token 并没有被 OAuth 2.0 规定必须采用某一种具体格式。

常见实现大致分成两类:

1
2
Opaque Token
JWT / Structured Token

Opaque Token

例如:

1
d765b9bd-1a52-4e04-97ed-c6d98f1a76fb

Token 本身没有业务含义。

Resource Server 拿到它后,需要查询:

1
2
3
4
这个 Token 属于谁?
有没有过期?
有什么 Scope?
发给哪个 Client?

可以通过:

1
2
3
4
5
共享 Token Store

或者

Authorization Server Introspection

完成。

架构类似:

1
2
3
4
5
6
7
8
9
Client
|
| Access Token
v
Resource Server
|
| Introspection
v
Authorization Server

优点是 Token 状态高度可控。

例如立即撤销:

1
Token Store -> inactive

下一次调用即可失效。

代价则是 Resource Server 可能需要额外网络访问和缓存策略。


JWT:把部分状态放入 Token

JWT(JSON Web Token)是一种结构化 Token 格式。

典型结构:

1
HEADER.PAYLOAD.SIGNATURE

例如:

1
2
3
eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.
eyJzdWIiOiIxMDAwMSIsInNjb3BlIjoib3JkZXIucmVhZCJ9.
signature

三部分分别是:

1
2
3
4
5
6
7
8
HEADER
算法、Token 类型等元数据

PAYLOAD
Claims

SIGNATURE
完整性与来源验证

Payload 中可以放:

1
2
3
4
5
6
{
"sub": "10001",
"scope": "order.read",
"iat": 1786950000,
"exp": 1786953600
}

这样 Resource Server 就可以:

1
2
3
4
5
6
7
8
9
10
11
验证签名

检查 exp

检查 aud

读取 sub

读取 scope

完成授权

不必每次回查 Authorization Server。

这就是 JWT 最明显的架构收益:

1
2
3
Remote State Lookup

Local Cryptographic Verification

可以把它理解成一种用计算换取部分远程状态访问的设计。


JWT 最容易出现的误解:签名不是加密

这一点必须单独说。

一个普通的:

1
JWS Signed JWT

通常只是:

1
2
3
4
5
Base64URL(header)
+
Base64URL(payload)
+
Signature

Base64URL 不是加密。

任何拿到 JWT 的人通常都能解码 Header 和 Payload。

Signature 解决的是:

1
2
有没有被篡改?
是不是可信方签发?

而不是:

1
别人能不能看到 Payload?

所以:

1
2
JWS = 签名
JWE = 加密

不能混为一谈。

如果 Payload 中存在不能暴露给持有 Token 一方的信息,就不能因为使用了 JWT 就认为数据已经“加密”。

更重要的是,OAuth Client 对 Access Token 应当按照不透明凭据使用:

1
Client 不应该依赖 Access Token 的内部格式。

哪怕今天 Authorization Server 发的是 JWT:

1
Client -> parse access_token -> 依赖某个 claim

也不是一个好的设计。

因为 Authorization Server 明天完全可能切换到 Opaque Token。

Token 格式是 Authorization Server 与 Resource Server 之间的实现契约,不应该变成 Client 的业务契约。


JWT 的优势与“覆水难收”

JWT 的优势很明显:

1
2
3
4
5
6
7
Resource Server 本地验签

减少远程 Introspection

更容易横向扩容

服务之间耦合降低

但它也带来一个典型问题:

1
2
3
4
5
6
7
JWT 已签发

有效期 1 小时

用户 5 分钟后取消授权

Token 怎么立即失效?

这就是常说的:

JWT 的“覆水难收”。

典型处理方式包括:

1
2
3
4
5
6
缩短 Access Token 生命周期
引入 Token Revocation / Deny List
Key Rotation
增加 Token Version
使用 Reference Token
关键业务继续 Introspection

所以并不存在:

1
JWT 一定优于 Opaque Token

真正的问题应该是:

1
2
3
4
5
6
7
你的系统更需要:

本地验证和高扩展性?

还是:

即时撤销和集中状态控制?

四种经典 Grant 以及今天应该怎么理解

OAuth 2.0 RFC 6749 最初经常以四种经典 Grant 进行教学:

1
2
3
4
Authorization Code
Implicit
Resource Owner Password Credentials
Client Credentials

课程资料也围绕这四种方式进行了完整比较。

但这部分内容是最需要结合今天安全实践重新理解的地方。

Authorization Code

典型场景:

1
2
3
第三方应用
+
用户参与授权

流程:

1
2
3
Authorization Code

Access Token

这是今天最重要的用户授权流程。

现代系统通常不是简单的:

1
Authorization Code

而是:

1
Authorization Code + PKCE

RFC 9700 要求 Public Client 必须使用 PKCE,同时推荐 Confidential Client 也使用 PKCE,Authorization Server 也必须支持 PKCE。


Client Credentials

Client Credentials 不代表某个最终用户。

它表示:

1
Client 以自己的身份访问资源

典型场景包括:

1
2
3
4
5
服务到服务调用
后台任务
机器账号
平台级公共能力
M2M API

例如:

sequenceDiagram
    participant A as Service A
    participant AS as Authorization Server
    participant B as Service B

    A->>AS: client_credentials
    AS-->>A: access_token
    A->>B: Bearer access_token
    B-->>A: Response

这里没有用户授权页面。

因为:

1
Resource Owner 不参与

所以这类 Token 的 Subject 应理解为 Client,而不是某个用户。

这也是企业微服务内部进行 Service-to-Service Authentication / Authorization 时经常采用的一类模式。


Implicit Grant

Implicit 当初是为无法安全保存 Client Secret 的浏览器应用设计的。

它会直接通过 Authorization Response 返回 Access Token:

1
2
3
Browser

access_token

这意味着 Access Token 进入 Front Channel。

这种设计今天已经不再是推荐方案。

RFC 9700 明确指出 Implicit Grant 容易产生 Access Token 泄漏与重放问题,Client 应改用返回 code 的 Authorization Code Flow。

因此今天面对:

1
2
3
SPA
Browser App
JavaScript Client

不要再使用:

1
Implicit Flow

而应考虑:

1
Authorization Code + PKCE

Resource Owner Password Credentials

Password Grant 的逻辑是:

1
2
3
4
5
6
7
用户

把 username/password 给 Client

Client

拿 username/password 换 Token

这恰好重新引入了 OAuth 最初想避免的问题:

1
第三方或 Client 接触用户密码

2020 年左右的资料中,仍然经常把它描述成:

1
“官方第一方应用可以考虑 Password Grant”

这个结论今天已经不能继续作为实践建议使用。

RFC 9700 已明确规定 Resource Owner Password Credentials Grant MUST NOT be used,原因包括扩大用户凭据攻击面,而且很难兼容 MFA、WebAuthn 等现代认证机制。

所以今天即使是第一方 App,也不应该因为“这是自己家的客户端”就设计:

1
2
3
App 收集密码

password grant

更合理的方向仍然是让身份认证发生在真正负责认证的系统中。


2026 年应该怎样看这四种模式

可以把经典 OAuth 2.0 教材和今天的工程实践对应成下面这张表。

Flow 历史定位 今天的建议
Authorization Code Web / Server Client 推荐,配合 PKCE
Client Credentials Client 自身访问 继续使用
Implicit 浏览器 Public Client 不再推荐
Password 高信任客户端 禁止新设计使用

OAuth 2.1 正是在试图把这些多年安全实践重新收敛到新的核心规范中。

截至 2026 年 8 月,OAuth 2.1 仍然是 OAuth Working Group 的 Active Internet-Draft;当前公开的 draft-ietf-oauth-v2-1-15 发布于 2026 年 3 月 2 日,并不是已经发布完成的正式 RFC。该草案已经不再包含 Implicit 和 Resource Owner Password Credentials,并把 PKCE 等安全实践纳入核心设计。

因此现阶段更准确的理解是:

1
2
3
4
5
6
7
正式 OAuth 2.0 基础
RFC 6749
+
现代安全要求
RFC 9700
+
其他扩展 RFC

而不是简单认为:

1
OAuth 2.1 已经完全取代 OAuth 2.0

PKCE:为什么 Public Client 也能安全使用授权码模式

移动 App 带来一个经典问题。

假设一个原生 App 中写入:

1
client_secret=123456

这个 Secret 真的是 Secret 吗?

不是。

App 安装包最终会被分发到成千上万台用户设备。

攻击者可以:

1
2
3
4
5
反编译
Hook
动态调试
内存读取
静态分析

因此,写入 Native App 或 SPA 的 Client Secret 不能被真正视为 Confidential Secret。

于是出现了另一个问题:

1
2
3
4
5
没有 client_secret

Authorization Code 被截获

攻击者是不是可以直接换 Access Token?

PKCE 就是为降低这类 Authorization Code Interception 风险设计的机制。RFC 7636 的设计背景正是保护无法安全保存 Client Secret 的 Public Client。


PKCE 的核心:Code Verifier 与 Code Challenge

Client 首先生成:

1
code_verifier

它是高熵随机字符串。

然后计算:

1
2
3
4
5
6
code_challenge =
BASE64URL(
SHA256(
ASCII(code_verifier)
)
)

现代实现应该使用:

1
code_challenge_method=S256

第一步:请求 Authorization Code

请求:

1
2
3
4
5
6
GET /authorize?
response_type=code&
client_id=mobile-client&
redirect_uri=com.example.app:/oauth/callback&
code_challenge=xxx&
code_challenge_method=S256

Authorization Server 保存:

1
2
3
Authorization Code

code_challenge

第二步:兑换 Token

Client 得到 Code 后:

1
POST /token

携带:

1
2
3
4
grant_type=authorization_code
code=xxxx
client_id=mobile-client
code_verifier=yyyy

Authorization Server 重新计算:

1
BASE64URL(SHA256(code_verifier))

然后比较:

1
计算结果 == 第一步的 code_challenge

只有一致才签发 Token。

因此,即便攻击者只拿到了:

1
Authorization Code

也缺少:

1
code_verifier

无法完成兑换。


PKCE 不是 Client Secret 的替代品

这是另一个常见误解。

PKCE 证明的是:

1
2
3
4
兑换 Code 的 Client

发起授权请求的 Client 实例
是同一方

它主要缓解的是:

1
Authorization Code interception / injection

而不是证明:

1
这个 Client 到底是不是平台预期的合法 Confidential Client

因此在能够安全保存 Client Credential 的服务器应用中:

1
2
3
Client Authentication
+
PKCE

可以同时存在。

RFC 9700 现在也明确把 PKCE 推荐范围扩展到了 Confidential Client,并指出 S256 是目前唯一不会在 Authorization Request 中直接暴露 Verifier 的 PKCE Challenge Method。


移动 App 应该如何使用 OAuth 2.0

移动 App 可以粗略分成:

1
2
3
4
5
Native App
|
+-- 有自己的 Backend
|
+-- 没有 Backend

但无论哪一种,有一个原则始终成立:

不要把真正需要保密的 Client Secret 写进 App。

对于 Public Native Client,今天的主流方向是:

1
2
3
Authorization Code
+
PKCE

RFC 8252 对 Native App 的 OAuth 2.0 使用进行了专门规范,并明确说明 PKCE 用于防止被截获的 Authorization Code 被攻击者兑换。

如果 App 本身还有 Backend,则可以进一步把敏感 Credential、长期 Token 管理和部分 OAuth 逻辑收敛到 Server 侧。


OAuth 2.0 为什么不能直接当作“登录协议”

再回到那个最常见的问题:

1
微信登录使用 OAuth

于是很多人自然得到:

1
OAuth = 登录协议

这个推导并不严谨。

OAuth 解决的是:

1
Can this client access this resource?

身份认证解决的是:

1
Who is this user?

OAuth 2.0 中用户确实经常需要登录 Authorization Server,但那是:

1
2
3
4
5
用户先完成 Authentication

Authorization Server 才知道谁在授权

再进行 Authorization

登录只是授权能够发生的前置条件之一。

OAuth 自身并没有规定:

1
Client 应如何获得一个可靠的“当前用户身份声明”

所以不能简单用:

1
2
3
拿到 Access Token
=
用户已经登录

来设计认证系统。


OIDC:在 OAuth 2.0 上增加身份认证

OpenID Connect(OIDC)正是为身份认证而设计。

可以粗略理解为:

1
2
3
4
5
OAuth 2.0
+
Identity Layer
=
OpenID Connect

OAuth 的核心输出是:

1
Access Token

OIDC 又增加了:

1
ID Token

两者解决的问题完全不同。


OIDC 的三个角色

OIDC 中常见三个角色:

OIDC 角色 含义
End User(EU) 最终用户
Relying Party(RP) 使用认证结果的应用
OpenID Provider(OP) 身份认证提供方

对应 OAuth 可以近似理解为:

1
2
3
4
5
6
7
8
9
10
11
12
13
Resource Owner

End User

Client

Relying Party

Authorization Server
+
Identity Provider

OpenID Provider

OIDC Authorization Code Flow

OIDC 建立在 OAuth Authorization Code Flow 上时,整体流程看起来非常熟悉。

sequenceDiagram
    actor User as End User
    participant RP as Relying Party
    participant OP as OpenID Provider

    User->>RP: 访问应用
    RP-->>User: 重定向到 OP
    User->>OP: 登录并确认
    OP-->>User: Redirect + code
    User->>RP: callback?code=...
    RP->>OP: 使用 code 请求 Token
    OP-->>RP: ID Token + Access Token
    RP->>RP: 验证并解析 ID Token

关键差异就在最后:

1
2
3
4
5
6
7
OAuth:
Access Token

OIDC:
Access Token
+
ID Token

ID Token 是什么

ID Token 通常采用 JWT。

其中包含能够描述一次身份认证结果的 Claims。

典型字段包括:

1
2
3
4
5
6
7
{
"iss": "https://identity.example.com",
"sub": "248289761001",
"aud": "client-123",
"exp": 1786953600,
"iat": 1786950000
}

几个字段含义分别是:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
iss
Issuer
谁签发的

sub
Subject
用户唯一标识

aud
Audience
这个 ID Token 是签给哪个 Client 的

exp
Expiration
什么时候失效

iat
Issued At
什么时候签发

RP 拿到 ID Token 后不能只做:

1
Base64 decode

还必须验证:

1
2
3
4
5
Signature
Issuer
Audience
Expiration
必要的 Nonce / Authentication Context

验证通过之后,RP 才可以相信:

1
2
这个 sub 对应的用户
已经在这个 OP 完成了认证

为什么既有 ID Token,又要 Access Token

既然 ID Token 已经告诉 Client:

1
用户是谁

为什么还要 Access Token?

因为它们职责不同。

1
2
3
4
5
6
7
8
9
10
11
ID Token

给 Client / RP

身份认证

Access Token

给 Resource Server

访问资源

例如 RP 从 ID Token 得到:

1
sub=10001

但如果还需要:

1
2
3
4
昵称
头像
邮箱
手机号

这些信息通常不应该全部塞进 ID Token。

Client 可以使用 Access Token 请求 UserInfo Endpoint:

1
2
3
4
5
6
7
8
9
RP
|
| Access Token
v
UserInfo Endpoint
|
| User Profile
v
RP

于是形成:

1
2
3
4
5
ID Token
-> Who are you?

Access Token
-> What resource may I access?

这个边界非常重要。


OIDC 与 SSO、联合登录

当多个系统共同信任同一个 OpenID Provider:

1
2
3
4
5
6
Application A
Application B
Application C
|
v
OpenID Provider

用户第一次访问 A:

1
2
3
4
5
6
7
8
9
A

OP

登录

ID Token

A 登录成功

随后访问 B:

1
2
3
4
5
6
7
8
9
B

OP

OP 已经存在用户认证会话

不必再次输入用户名密码

返回新的认证结果

这样就形成了 Single Sign-On。

因此:

1
2
SSO 是一种业务能力
OIDC 是可以实现 SSO 的协议

两者不能简单画等号。

同样:

1
2
3
CAS
SAML
OIDC

都可以被用于构建不同类型的单点登录体系。


开放平台真正的系统架构

OAuth 2.0 最典型的工程落地并不是一个孤零零的 /oauth/token 接口,而是一套完整开放平台。

根据课程中的开放平台案例,可以抽象成三层。

flowchart TB
    Client1[第三方应用 A]
    Client2[第三方应用 B]

    Dev[Developer Center]
    AS[OAuth Authorization Server]
    Gateway[API Gateway]

    Order[Order Service]
    Goods[Goods Service]
    User[User Service]

    Client1 --> AS
    Client2 --> AS

    Dev --> AS

    Client1 --> Gateway
    Client2 --> Gateway

    Gateway --> Order
    Gateway --> Goods
    Gateway --> User

Developer Center 负责:

1
2
3
4
5
6
7
Client 注册
client_id
Client Credential
Redirect URI
Scope
应用审核
Client 生命周期

Authorization Server 负责:

1
2
3
4
5
6
Authorization
Code
Access Token
Refresh Token
Token Revocation
Client Authentication

API Gateway 负责:

1
2
3
4
5
6
入口流量
Token 验证
基础 Scope 校验
限流
路由
审计

Resource Server 则负责:

1
2
3
4
领域业务
资源归属
数据权限
业务级授权

为什么 Token 校验适合下沉到 API Gateway

假设系统后面存在:

1
2
3
4
5
6
Order Service
Goods Service
Customer Service
Inventory Service
Finance Service
...

如果每个服务都重复:

1
2
3
4
5
解析 Token
验证签名
检查 Token 生命周期
查询 Client
解析基本 Scope

不仅代码重复,还容易导致:

1
2
3
A 服务校验逻辑版本 1
B 服务校验逻辑版本 2
C 服务忘了校验某个字段

因此常见架构是:

1
2
3
4
5
6
7
8
External Client
|
v
API Gateway
|
| Authentication / Basic Authorization
v
Internal Services

Gateway 验证成功后,可以向下游传递可信身份上下文,例如:

1
2
3
4
user_id
client_id
tenant_id
scope

但是这里不能推导出:

1
2
3
有 Gateway
=
业务服务完全不用做权限校验

订单是否属于当前商家、租户数据隔离、财务单据能否修改等 领域级授权,依旧应该由真正掌握业务语义的服务负责。

否则极容易出现 Horizontal Privilege Escalation(水平越权)。


Authorization Server 与 API Gateway 的职责不要混淆

一个非常实用的架构划分是:

1
2
3
4
5
6
7
Authorization Server

负责颁发凭据

API Gateway / Resource Server

负责验证和执行授权

也就是:

1
2
3
4
5
授权发生在哪里?
Authorization Server

Token 在哪里真正产生价值?
Resource Server

Authorization Server 决定:

1
给 Client 什么权限

Resource Server 决定:

1
这个权限是否足以执行当前资源操作

两者共同组成完整授权闭环。


OAuth 2.0 的安全不能只靠 HTTPS

HTTPS 是 OAuth 必不可少的基础设施,但:

1
HTTPS != OAuth Security

TLS 解决的是:

1
2
Network Transport Confidentiality
Network Transport Integrity

OAuth 还要解决:

1
2
3
4
5
6
7
8
9
Redirect URI 劫持
Authorization Code 截获
Authorization Code Injection
CSRF
Token Leakage
Token Replay
Client Credential 泄漏
Scope 越权
Refresh Token 重放

这是完全不同的层次。


state 应该怎么理解

传统 OAuth 教程通常会说:

1
state 用于防 CSRF

这个理解方向没错,但现代实现需要更精确。

state 首先应该是一个与当前用户浏览器会话强绑定、不可预测、一次性的事务值:

1
2
3
4
5
6
7
8
9
10
11
Authorization Request
|
| state=abc123
v
Authorization Server

Authorization Response
|
| state=abc123
v
Client

Client 必须验证:

1
2
3
response.state
==
session.expected_state

如果不匹配,就应终止授权流程。

RFC 9700 要求 Client 必须防御 OAuth Redirect Endpoint 上的 CSRF;如果 Client 已确认 Authorization Server 正确支持 PKCE,可以利用 PKCE 提供的 CSRF 保护,在 OIDC 场景中也可以使用 nonce,否则应使用与 User Agent 安全绑定的一次性 state

所以不要把 state 理解成一个随便写的:

1
state=123

真正重要的是 Transaction Binding


现代 OAuth 的安全基线

如果今天重新设计一套 OAuth 系统,可以把下面这些作为基础要求。

授权码模式优先

对于需要用户参与的授权:

1
Authorization Code + PKCE

应该成为默认考虑方案。


PKCE 使用 S256

不要新设计:

1
code_challenge_method=plain

优先:

1
S256

RFC 9700 明确指出当前只有 S256 不会把 Verifier 暴露在 Authorization Request 中。


Redirect URI 必须严格匹配

不要支持:

1
https://example.com/*

或者:

1
redirect_uri=https://anything-from-request.example.com

否则 OAuth 很容易被 Open Redirect 和 Code Leakage 攻击击穿。


Access Token 不进入 URL

错误:

1
GET /orders?access_token=xxxx

推荐:

1
Authorization: Bearer xxxx

Access Token 尽量短生命周期

例如设计成:

1
2
3
短期 Access Token
+
长期但受控的 Refresh Token

而不是:

1
30 天 Access Token

降低 Token 泄漏后的利用窗口。


Refresh Token 做 Rotation 或 Sender Constraint

特别是 Public Client,不能仅仅依靠一个长期 Bearer Refresh Token 无限重复使用。RFC 9700 要求 Public Client 的 Refresh Token 使用 Sender Constraint 或 Rotation。


Scope 遵循最小权限

避免:

1
scope=all

更合理的是:

1
2
3
4
order.read
order.write
order.print
goods.read

并尽可能再结合:

1
2
3
4
Resource
Audience
Tenant
Owner

限制 Token 可以被使用的位置。

当前 OAuth 安全最佳实践也建议 Access Token 对具体 Resource Server 和操作做权限限制,并鼓励使用 Audience-Restricted Token。


不再使用 Password Grant

新系统不要采用:

1
grant_type=password

RFC 9700 已明确禁止这种模式用于现代 OAuth 设计。


不再使用 Implicit Flow

SPA 或浏览器应用不要再因为“没有后端”就选择:

1
response_type=token

今天应该优先:

1
Authorization Code + PKCE

不同业务到底应该选择什么方案

经过前面的分析,选择 OAuth 流程其实可以很简单。

场景 推荐方案
第三方 Web 应用访问用户资源 Authorization Code + PKCE
SPA Authorization Code + PKCE
Native App Authorization Code + PKCE
后端 Service-to-Service Client Credentials
定时任务调用内部 API Client Credentials
用户登录 / 联合登录 OIDC Authorization Code + PKCE
SSO OIDC
旧 Password Grant 不应新建
旧 Implicit Flow 应迁移

如果看到一个需求是:

1
2
A 服务调用 B 服务
没有最终用户参与

应该首先考虑:

1
Client Credentials

如果是:

1
第三方应用代表用户访问订单

考虑:

1
Authorization Code + PKCE

如果需求真正是:

1
2
3
我要知道用户是谁
我要做统一登录
我要做 SSO

那重点已经不是纯 OAuth,而应该进入:

1
OIDC

OAuth、OIDC、JWT 三者到底是什么关系

这三个概念经常一起出现,所以很容易被混为一谈。

最简单的区分方法是:

1
2
3
4
5
6
7
8
OAuth 2.0
授权框架

OIDC
身份认证协议

JWT
Token 数据格式

因此下面这些组合都可能存在:

1
2
3
4
5
6
7
OAuth + Opaque Access Token

OAuth + JWT Access Token

OIDC + JWT ID Token

OIDC + Opaque Access Token

JWT 并不是 OAuth 必须使用的东西。

OAuth 也不是 JWT 的一个使用场景而已。

OIDC 则利用 OAuth 的流程,又额外定义了身份认证语义。


几个特别容易踩的认知坑

“拿到了 Access Token,所以用户已经登录”

错误。

Access Token 表示:

1
Client 获得了访问某些 Resource 的权限

登录状态应该使用专门的身份认证机制。

需要标准化联合身份认证时使用 OIDC。


“Access Token 是 JWT,所以 Client 可以解析”

不应该。

即使今天能解析:

1
access_token.split(".")

Client 也不应该依赖 Payload 中的字段。

Resource Server 才是 Access Token 的真正消费者。


“用了 JWT 就不需要数据库”

不一定。

如果业务要求:

1
2
3
4
5
6
立即撤销
用户实时封禁
权限实时变化
设备绑定
Token Blacklist
Key Management

依旧可能需要缓存或状态服务。

架构复杂度并没有凭空消失,只是从:

1
Token Store

转移到了:

1
2
3
4
Key Management
Revocation
Cache
Authorization Policy

“有 API Gateway 后 Resource Server 就不用鉴权了”

不准确。

Gateway 很适合统一完成:

1
2
3
4
5
Token 验证
Client 验证
基础 Scope
路由
限流

但:

1
2
3
订单是不是当前商家的?
当前用户能不能修改这张结算单?
租户 ID 是否一致?

仍属于业务服务掌握的领域权限。


“OAuth 2.0 是给第三方系统用的,内部系统不能用”

也不准确。

OAuth 最经典的模型确实来自委托第三方访问,但其中很多机制同样可以应用于企业系统:

1
2
3
4
5
6
Service-to-Service
API Gateway
微服务鉴权
机器身份
后台 Job
开放 API

只是没有最终用户参与时,通常会转化为:

1
Client Credentials

而不是生搬硬套 Authorization Code。


从开放平台角度重新看 OAuth 2.0

真正成熟的 OAuth 平台并不是:

1
写一个 TokenController

而是由一整套能力共同组成:

1
2
3
4
5
6
7
8
9
10
11
12
13
Developer Management

Client Registration

Authorization Server

Token Lifecycle

API Gateway

Resource Server

Audit / Revocation / Security

如果继续展开,一个企业级开放平台往往还会包含:

1
2
3
4
5
6
7
8
9
10
11
12
Client 审核
Secret Rotation
Scope 管理
API 产品管理
Consent 管理
Token Introspection
Token Revocation
Key Management
Rate Limit
Audit Log
Risk Control
Developer Portal

所以 OAuth 真正难的地方,往往并不在:

1
2
3
if (tokenValid) {
return data;
}

而是整个授权生命周期和安全边界的设计。


站在今天重新理解 OAuth 2.0

这些资料形成于 2020 年,当时以 RFC 6749 中“四种 Grant”作为 OAuth 2.0 的主要教学结构非常常见。

几年之后,OAuth 社区已经把大量真实攻击经验沉淀进新的安全最佳实践。

2025 年发布的 RFC 9700 已成为 OAuth 2.0 Security Best Current Practice,它明确强化了 PKCE、Redirect URI、Token Replay、Refresh Token 等要求,同时弱化甚至淘汰了一些早期模式。

到 2026 年,OAuth 2.1 仍然处于 Internet-Draft 阶段,其方向已经非常明确:

1
2
3
4
5
6
保留 Authorization Code
强制现代化 PKCE
保留 Client Credentials
去掉 Implicit
去掉 Password Grant
收紧 Redirect URI 等安全要求

所以今天学习 OAuth 2.0,最重要的并不是死记:

1
OAuth 有四种模式

而应该建立下面这套模型:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
                   OAuth 2.x
|
+-----------+-----------+
| |
User Delegation Client Itself
| |
Authorization Code Client Credentials
+ PKCE
|
+------> Access Token
|
v
Resource Server
|
Scope / Resource
Audience / Policy

如果还需要解决:

1
Who is the user?

再在其上引入:

1
OpenID Connect

总结

OAuth 2.0 最值得理解的不是某一个参数,而是它背后的授权思想:

1
2
3
4
5
6
7
8
9
不要把用户的主凭据交给 Client

用户只授权有限权限

Authorization Server 签发短期凭据

Client 使用 Access Token

Resource Server 执行权限判断

Authorization Code 把 Front Channel 中的用户交互与 Back Channel 中的 Token 签发隔离开来;PKCE 又进一步把 Authorization Code 与发起请求的 Client 实例绑定。

Scope 决定 Token 可以做什么,Access Token 是访问资源的凭据,Refresh Token 用于延续授权生命周期,而不是制造一个永久有效的 Access Token。

JWT 只是 Token 的一种结构化表达形式。它可以让 Resource Server 本地完成验证,却同时带来即时撤销、密钥管理等新的成本;签名也绝不等于加密。

OAuth 2.0 解决的是 Authorization,而不是 Authentication。需要身份认证、联合登录和 SSO 时,应使用建立在 OAuth 2.0 之上的 OpenID Connect,通过 ID Token 表达身份认证结果,通过 Access Token 访问 UserInfo 或其他受保护资源。

从工程实践来看,今天最值得记住的组合已经非常清晰:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
用户授权:
Authorization Code + PKCE

服务间调用:
Client Credentials

用户身份认证:
OIDC + Authorization Code + PKCE

浏览器 / Native Public Client:
不要再使用 Implicit

用户名密码换 Token:
不要再使用 Password Grant

真正把这些边界理解清楚之后,再去阅读微信、支付宝、Google、GitHub、企业 IAM、Spring Security 或任意开放平台的 OAuth 文档,会发现那些看似零散的 client_idredirect_uriscopecodeaccess_tokenrefresh_token 参数,实际上都只是同一套授权模型在不同系统里的具体表达。


OAuth 2.0 深入理解:授权码、令牌、PKCE、JWT、OIDC 与开放平台实践
https://allendericdalexander.github.io/2026/08/17/archtect/idp/OAuth-2-0-jwt-pcke-oidc/
作者
AtLuoFu
发布于
2026年8月17日
许可协议