Skip to content

授权码、PKCE 与 state 各自防什么 ​

一个第三方应用接入公司的开放平台,按文档实现了授权码流程:跳到授权页、用户同意、拿到 code、换成令牌。安全评审时被问了几个问题:授权码被截获了会怎样?回调地址能不能被改?刷新令牌泄露了能不能发现?团队发现自己说不清楚,流程里每一步「为什么要这样」。

OAuth 2.0 的授权码流程里有一串参数:code、redirect_uri、state、code_challenge、code_verifier、刷新令牌。每一个都在防一种具体的攻击,而且它们是互相绑定的:授权码绑定到客户端、回调地址和 PKCE 挑战,换令牌时逐项核对。理解了这些绑定关系,评审时的问题就有了答案,自己实现客户端时也知道哪些检查不能省。

本文在本地启动 Spring Security 7.1.1 的授权服务器(随 Spring Boot 4.1.1),手工走一遍授权码流程,再逐项发送错误的请求,看授权服务器拒绝了什么。只做防御视角的验证,所有用户、客户端和密钥都是演示数据。实验见文末。

浏览器前通道授权服务器客户端服务端后通道授权请求 + challenge + state重定向:code + statecode 交给客户端code + verifier授权码绑定了什么,换令牌时逐项核对一次性重放:作废整次授权redirect_uri精确匹配,错误不重定向客户端别的客户端换:拒绝PKCE(S256)默认所有客户端都要state 防的是另一个方向:回调被伪造、会话绑定到攻击者的授权;授权服务器只原样带回,校验在客户端公共客户端不签发刷新令牌;默认刷新令牌可反复使用;关闭复用后轮换,但旧令牌被重放时新令牌仍有效,重放检测需要自己实现令牌签发之后:资源服务器校验受众与类型,ID Token 不能当访问令牌所有用户、客户端与密钥都是本地演示数据
图 1 · 实测(Spring Security 7.1.1 授权服务器):授权码经浏览器回到客户端,客户端在后通道用它和 verifier 换令牌。授权码一次性,重放会作废这次授权的所有令牌;回调地址精确匹配,错误不重定向;授权码绑定客户端与 PKCE 挑战,默认对所有客户端要求 S256;state 由客户端生成并自己校验。

一、为什么要有授权码这一步 ​

授权码流程把一次授权拆成两条通道:

  • 前通道:浏览器重定向。授权服务器把用户带回客户端的回调地址,URL 里只带一个短期、一次性的授权码。这条通道经过用户的浏览器,可能被历史记录、日志、恶意扩展看到。
  • 后通道:客户端的服务端直接向令牌端点发请求,用授权码换访问令牌。这条通道不经过浏览器,客户端还要证明自己的身份。

真正有价值的访问令牌只在后通道出现。前通道泄露的授权码本身价值有限,前提是它满足几个条件:只能用一次、只能由申请它的客户端换、只能配合同一个回调地址、并且(有了 PKCE 之后)只有发起授权的那个实例才能换。下面逐项验证这些条件。

text
登录后发起授权请求:302 → https://web.example/callback,带 code,state=s-123
用授权码换令牌:200,access_token 有,refresh_token 有,id_token 有

二、授权码只能用一次,重放会作废整次授权 ​

text
同一个授权码再换一次:400,error=invalid_grant
同一次授权签发的 refresh_token 再用来刷新:400,error=invalid_grant
授权码被重复使用之后,再内省第一次的访问令牌:active=false

第二次兑换被拒绝,而且授权服务器把第一次兑换签发的访问令牌和刷新令牌也一起作废了。这是规范建议的做法:授权码被使用两次,说明它很可能被截获了,此时无法判断哪一次兑换是合法的,最安全的处理是让这次授权签发的所有令牌失效,让用户重新授权。

三、回调地址:精确匹配 ​

text
授权请求的 redirect_uri 未登记:400,error=Bad Request
redirect_uri 只比登记的多一段路径:400,error=Bad Request
换令牌时的 redirect_uri 与授权请求不同:400,error=invalid_grant

回调地址必须和登记的值精确一致。如果授权服务器接受前缀匹配或通配符,攻击者就可以把回调改到同一域名下另一个自己能控制的路径(比如一个开放重定向的页面),让授权码被送到别处。注意错误的处理方式:回调地址不合法时,授权服务器直接返回 400,不会重定向到这个地址去报告错误,否则错误信息本身就成了一次开放重定向。

换令牌时再核对一次 redirect_uri,确保授权码和这次兑换用的是同一个回调。

四、授权码绑定到客户端 ​

text
web 拿到的授权码由 web-rotate 去换:400,error=invalid_grant

授权码记录了是哪个客户端申请的,换令牌时客户端要认证自己(机密客户端用 client_secret),身份不一致就拒绝。这防止另一个应用拿着截获的授权码去换令牌。

五、PKCE:绑定到发起授权的那个实例 ​

客户端认证解决不了一种情况:公共客户端(单页应用、手机 App)没有办法安全地保存密钥,打包在应用里的「密钥」所有人都能拿到。PKCE(RFC 7636)的做法是每次授权都生成一个随机的 code_verifier,把它的 SHA-256 摘要作为 code_challenge 放进授权请求;换令牌时提交原始的 verifier,授权服务器重新计算摘要比对。截获了授权码的人不知道 verifier,就换不出令牌。

text
公共客户端不带 code_challenge:302 → https://spa.example/callback,error=invalid_request(OAuth 2.0 Parameter: code_challenge)
带 S256 挑战,换令牌时给错误的 verifier:400,error=invalid_grant
带挑战,换令牌时不给 verifier:400,error=invalid_client
给正确的 verifier(公共客户端):200,access_token 有,refresh_token 无,id_token 无
code_challenge_method=plain:302 → https://spa.example/callback,error=invalid_request(OAuth 2.0 Parameter: code_challenge_method)

plain 方法把 verifier 原样当作挑战,挑战被截获就等于 verifier 被截获,失去了意义,所以被拒绝。另一个值得注意的结果是,机密客户端不带 PKCE 也被拒绝了:

text
机密客户端(有 client_secret)不带 code_challenge,默认设置:302 → https://web.example/callback,error=invalid_request(OAuth 2.0 Parameter: code_challenge)

Spring Security 7.1 的授权服务器默认对所有客户端要求 PKCE。这和当前的安全最佳实践(RFC 9700)以及 OAuth 2.1 草案一致:PKCE 不只防截获,也防「授权码注入」(攻击者把自己的授权码塞进受害者的会话),机密客户端同样受益。旧的客户端实现如果没有 PKCE,在升级授权服务器之后会直接失败。

公共客户端也没有拿到刷新令牌:这是 Spring Security 授权服务器对公共客户端的默认策略,长期有效的凭据不交给无法保密的客户端。

六、state:防的是另一个方向 ​

PKCE 保护的是授权码不被别人换走。state 保护的是客户端自己:客户端在发起授权前生成一个随机值、存进用户的会话,回调时检查带回来的 state 与会话里的一致。这防止攻击者构造一个回调链接(带着攻击者自己的授权码),诱导受害者点击,让受害者的会话绑定到攻击者的账户上。

text
state 由客户端生成,授权服务器原样带回(本次 state=s-123);校验它是客户端的责任

授权服务器只负责原样带回,检查完全在客户端一侧。这一步最容易被省略,因为流程不检查它也能跑通。PKCE 已经覆盖了一部分这类攻击,但 state 还承担着「这次回调属于哪个发起的请求」的关联作用,实践中两者都用。

七、刷新令牌:复用还是轮换 ​

text
TokenSettings 默认值:reuseRefreshTokens=true
web 用 refresh_token 刷新:200,…;新的 refresh_token 与原来的相同
web-rotate 显式设 reuseRefreshTokens=false:刷新后新的 refresh_token 与原来的不同
轮换之后再用原来的 refresh_token:400,error=invalid_grant
旧的被重放之后,用轮换得到的新 refresh_token:200,access_token 有,refresh_token 有,id_token 有

默认设置下,刷新令牌在有效期内可以反复使用;刷新令牌一旦泄露,攻击者和合法客户端可以同时使用,谁也察觉不到。关闭复用之后,每次刷新都换一个新的,旧的立即失效。

轮换带来了发现泄露的可能:如果一个已经被换掉的刷新令牌又被使用,说明有两方持有同一个令牌。安全最佳实践建议此时作废整个令牌家族。但实验显示,在这个版本的默认实现里,旧令牌被重放只是被拒绝,新令牌仍然有效:攻击者如果先用了偷来的令牌,合法客户端的下一次刷新会失败,反之则攻击者失败,但系统不会因此作废整个家族。需要重放检测时,要在授权服务器一侧自己实现(记录令牌家族、发现重放时撤销),和第二节授权码重放的处理方式对照着设计。

实测:轮换开启RT1刷新RT2RT1 失效刷新RT3仍然成功攻击者重放 RT1400 invalid_grant重放被拒,但 RT2 不受影响最佳实践建议发现重放 = 有两方持有作废整个令牌家族需自行记录家族并撤销默认设置 reuseRefreshTokens=trueRT1每次刷新都还是 RT1合法客户端和攻击者可以同时使用,谁也察觉不到
图 2 · 实测(Spring Security 7.1.1 授权服务器,reuseRefreshTokens=false):合法客户端用 RT1 刷新得到 RT2,RT1 立即失效;旧的 RT1 被重放时返回 invalid_grant,但 RT2 仍然可以刷新。最佳实践建议此时作废整个令牌家族(虚线),这一步要在授权服务器一侧自己实现;默认 reuseRefreshTokens=true 时 RT1 一直可用,泄露了也无从察觉

八、令牌签发之后 ​

text
撤销一个访问令牌之后:内省 active=false;资源服务器用 JWK 本地验签:接受
把 ID Token 当作访问令牌交给只验签的资源服务器解码器:接受(aud=[web])

授权码流程到令牌签发就结束了,剩下的风险在资源服务器一侧:自包含的访问令牌在过期前无法通过撤销立即失效;只验签的资源服务器分不清 ID Token 和访问令牌。资源服务器该校验哪些声明,见访问令牌验签通过之后,还要校验什么。

九、一张对照表 ​

参数或检查防什么谁检查
授权码一次性,重放作废整次授权截获的授权码被重复使用授权服务器
redirect_uri 精确匹配,错误不重定向授权码被送到攻击者控制的地址授权服务器
客户端认证其他应用拿截获的授权码去换授权服务器
PKCE(S256)公共客户端的授权码被截获、授权码注入授权服务器
state回调伪造,会话被绑定到攻击者的授权客户端
刷新令牌轮换与重放检测刷新令牌泄露后长期被使用授权服务器(检测需自行实现)

十、检查清单 ​

  • 所有客户端都使用 PKCE 的 S256 方法,不用 plain。
  • 回调地址精确登记,不用通配符或前缀。
  • 客户端生成随机的 state,存进会话,回调时校验。
  • 不把长期有效的刷新令牌交给公共客户端;需要时使用轮换,并在授权服务器一侧实现重放检测。
  • 资源服务器校验受众与类型,不让 ID Token 冒充访问令牌。

配套实验

参考资料

文章以 CC BY-NC-SA 4.0 授权 · 代码片段以 MIT 授权