某团队维护的单页应用(SPA,Single Page
Application)在一次例行安全审计中被红队给出高危结论:用户登录后,access_token
以 URL
fragment(#access_token=...)形式直接落在浏览器地址栏。紧接着被发现的链路有三条——浏览器历史记录完整保留了该
token,前端曾经把 window.location.href
写入过前端日志收集系统,某个第三方监控 SDK 还会把完整 URL
连同错误上下文一起上报。审计方给出的结论很直接:只要攻破前述任何一环就能拿到一个几十分钟有效的访问凭证,而
SPA 用的是 OAuth 2.0 的 implicit grant
flow(隐式授权流程,RFC 6749 §4.2),这正是 OAuth 2.1
决心废弃的历史遗产。
这次事故不是个例。把 token 从 URL 里赶走、把 public client 的授权码拦截风险堵住、把松散的 redirect 匹配收紧——这三件事合起来,构成了 OAuth 2.1 草案(draft-ietf-oauth-v2-1)最重要的一次收敛,而 PKCE(Proof Key for Code Exchange,RFC 7636)则是这次收敛的核心密码学机制。本文从这条主路径出发,系统讨论 OAuth 2.1 的变更、PKCE 的数学原理、完整的授权码 + PKCE 流程参数、SPA 与移动端的必选理由,以及 DPoP、PAR、JAR、RAR 等现代扩展与常见攻击面。更基础的概念性介绍可参考 OAuth 2.0 入门 与 PKCE 基础,宏观的认证架构请见 身份与认证架构总览。
一、从 OAuth 2.0 到 OAuth 2.1:一次迟到的收敛
1.1 为什么需要 OAuth 2.1
OAuth 2.0 自 2012 年 RFC 6749 发布以来,实际上是一个”菜单式”的授权框架:定义了四种 grant type、两类 client(public 与 confidential)、多种 token 传递方式,把大量安全决策留给实现者。十余年的工业实践暴露出两个问题。第一,RFC 6749 本身只描述机制,真正的安全实践散落在十几份 BCP(Best Current Practice)与扩展 RFC 里,例如 OAuth 2.0 Security Best Current Practice(draft-ietf-oauth-security-topics)、OAuth 2.0 for Native Apps(RFC 8252)、OAuth 2.0 for Browser-Based Apps(draft-ietf-oauth-browser-based-apps)——开发者很难把它们全部读完、正确落地。第二,早期 RFC 6749 允许的某些模式(implicit、password grant)在现代威胁模型下已经是净负收益,继续放在”标准选项”里会诱导错误实现。
OAuth 2.1 的设计目标非常克制:不引入新协议语义,只做三件事——合并 BCP 中已成定论的要求、删除被证明不安全的 grant、把原本”建议”改成”必须”。它是一次有意为之的收敛,而不是 3.0 式的重构。
1.2 废弃 implicit grant
implicit grant 的工作方式是:客户端在
/authorize 请求中指定
response_type=token,授权服务器(AS,Authorization
Server)直接在重定向 URL 的 fragment 中返回
access_token。RFC 6749 当年设计它是为了解决 SPA
拿不到授权码的问题——SPA 没有后端,无法安全保存 client_secret
去换 token,于是干脆让 AS 直接返回 token。
问题在于 token 落在 URL
上之后,泄露路径众多:浏览器历史记录、前端埋点与错误上报、浏览器扩展、共享设备上的截屏/录屏、以及任何会读取
window.location
的第三方脚本。更糟的是,implicit 流程默认无法签发
refresh_token(因为无法证明调用方身份),导致实现者要么短
token 频繁跳转体验糟糕,要么把 token
寿命拉得很长使泄露后果更严重。
OAuth 2.1 直接删除了 implicit grant。替代方案是所有 public client(包括 SPA)统一走授权码流程 + PKCE。PKCE 让拿不到 client_secret 的客户端也能安全地用授权码换 token,详见第二节。
1.3 废弃 Resource Owner Password Credentials(ROPC)
ROPC
让用户把用户名密码直接交给客户端,客户端再拿这对凭证去
/token 换
access_token。它的存在理由是”迁移旧系统”——一些 legacy
客户端习惯了用密码登录,直接替换成浏览器跳转体验断裂。但代价非常大:
- 第三方客户端能直接看到用户明文密码,违反了 OAuth 设计初衷(用授权代替凭证共享);
- 无法支持 MFA(多因素认证)、设备绑定、风险引擎等现代身份控制;
- 密码一旦被缓存或记录,影响面远大于一个 access_token;
- 钓鱼攻击成本极低——伪造一个接收密码的界面即可。
OAuth 2.1 也删除了 ROPC。迁移路径是改为 device authorization grant(RFC 8628,适合无浏览器环境)或引导到浏览器走授权码 + PKCE。
1.4 PKCE 对所有 public client 强制
RFC 7636 最初把 PKCE 定位为”移动 App 推荐使用”,原因是移动端的自定义 URL scheme 容易被劫持。OAuth 2.1 把 PKCE 的适用范围扩展到所有 public client——包括 SPA、桌面应用、IoT 设备、CLI 工具——并且从”推荐”升级为”必须”。更进一步,Security BCP 建议连 confidential client 也开启 PKCE,因为它免费提供了一层防御,不依赖 client_secret 的保密性。
这一条是 OAuth 2.1 里最具现实影响的改动:几乎所有新
SDK、新 AS 实现都默认开启 PKCE 校验;如果客户端没有附带
code_challenge,AS 会直接拒绝。
1.5 redirect_uri 精确匹配
RFC 6749 允许 AS 对注册的 redirect_uri
做前缀匹配或模式匹配,例如客户端注册
https://app.example.com/ 就能接受
https://app.example.com/callback、https://app.example.com/oauth/cb
等任意子路径。实际攻击中这被滥用为:利用开放重定向(open
redirect)漏洞把授权码带到攻击者控制的页面,或者利用子路径上的
XSS 把 URL 里的 code 读走。
OAuth 2.1 要求 redirect_uri 精确字符串匹配,包含大小写与 query string。与此同时,OAuth 回调地址本身就不应包含 fragment。客户端在注册时必须把所有合法回调地址完整枚举,AS 在比对时不允许任何通配、模板或字符串拼接。这对开发体验有少量摩擦,但杜绝了一整类攻击。
1.6 Bearer token 不得出现在 URL query string 里
RFC 6750 曾经列出三种 access_token
的传递方式:Authorization header、请求体表单字段、URL query
string(?access_token=...)。第三种在日志、Referer、代理缓存里泄露风险最高。OAuth
2.1 明确禁止 URL query string 方式,只允许 Authorization
header;请求体方式被限制在”不能使用 header
的极少数场景”。
1.7 refresh_token rotation
refresh_token 是长寿命凭证,一旦泄露后果比 access_token 严重得多。OAuth 2.1 建议对 public client 启用 rotation:每次用 refresh_token 换新 access_token 时,AS 同时发放一个新的 refresh_token 并使旧的立即失效。如果攻击者盗用了某个 refresh_token 并先于合法客户端使用,下次合法客户端再来兑换时旧 token 已经失效,AS 可据此触发异常——实际实现(如 Auth0、Okta)通常会直接撤销整条 token chain。
1.8 其他细节
- device authorization grant(RFC 8628)被正式纳入主规范;
state继续保留,用于把授权响应与浏览器侧会话绑定;PKCE 解决授权码拦截,两者不能互相替代;- 明确 JSON-only 的 token 响应格式(淘汰 form-encoded 历史分支);
- 正式描述 token introspection(RFC 7662)和 revocation(RFC 7009)的推荐实现。
1.9 对现有客户端的迁移成本
OAuth 2.1 并非破坏性协议变化——所有”仍被支持”的流程都是 OAuth 2.0 已有的,只是被选择性收敛。客户端迁移有三条主线:
- implicit → 授权码 + PKCE:需要客户端实现 verifier/challenge 生成和 token 端点调用,多出一次 HTTP 请求,但换来 refresh_token 能力和 token 不入 URL 的安全收益。前端改动量集中在一个登录库。
- password grant → 浏览器授权码:UX 变化最大——从 App 内输入密码改为跳转浏览器。可以配合 passkey、SSO、MFA 让整体体验反而更顺。内部员工场景可走 device code + PIN。
- redirect_uri 模糊匹配 → 精确匹配:把所有实际使用过的回调 URL 列清单注册进 AS;CI/CD 的 staging、预发环境、本地开发每个环境一条。
对 AS 运营方而言,最关键的是”关掉旧开关”的节奏。把 implicit 和 password 从”默认允许”改成”默认禁止、按客户端显式申请豁免”;豁免项加观测与到期时间;在三到六个月窗口里推动客户端迁移完成。
下面这张表把主要变化列一下:
| 方面 | OAuth 2.0 | OAuth 2.1 |
|---|---|---|
| implicit grant | 允许 | 废弃 |
| password grant | 允许 | 废弃 |
| PKCE | 移动端推荐 | 所有 public client 必须 |
| redirect_uri | 前缀 / 模式匹配 | 精确匹配 |
| URL query 传 token | 允许 | 禁止 |
| refresh_token rotation | 可选 | 推荐(public client) |
| device flow | 扩展 RFC 8628 | 纳入主文档 |
二、PKCE 的密码学原理
2.1 为什么 SPA 和移动端需要 PKCE
授权码流程的经典安全假设是:客户端有一个保密的
client_secret,AS 通过
client_id + client_secret
验证调用方身份,第三方即使截获 code 也没法兑换
token。这个假设在 confidential client(有后端的 Web
应用、后台服务)上成立。
public client 不成立。SPA 的所有代码都在浏览器里,任何嵌入的密钥都能被前端调试器读出来;移动 App 装到用户设备上,二进制里的常量可以被逆向;桌面客户端、CLI 工具同样如此。结论是:public client 必须假设自己没有任何秘密——任何”客户端 own”的凭证都可能被攻击者拿到。
于是问题变成:没有长期密钥的客户端怎么证明”这次来兑换 token 的人确实是当初发起授权的人”?PKCE 的答案是每次请求动态生成一对一次性的”挑战 / 答案”。
2.2 code_verifier 与 code_challenge 的构造
PKCE 定义了两个关键字段。
code_verifier:客户端在发起授权前生成的高熵随机字符串。RFC 7636 规定长度 43 到 128 字符,字符集限定为
[A-Za-z0-9\-._~](即 unreserved URI characters,避免 URL 编码的坑)。43 个字符,对应约 256 bit 熵,这是底线。code_challenge:对 code_verifier 做变换后的值,放进
/authorize请求里送给 AS。变换方法由code_challenge_method指定,OAuth 2.1 仅允许S256:code_challenge = BASE64URL-ENCODE(SHA-256(ASCII(code_verifier)))BASE64URL-ENCODE是无 padding 的 URL-safe base64。
AS 在 /authorize 阶段把
code_challenge 和
code_challenge_method 与该次 code
绑定持久化;在 /token 阶段客户端提交
code_verifier,AS 重新计算
BASE64URL-ENCODE(SHA-256(verifier))
与当初存下的 code_challenge 比较,一致才签发
token。
2.3 S256 与 plain:为什么不能用 plain
RFC 7636 同时定义了 plain 方法——直接把
verifier 当作 challenge 传。OAuth 2.1 禁止
plain,原因是:
plain 模式下
code_challenge == code_verifier,也就是授权阶段传给
AS 的值和兑换阶段传给 AS 的值完全相同。如果攻击者能截获
/authorize 请求里的 challenge(它出现在 URL
里、会经过浏览器、日志、Referer),就等于拿到了
verifier,后面再截获 code 即可兑换 token。PKCE
的保护直接退化为零。
S256 的保护依赖 SHA-256 的单向性:即便攻击者完整截获
challenge,也无法从中倒推出 verifier。而 verifier 只在
/token 这次 TLS 请求的 body
里短暂出现一次,截获窗口极小。
OAuth 2.1 规范里把 plain 列为 MUST NOT
support,新客户端、新 AS 都不应实现。
2.4 完整计算示例(Go)
下面是一段生产级风格的 Go 代码,演示 verifier/challenge 的生成:
package pkce
import (
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"fmt"
)
type Pair struct {
Verifier string
Challenge string
Method string
}
func New() (*Pair, error) {
// 32 字节随机 = 256 bit 熵;base64url 后约 43 字符,符合最小长度
buf := make([]byte, 32)
if _, err := rand.Read(buf); err != nil {
return nil, fmt.Errorf("pkce: rand: %w", err)
}
verifier := base64.RawURLEncoding.EncodeToString(buf)
sum := sha256.Sum256([]byte(verifier))
challenge := base64.RawURLEncoding.EncodeToString(sum[:])
return &Pair{
Verifier: verifier,
Challenge: challenge,
Method: "S256",
}, nil
}一次真实运行结果(示意):
verifier = dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk
challenge = E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM
把 verifier 喂进 sha256sum | xxd | base64
自行验证也是一样的结果——没有密钥、没有盐,完全是公开可复算的函数。安全性完全来自
verifier 本身的熵和一次性。
2.5 PKCE 为什么能防止授权码拦截
把攻击模型写清楚。授权码拦截攻击(Authorization Code Interception Attack,RFC 7636 §1 给出完整分析)的典型路径是:
- 合法客户端 C
发起授权:
/authorize?... redirect_uri=customscheme://cb; - 用户在浏览器登录,AS 302 回
customscheme://cb?code=abc&state=xyz; - 在手机上,这个 custom scheme 可能被恶意 App M 注册了同样的 scheme——系统把 code 交给 M;
- 如果没有 PKCE,M 就可以直接带着 code 去
/token换 access_token 和 refresh_token。
加上 PKCE 后,流程第 1 步 C 已经把
code_challenge = S256(verifier) 发给了 AS,AS
把 challenge 和 code 绑定;第 4 步 M 带着截获的 code
去兑换,但 M 没有 verifier(verifier 只在 C
的内存里,没通过任何外部通道传输),无论提交什么值 AS
都会算出不同的哈希,校验失败拒绝兑换。
这条保护的关键前提是 verifier 不被泄露。对 SPA 来说 verifier 放在内存或 sessionStorage(不是 localStorage,防止跨标签泄露);对移动端来说放在内存或 Keychain / Keystore;对桌面 CLI 来说放在进程变量。它的寿命很短——只在本次授权到 token 兑换之间存在,兑换完立刻销毁。
三、授权码 + PKCE 完整流程与参数
下面给出一次完整的授权码 + PKCE
交互,所有字段尽可能贴近真实请求。假设 AS 是
https://as.example.com,client_id 是
spa-demo,redirect_uri 是
https://app.example.com/callback。
3.0 完整时序
在钻进参数之前先把时间线摆出来。客户端、浏览器、授权服务器、资源服务器四个角色的十来次交互,可以分成三个阶段:
- 前奏:客户端生成 verifier/challenge、决定 scope、发起浏览器跳转;这一步只涉及客户端和浏览器,AS 还没参与。
- 授权:浏览器把用户带到 AS,AS 执行登录、MFA、风控、同意页,最后 302 回带 code 的回调;AS 内部可能还有 SSO、passkey、风险引擎等多级子流程。
- 兑换与使用:客户端收到 code,在后台通过 TLS 直连 AS 兑换 token;拿到 token 之后对资源服务器发起实际 API 调用。整个 token 的寿命周期从这一刻开始计算。
把这个时序记在心里,下面每个请求/响应的字段作用就容易对应。
3.1 /authorize 请求
客户端先生成 verifier/challenge,再构造浏览器跳转:
GET /authorize
?response_type=code
&client_id=spa-demo
&redirect_uri=https%3A%2F%2Fapp.example.com%2Fcallback
&scope=openid%20profile%20email%20orders.read
&state=af0ifjsldkj
&nonce=n-0S6_WzA2Mj
&code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM
&code_challenge_method=S256 HTTP/1.1
Host: as.example.com
关键参数解释:
response_type=code:授权码流程,OAuth 2.1 的唯一推荐响应类型;client_id:注册时分配的客户端标识;redirect_uri:必须与注册完全一致(精确匹配);scope:权限范围;如果包含openid,同时是 OIDC(OpenID Connect)流程(详见 企业单点登录:OIDC 与现代 SSO);state:CSRF 防护,客户端随机生成并在回调中校验;nonce:OIDC 字段,用于在 id_token 中防重放;code_challenge/code_challenge_method:PKCE 两个核心参数。
3.2 /authorize 响应(用户同意后)
AS 渲染登录页、用户登录、用户同意授权后,返回 302:
HTTP/1.1 302 Found
Location: https://app.example.com/callback
?code=SplxlOBeZQQYbYS6WxSbIA
&state=af0ifjsldkj
客户端(前端代码)首先校验 state
是否等于发起时的值,然后把 code 连同内存里的
code_verifier 一起提交到
/token。
3.3 /token 请求
POST /token HTTP/1.1
Host: as.example.com
Content-Type: application/x-www-form-urlencoded
grant_type=authorization_code
&code=SplxlOBeZQQYbYS6WxSbIA
&redirect_uri=https%3A%2F%2Fapp.example.com%2Fcallback
&client_id=spa-demo
&code_verifier=dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk
注意 public client 没有
client_secret,但必须提交
client_id(便于 AS 审计和限流)和
code_verifier。redirect_uri
必须与当初 /authorize 里的值完全相同,AS
会二次校验以防 mix-up。
3.4 /token 响应
HTTP/1.1 200 OK
Content-Type: application/json
Cache-Control: no-store
Pragma: no-cache
{
"access_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...",
"token_type": "Bearer",
"expires_in": 600,
"refresh_token": "8xLOxBtZp8",
"scope": "openid profile email orders.read",
"id_token": "eyJhbGciOiJSUzI1NiIsImtpZCI6IjA..."
}
AS 在这一步执行三件事:
- 检查
code存在且未被使用(单次消费),校验过期时间(典型 60 秒); - 取出与 code 绑定的
code_challenge,与S256(code_verifier)比较; - 校验
client_id、redirect_uri与授权阶段一致。
任何一步失败,返回 400 invalid_grant,并且该
code 立即作废(防止爆破 verifier)。
3.5 调用受保护 API
GET /api/orders HTTP/1.1
Host: api.example.com
Authorization: Bearer eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...
资源服务器(Resource Server,RS)校验 JWT 签名、issuer、audience、过期时间后放行。JWT 的签名校验与 JWKS 刷新逻辑本文不展开。
3.6 refresh_token 刷新
access_token 过期后:
POST /token HTTP/1.1
Host: as.example.com
Content-Type: application/x-www-form-urlencoded
grant_type=refresh_token
&refresh_token=8xLOxBtZp8
&client_id=spa-demo
&scope=openid profile email orders.read
如果开启了 rotation,响应里会返回新的
refresh_token,旧的立即失效;如果同一
refresh_token 被使用两次,AS 应撤销整个 token chain。
3.7 AS 侧的服务端校验伪代码
一次完整的 /token 端点处理大致如下:
func handleToken(w http.ResponseWriter, r *http.Request) {
// 1. 解析 grant_type
if r.FormValue("grant_type") != "authorization_code" {
writeErr(w, "unsupported_grant_type")
return
}
// 2. 原子地取出并删除 code(防止并发重复兑换)
codeStr := r.FormValue("code")
rec, err := store.ConsumeCode(r.Context(), codeStr)
if err != nil {
writeErr(w, "invalid_grant")
return
}
// 3. 校验客户端
clientID := r.FormValue("client_id")
if rec.ClientID != clientID {
writeErr(w, "invalid_grant")
return
}
// 4. 精确匹配 redirect_uri
if r.FormValue("redirect_uri") != rec.RedirectURI {
writeErr(w, "invalid_grant")
return
}
// 5. 校验 PKCE
verifier := r.FormValue("code_verifier")
if verifier == "" {
writeErr(w, "invalid_grant")
return
}
sum := sha256.Sum256([]byte(verifier))
challenge := base64.RawURLEncoding.EncodeToString(sum[:])
if subtle.ConstantTimeCompare([]byte(challenge), []byte(rec.CodeChallenge)) != 1 {
writeErr(w, "invalid_grant")
// 安全敏感实现在此刻撤销同链上的 refresh_token
return
}
// 6. 签发 token
issueTokens(w, rec)
}两点值得强调:code 的消费必须原子(上面用单个
ConsumeCode
调用);挑战比对必须用常量时间比较,防止时间旁路——虽然
SHA-256 输出已经把 verifier 打散,但 PKCE
的一些扩展模式允许对称比较,养成习惯没有坏处。
四、浏览器与移动端落地
4.1 SPA 为什么没有别的选项
SPA 的约束可以归纳成三条铁律:
- 所有代码对用户可见,不能持有任何长期秘密;
- 没有后端会话存储,必须在浏览器里管理状态;
- 浏览器环境充斥着第三方脚本、扩展、cross-origin 窗口,任何暴露在 URL 或 storage 中的敏感值都要假设会泄露。
PKCE 恰好匹配这三条。它不要求客户端有 client_secret;verifier 可以短暂放在 sessionStorage(和当前标签绑定,关标签即销毁)或内存变量;challenge 即使泄露也无法独自完成兑换。这就是为什么 OAuth 2.0 for Browser-Based Apps BCP 和 OAuth 2.1 都把”SPA 用授权码 + PKCE”列为唯一推荐方案。
浏览器端还有两个额外最佳实践:
- 把 refresh_token 放到 HttpOnly Cookie 由 BFF(Backend for Frontend)持有,前端 JS 完全接触不到——这是 draft-ietf-oauth-browser-based-apps 推荐的 BFF 模式;
- 如果实在没有后端,refresh_token rotation + 短寿命是兜底方案。
4.2 移动端的自定义 scheme 劫持
iOS / Android 通过 custom URL scheme 接收 OAuth
回调,例如
com.example.app://oauth/cb。问题是操作系统并不能保证
scheme 唯一,任何 App 都可以声明同一个
scheme,系统在多个声明者之间选择的规则不一致(Android
弹选择器、iOS 取最早安装的)。恶意 App 声明一个和目标 App
相同的 scheme,就可能截获回调里的 code。
对策有两个层次:
- 首选 claimed HTTPS scheme(iOS
Universal Links / Android App Links):通过
.well-known/apple-app-site-association或assetlinks.json把 HTTPS URL 绑定到具体 App,系统拒绝其他 App 声明同一 URL; - PKCE 做最后一道防线:即使回调被截获,没有 verifier 无法兑换。
RFC 8252(OAuth 2.0 for Native Apps)同时强制两件事:授权必须在系统浏览器(或 In-App Browser Tab / ASWebAuthenticationSession)里完成,不允许用普通 WebView;客户端必须使用 PKCE。两条合起来才是完整方案。
4.3 AS 侧如何强制 PKCE
在 AS 实现上,强制 PKCE 意味着:
- 客户端注册时声明类型为 public,AS 在
/authorize时检查code_challenge必须存在,否则返回invalid_request; - 存储 code 的数据结构必须同时保存
code_challenge与method; - 强制
method=S256,拒绝 plain; /token阶段必须校验 verifier,缺失时返回invalid_grant。
主流实现(Keycloak、Auth0、Okta、Azure AD、Hydra)都支持 per-client 的 PKCE enforcement 开关,推荐全局打开。
在真正实现时,浏览器侧通常有两种架构:
- BFF 模式:浏览器只保留同站 session
cookie,
access_token和refresh_token都由 BFF(Backend for Frontend)代持。这是生产环境的首选。 - 纯 SPA 模式:浏览器直接走授权码 +
PKCE,
access_token只放内存,刷新能力要么不用,要么配合极短寿命与 rotation 严格控制。
下面这段代码只演示纯浏览器
SPA的最小实现,用来说明 PKCE
的关键状态如何保存与销毁;如果你采用 BFF
架构,浏览器并不会直接调用 /token 端点。
4.4 纯浏览器 SPA 的最小实现(TypeScript)
用 Web Crypto API 可以在浏览器里完成 PKCE 全流程,无需任何第三方库:
async function startLogin() {
const verifier = base64url(crypto.getRandomValues(new Uint8Array(32)));
const challenge = base64url(
await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier))
);
sessionStorage.setItem("pkce_verifier", verifier);
const state = base64url(crypto.getRandomValues(new Uint8Array(16)));
sessionStorage.setItem("oauth_state", state);
const url = new URL("https://as.example.com/authorize");
url.search = new URLSearchParams({
response_type: "code",
client_id: "spa-demo",
redirect_uri: "https://app.example.com/callback",
scope: "openid profile email orders.read",
state,
code_challenge: challenge,
code_challenge_method: "S256",
}).toString();
location.assign(url.toString());
}
async function handleCallback() {
const params = new URLSearchParams(location.search);
const returnedState = params.get("state");
if (returnedState !== sessionStorage.getItem("oauth_state")) {
throw new Error("state mismatch");
}
const code = params.get("code");
const verifier = sessionStorage.getItem("pkce_verifier");
sessionStorage.removeItem("pkce_verifier");
sessionStorage.removeItem("oauth_state");
const resp = await fetch("https://as.example.com/token", {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
grant_type: "authorization_code",
code,
redirect_uri: "https://app.example.com/callback",
client_id: "spa-demo",
code_verifier: verifier,
}),
});
const tokens = await resp.json();
// 纯 SPA 模式下 access_token 只放内存;若需要长期刷新,优先改为 BFF 代持 refresh_token
return tokens;
}
function base64url(buf) {
return btoa(String.fromCharCode(...new Uint8Array(buf)))
.replace(/=/g, "")
.replace(/\+/g, "-")
.replace(/\//g, "_");
}几个关键习惯:verifier 和 state 都存
sessionStorage(关标签即清),回调校验后立刻删除;fetch
调用 token 端点放在当前页面的 onload
处理里,不要跨组件传递;access_token 只放内存变量(React
Context、Zustand store),不入 localStorage。
五、Sender-Constrained Token 与现代扩展
5.1 Bearer token 的根本问题
标准的 access_token 是 Bearer token——“持有即生效”,服务端不验证持有者身份。一旦 token 泄露(日志、浏览器、网络劫持、SSRF),攻击者可以直接使用。PKCE 保护了授权码兑换环节,但没有保护已经签发出去的 token。
现代扩展的思路是给 token 加上”持有者绑定”,让 token 离开合法客户端后不可用。主流有两条路径:DPoP 和 mTLS。
5.2 DPoP(RFC 9449)
DPoP(Demonstrating Proof of Possession)让客户端每次调用
API 时,用一把客户端持有的私钥签一个一次性 JWT,附在
DPoP header 里;access_token 里通过
cnf.jkt claim
绑定了对应公钥的指纹(thumbprint)。服务端校验 JWT
的签名、时间戳、HTU/HTM 与请求一致后放行。
DPoP proof JWT 的 payload 大致如下:
{
"jti": "e4cbb1a7-6f86-4e54-8b8a-11c9de52e4a0",
"htm": "GET",
"htu": "https://api.example.com/orders",
"iat": 1716700000,
"ath": "fUHyO2r2Z3DZ53EsNrWBb0xWXoaNy59IiKCAqksmQEo"
}header:
{
"typ": "dpop+jwt",
"alg": "ES256",
"jwk": { "kty": "EC", "crv": "P-256", "x": "...", "y": "..." }
}htm/htu:HTTP method 和目标 URL,防止 proof 被挪用到别的端点;iat/jti:时间戳和唯一 id,RS 通过 jti 防重放(短时间窗口内记录使用过的 jti);ath:access_token 的 SHA-256 哈希(base64url),把 proof 与具体 token 绑定;- header 里直接嵌入公钥
jwk,RS 用它验证签名,并与 access_token 的cnf.jkt比对。
AS 侧发放 token 时,客户端在 /token
请求里也附带一个 DPoP proof,AS 从中提取 jkt 写到
access_token 的 cnf claim。之后每次 API
调用都要带 proof。
DPoP 还有 nonce 机制:RS 可以挑战性地返回
DPoP-Nonce header,客户端把该 nonce 放进下一次
proof 的 nonce
claim。用来防止客户端机器时钟不准导致的重放窗口问题。
和 Bearer 相比,DPoP 让攻击者即使拿到 access_token 也无法使用——他没有私钥签不出合法 proof。代价是客户端需要持久化一对非对称密钥并在每次请求签名。
5.3 mTLS sender-constrained token(RFC 8705)
在企业后端到后端的场景里,mTLS(mutual
TLS)是更自然的方案。客户端用 TLS 客户端证书建立连接,AS
把证书指纹 x5t#S256 写进 access_token 的
cnf claim:
{
"cnf": {
"x5t#S256": "bwcK0esc3ACC3DB2Y5_lESsXE8o9ltc05O89jdN-dg2"
}
}RS 校验 TLS 通道上客户端证书的 SHA-256 指纹是否等于该值。不等则拒绝。
mTLS 方案在服务间调用里成本低(TLS 本就在建),适合金融、B2B API;在浏览器场景里体验差,因为用户要管理证书,所以 SPA 走 DPoP 更自然。
5.4 Pushed Authorization Requests(PAR,RFC 9126)
传统 /authorize 请求把所有参数放在 URL
里经过浏览器跳转,参数会出现在浏览器历史、Referer,且长度受限。PAR
让客户端先通过后端通道(TLS + 客户端认证)把授权参数 POST 到
/par 端点,AS 返回一个短寿命的
request_uri,然后浏览器跳转只带这个
request_uri:
POST /par HTTP/1.1
Host: as.example.com
Authorization: Basic c3BhLWRlbW86...
Content-Type: application/x-www-form-urlencoded
response_type=code
&client_id=spa-demo
&redirect_uri=https%3A%2F%2Fapp.example.com%2Fcallback
&scope=openid+orders.read
&state=...
&code_challenge=...
&code_challenge_method=S256
响应:
{
"request_uri": "urn:ietf:params:oauth:request_uri:6esc_11ACC5bwc014ltc14eY22c",
"expires_in": 60
}浏览器跳转:
GET /authorize?client_id=spa-demo&request_uri=urn:ietf:params:oauth:request_uri:6esc_...
好处:授权请求的完整参数从未暴露给浏览器;请求可以非常大(富权限、复杂
scope);request_uri
是一次性、短寿命的,截获无用。FAPI 2.0(Financial-grade
API)把 PAR 列为必选。
5.5 JWT-Secured Authorization Requests(JAR,RFC 9101)
JAR 让客户端把整个 /authorize 参数打包成签名
JWT,用 request 参数传(或
request_uri 指向它):
GET /authorize?client_id=spa-demo&request=eyJhbGciOiJSUzI1NiIs...
JWT 的 payload 里是原本的 OAuth 参数,header 里是签名算法与 kid。AS 用客户端注册时登记的公钥验签,确认参数未被中间篡改。JAR 常与 PAR 一起用——PAR 提供”不暴露给浏览器”,JAR 提供”抗篡改 + 身份证明”。
5.6 Rich Authorization Requests(RAR,RFC 9396)
scope 是粗粒度权限模型,在”转账 10 万元到账号
X”这种细粒度、结构化授权场景里无能为力。RAR 引入
authorization_details
参数,允许客户端声明结构化授权对象:
{
"authorization_details": [
{
"type": "payment_initiation",
"actions": ["initiate", "status"],
"locations": ["https://example.com/payments"],
"instructedAmount": { "currency": "EUR", "amount": "123.50" },
"creditorAccount": { "iban": "DE02100100109307118603" },
"creditorName": "Merchant A"
}
]
}AS 把 authorization_details
展示在同意页上让用户审阅,签发的 access_token
里会带上已授权的细粒度权限。Open Banking(PSD2)和 FAPI 2.0
Baseline 场景广泛使用。
5.7 FAPI 2.0:把这些扩展组合成 profile
上面五个扩展(DPoP、mTLS、PAR、JAR、RAR)各自解决一类问题,单独使用都是可选项。FAPI 2.0(Financial-grade API)Baseline 把它们按金融级要求打包成了一个强制 profile:
- 授权码 + PKCE(S256)必选;
- PAR 必选——授权参数不走浏览器;
- JAR 可选(Advanced Profile 下必选)——授权参数签名;
- sender-constrained token 必选,二选一:mTLS 或 DPoP;
- redirect_uri 精确匹配 + HTTPS 强制;
- token 端点客户端认证用 mTLS 或 private_key_jwt(不允许 client_secret_basic);
- ID Token 必须签名,敏感字段可选择加密(JWE)。
可以把 FAPI 2.0 当作”OAuth 2.1 的金融加强版”。对非金融场景它可能过度,但里面每条要求都有明确威胁模型对应,按需裁剪非常合适作为企业级 IAM 的默认 profile。
六、常见攻击与防御
PKCE 与 OAuth 2.1 关闭了一批攻击面,但协议层面能做的有限,剩下的坑大多落在实现上。
6.1 开放重定向(Open Redirect)
redirect_uri
注入或利用子路径上的开放重定向漏洞,使授权码流转到攻击者控制的页面。防御:
- 严格精确匹配(OAuth 2.1 已要求),不允许前缀或通配;
- 应用内部任何”跳转参数”(
?next=...、?returnTo=...)都要白名单校验; - 对同域下的任何 open redirect 漏洞保持零容忍,因为它们和 OAuth 精确匹配组合起来仍然可能被滥用。
6.2 CSRF:state 参数必须存在且校验
客户端必须为每次授权生成强随机
state,并在回调中校验其与本次会话绑定。缺少校验会允许攻击者把自己的授权回调”注入”到受害者的会话,使受害者在不知情的情况下绑定了攻击者的账户。OAuth
2.1 要求 state 或 PKCE
至少存在其一,但两者最好同时存在——PKCE 防拦截,state 防
CSRF,用途不同。
6.3 授权码重放
code 必须:
- 单次使用——兑换过一次立即失效;
- 短寿命(RFC 建议最多 10 分钟,实际推荐 60 秒);
- 与 client_id、redirect_uri、code_challenge 同时绑定。
如果 AS 没做单次消费,攻击者截获一次兑换的 HTTPS 请求元数据(即便解不出内容)也可能在短时间内重放。成熟实现通常采用”兑换即删除 + 一次失败即撤销整个 code”的双保险。
6.4 Token 泄露路径全景
梳理一遍 access_token / refresh_token 的泄露路径:
- URL fragment / query:OAuth 2.1 已禁止;
- Referer header:含 token 的 URL 被外链资源带出;禁止把 token 放 URL 就绝大部分解决;
- 浏览器历史:同上;
- localStorage:跨标签、跨会话共享,XSS 下全部拿走;优先用内存或 HttpOnly Cookie;
- Server access
log:反向代理、应用日志记录 Authorization
header;部署层应显式 mask
Authorization与DPoPheader; - Crash dumps / APM:错误采集工具未过滤敏感 header;
- Browser dev tools:社工攻击面;短寿命 + refresh rotation 缓解。
6.5 Mix-Up Attack
多 IdP 场景下,客户端同时支持多个 AS(例如”用 Google
登录”和”用 Microsoft 登录”)。攻击者诱导受害者点击”Google
登录”,但把授权请求偷偷送到攻击者控制的 AS;AS 把 code
回调到合法客户端的 redirect_uri;客户端不知道这个 code
属于哪个 IdP,默认送到 Google 的 /token
端点——结果要么泄露攻击者的 code 给
Google(低危),要么客户端拿攻击者账户的 token
当受害者处理(高危,导致账户合并漏洞)。
RFC 9207(OAuth 2.0 Authorization Server Issuer
Identification)要求 AS 在回调中返回 iss
参数,客户端校验 iss 与期望 AS
一致后再去兑换。另外 PAR + JAR
的组合也能缓解,因为授权请求与 AS 绑定。
6.6 Downgrade:client_secret 与 PKCE 同时不在
一些旧 AS 在同时支持 confidential 与 public client
时,如果客户端不提交 client_secret 也不提交
code_verifier,会错误地”跳过”验证。修复是在 AS
上强制——只要客户端是 public 类型必须有 verifier,只要是
confidential 类型必须有 secret;对开启了 PKCE 的
confidential client 两者同时校验。
6.7 refresh_token 永久有效
public client 的 refresh_token 如果不过期不 rotate,一次泄露等于终生失守。运营层面需要:
- rotation + chain 撤销;
- 绝对超时(sliding window + absolute expiry,例如 30 天);
- 与设备、IP、UA 指纹弱绑定,异常时触发重新认证;
- 提供”从所有设备登出”的撤销入口。
6.8 CSP 与 XSS:前端侧的兜底
所有浏览器侧的 OAuth
方案最终都依赖浏览器本身的同源与脚本隔离。XSS 一旦发生,无论
token 放在哪里(localStorage、内存、甚至 HttpOnly Cookie
借助 fetch 调用)攻击者都能以用户身份调用
API。协议层无法修复 XSS,只能在前端工程侧做以下组合:
- 严格 CSP(Content Security
Policy):
script-src 'self',禁用unsafe-inline和unsafe-eval;第三方脚本用 SRI(Subresource Integrity)hash 固定; - 框架层默认转义,不手动
innerHTML拼接用户输入; - 对 iframe 嵌入用
frame-ancestors 'none'或显式白名单,防止 clickjacking 诱导同意页; - BFF 架构里 API 走
SameSite=StrictCookie,避免跨站携带。
这些不是 OAuth 规范要求,但缺了它们 PKCE/DPoP 的保护会被轻易绕过。
6.9 同意页欺骗与 phishing
攻击者在合法 AS 下注册一个看起来像官方的 client,诱导用户授权大权限;或者把 client name 改成 Unicode 混淆字符骗过同意页。防御在 AS 侧:
- client 注册需要人工审核或域名校验;
- 同意页显示 client 的已验证域名与 logo(OIDC Federation
或
software_statementJWT 签名验证); - 对高危 scope(全量读写、管理员权限)要求二次确认与延迟生效;
- 出现异常 scope 组合时触发风控(例如 “只读应用突然申请写权限”)。
七、工程坑点
真实工程落地中,协议合规只是起点,下面这些是反复踩过的坑。
- SPA 用 localStorage 存 access_token:任何页面的 XSS 都能偷走全部 token。宁可用内存 + BFF,或者 HttpOnly + SameSite=Lax 的 session cookie。
- 把 verifier 写到 localStorage:比 token 更危险——verifier 泄露等于 PKCE 失效。只能放 sessionStorage 或内存,兑换后立刻 delete。
- 忘记校验
state:大量开源 OAuth 中间件默认开 state 但允许关,线上关掉以后出过账号合并漏洞。 - redirect_uri 注册表带通配:一些 AS
界面允许
https://*.example.com,这是历史包袱;生产上逐条列出精确 URL,哪怕 50 条。 - AS 不强制 S256:让客户端能选 plain,攻击者 MITM 改掉方法绕 PKCE;AS 配置侧白名单 method=S256。
- code 单次消费未原子化:高并发下用
SELECT + DELETE两步会出现双兑换;用DELETE ... RETURNING或 RedisGETDEL保证原子。 - 错误回显带 URL:5xx 页面原样显示
Location或Referer,让 code / token 出现在错误页;统一做脱敏。 - DPoP proof 缺少
ath:没绑定 access_token 时 proof 可被挪用到不同 token;RFC 9449 已把ath改为要求,实现必须带上。 - DPoP 的 nonce 缓存不够分布式:多实例 RS 之间不共享 jti 集合,攻击者可以跨实例重放;用 Redis / Memcached 做共享存储,TTL = proof 有效期 + 容忍抖动。
- refresh_token rotation 与多标签冲突:SPA 同时打开两个标签并发刷新,一个拿到新 token、一个旧 refresh_token 被标为泄露触发 chain revoke。解法:BFF 里加互斥锁;或者前端单例刷新。
- OIDC discovery 未 pin:信任
/.well-known/openid-configuration的所有 endpoint 字段,攻击者劫持后把token_endpoint指到他自己的服务器。需要 HSTS + 证书 pinning + 敏感 endpoint 硬编码。 - mobile 用 WebView 做授权:WebView 可被宿主 App 注入脚本读密码,RFC 8252 禁止;改用系统浏览器或 ASWebAuthenticationSession / Chrome Custom Tab。
八、选型建议
新项目直接选择 OAuth 2.1 + 授权码 + PKCE,没有第二个值得考虑的主路径。下面按场景给出具体推荐。
- 纯 Web 应用(有后端):授权码 + PKCE,token 全部保存在后端 session 里,浏览器只持有 session cookie。简单、安全、运维成本最低。
- SPA + 独立 API:优先 BFF 模式——前端和后端部署在同一域名,前端通过 session cookie 访问 BFF,BFF 持有 access_token 调 API。如果实在没有 BFF 条件,授权码 + PKCE + refresh rotation + 短寿命 access_token,access_token 放内存,refresh 放 HttpOnly Cookie(仍然建议后端做)。
- iOS / Android:RFC 8252 + PKCE + Universal Links / App Links。用 AppAuth 库(iOS/Android 官方维护)。
- 桌面 App / CLI:授权码 + PKCE,回调用
http://127.0.0.1:随机端口(RFC 8252 §7.3 允许且推荐)。 - Server-to-Server / Workload:client_credentials + mTLS sender-constrained,或者 JWT bearer assertion。PKCE 在此无意义。
- IoT 设备 / TV:device authorization grant(RFC 8628)。
- 金融级 / 开放银行:FAPI 2.0 Baseline:授权码 + PKCE + PAR + JAR + DPoP 或 mTLS;签名 JWT token + RAR 细粒度授权。
- 已存在的 implicit / password 系统:按优先级迁移——先 implicit(安全高危),再 password(可走 OIDC + RP-initiated flow 替换)。迁移期用 AS 的”协议适配层”在内部转换,不让客户端直接暴露在旧协议里。
sender-constrained 选型上,B2C 场景(SPA + 公网 API)选 DPoP,B2B 场景(内部后端互调)选 mTLS,两者不冲突可共存。
九、参考资料
- RFC 6749:The OAuth 2.0 Authorization Framework
- RFC 6750:The OAuth 2.0 Authorization Framework: Bearer Token Usage
- RFC 7636:Proof Key for Code Exchange by OAuth Public Clients(PKCE)
- RFC 8252:OAuth 2.0 for Native Apps(BCP 212)
- RFC 8628:OAuth 2.0 Device Authorization Grant
- RFC 8705:OAuth 2.0 Mutual-TLS Client Authentication and Certificate-Bound Access Tokens
- RFC 9101:The OAuth 2.0 Authorization Framework: JWT-Secured Authorization Request(JAR)
- RFC 9126:OAuth 2.0 Pushed Authorization Requests(PAR)
- RFC 9207:OAuth 2.0 Authorization Server Issuer Identification
- RFC 9396:OAuth 2.0 Rich Authorization Requests(RAR)
- RFC 9449:OAuth 2.0 Demonstrating Proof of Possession(DPoP)
- draft-ietf-oauth-v2-1:The OAuth 2.1 Authorization Framework
- draft-ietf-oauth-security-topics:OAuth 2.0 Security Best Current Practice
- draft-ietf-oauth-browser-based-apps:OAuth 2.0 for Browser-Based Apps
- FAPI 2.0 Baseline / Advanced Profile(OpenID Foundation)
- OAuth 2.0 入门
- PKCE 基础
- 企业单点登录:OIDC 与现代 SSO
- 身份与认证架构总览
同主题继续阅读
把当前热点继续串成多页阅读,而不是停在单篇消费。
身份与访问控制工程
从 OIDC、OAuth 2.1、SAML、SCIM 到多租户权限、CIAM、PAM 与身份平台选型——系统拆解现代身份与访问控制的协议、架构与工程实践。
【身份与访问控制工程】企业单点登录:OIDC 与现代 SSO
B2B 大客户的安全团队只认自家 Okta,集团五条产品线要统一账号,合规团队要求所有入口都可审计——这些需求最终都落在同一个协议上:OpenID Connect。本文从一个真实的商业谈判场景切入,系统拆解 OIDC 在 OAuth 2.0 之上增加了什么、授权码流程的每一个字段为什么存在、企业集成里最常见的六类实现坑,以及 Discovery、JWKS、Dynamic Client Registration、mTLS Client Auth 的工程细节,帮助你把 SSO 从 demo 做到可以放进 SOC 2 审计报告里。
【身份与访问控制工程】SAML 还值得学吗:企业遗留 SSO 的现实世界
SAML 2.0 是 2005 年的协议,却仍活在每一个和银行、保险、制造业做生意的 B2B SaaS 后台——本文从一封客户邮件开始,拆解 SAML 断言结构、SP-initiated 流程、Metadata、证书轮换与 XML 签名包装攻击等现实工程问题
【身份与访问控制工程】RBAC、ABAC、ReBAC:权限模型怎么选
从角色爆炸问题切入,深入对比 RBAC(NIST 四级模型)、ABAC(XACML)、ReBAC(Zanzibar 启发)三种权限模型的数据结构、SQL 建模、策略表达能力与工程取舍,给出混合模型实践与选型决策树。