问题:公共客户端为什么不能安全保存 client_secret
在 OAuth 2.0 授权码模式(Authorization Code Grant)中,传统做法是用 client_secret 来证明「换 token 的请求来自合法客户端」。
但移动 App、桌面应用、单页应用(SPA)这类**公共客户端(public client)**无法安全保存 client_secret:
- 移动 App 的代码可以被反编译,硬编码的密钥能被提取;
- SPA 的 JavaScript 完全暴露在浏览器中;
- 桌面应用的二进制文件同样可被逆向。
一旦 client_secret 泄露,攻击者就能伪装成该客户端,配合被窃取的授权码换取访问令牌。
授权码拦截攻击
授权码模式依赖「授权码只对合法客户端有效」这一假设。攻击者若能拦截授权码,就可能用它换取 token。常见场景包括:
- 移动系统上恶意 App 注册相同的自定义 URL Scheme 来截获重定向;
- 浏览器或系统环境中的其他应用监听回调;
- 授权码出现在日志、Referer 等位置被泄露。
对公共客户端来说,光有授权码不足以证明「换 token 的请求来自当初发起授权的那个客户端」。
PKCE 的思路
PKCE(Proof Key for Code Exchange,发音近似「pixy」,RFC 7636)引入一个每次授权都重新生成的随机秘密,并把它和授权请求、token 请求绑定起来。
核心是两个值:
code_verifier:客户端生成的高熵随机字符串,全程留在客户端本地,不经过浏览器重定向;code_challenge:由code_verifier计算得到,随授权请求一起发送。
RFC 7636 规定的转换方式有两种:
S256:code_challenge = BASE64URL-ENCODE(SHA256(ASCII(code_verifier)))plain:code_challenge = code_verifier(不推荐,仅用于无法做 SHA-256 的极端受限环境)
关键点:攻击者即使截获了授权码,也拿不到 code_verifier,因此无法构造出合法的 token 请求。
流程步骤
- 客户端生成随机
code_verifier(RFC 7636 建议长度 43~128 个字符,使用[A-Z] / [a-z] / [0-9] / "-" / "." / "_" / "~"字符集)。 - 计算
code_challenge = BASE64URL(SHA256(code_verifier)),并选定code_challenge_method=S256。 - 发起授权请求时带上
code_challenge和code_challenge_method。 - 用户在授权服务器完成授权,重定向回客户端并携带
code。 - 客户端在本地取出
code_verifier,向 token 端点提交code+code_verifier。 - 授权服务器用同样的算法校验
code_verifier与之前收到的code_challenge是否匹配,匹配才签发 token。
为什么公共客户端必须用
- 弥补没有 client_secret 的缺口:PKCE 让每次授权都有一次性秘密,攻击者无法复用被截获的授权码。
- 防御授权码注入:攻击者用自己账号的授权码注入受害者客户端时,因为拿不到对应的
code_verifier,换 token 会失败。 - 成本低、收益高:只需两端各加几个参数和一次哈希计算,无需改动整体架构。
实践建议
- 公共客户端优先使用
S256,避免plain。 code_verifier必须使用密码学安全的随机数生成器,且每次授权都重新生成,不要复用。- 授权服务器对公共客户端应要求 PKCE;对机密客户端,当前最佳实践(OAuth 2.0 Security Best Current Practice)也建议使用 PKCE。
- PKCE 不能替代
state参数:state用于防 CSRF 和关联请求,两者作用不同,建议同时使用。 - 结合精确的重定向 URI 白名单,避免开放重定向带来的额外风险。
PKCE 不是可选项,而是公共客户端在授权码模式下的基础防线。