移动应用接入第三方登录时,几乎都会用到OAuth 2.0的授权码模式。但传统授权码模式在设计之初是面向服务端应用的,这类应用能够安全地保存client_secret。移动端应用属于公开客户端,代码打包在APK或IPA里,密钥一旦写进客户端就等于公开了。授权码劫持攻击正是利用了这个弱点:攻击者注册一个恶意应用抢占自定义scheme,截获回调中的授权码,然后在真正的应用之前用它换取令牌。PKCE(Proof Key for Code Exchange)就是针对这个问题提出的标准方案,下面从攻击原理、工作机制和落地实现三个层面展开说明。

授权码劫持到底是怎么发生的
理解PKCE之前,先要弄清楚攻击的具体过程。标准的授权码流程中,客户端向授权服务器发起请求,用户完成登录授权后,授权服务器会把授权码附加在redirect_uri上,通过系统浏览器回调到应用。在Android上,这个回调依赖自定义scheme或App Links;在iOS上则依赖URL Scheme。问题就出在scheme的注册机制上:系统允许多个应用注册相同的scheme,当回调发生时,由操作系统决定由谁响应。
攻击者可以写一个恶意应用,注册与目标应用完全相同的URL Scheme。当用户在浏览器中完成授权,系统弹出回调时,恶意应用有可能抢先接收到这个带授权码的回调。拿到授权码后,如果客户端不需要client_secret(移动端通常如此),攻击者就能直接用它换取access_token,整个过程用户毫无感知。RFC 8252明确指出,原生应用不应使用嵌入式WebView,且必须配合PKCE使用授权码流程,原因就在这里。
需要注意,这种攻击不要求恶意应用获取任何特殊权限,只要能注册scheme就够了。Google早在OAuth 2.0增强版中就强制所有移动端应用使用PKCE,OAuth 2.1草案更是直接把PKCE写进了授权码流程的强制要求。如果你的移动端项目还在用不带PKCE的授权码模式,或者干脆用隐式模式,安全隐患是实实在在的。
PKCE的工作机制详解
PKCE的核心思路是:让客户端在发起授权请求时先承诺一个随机值,在换取令牌时再证明自己确实持有这个值。具体分成两个角色:code_verifier是客户端本地生成的高熵随机字符串,长度在43到128个字符之间,只使用URI安全字符集;code_challenge是对verifier做转换后的结果,随授权请求发送给授权服务器。转换方式有两种,plain表示直接传原文,S256表示对verifier做SHA-256哈希后再做Base64URL编码。生产环境必须使用S256,plain方式只是兼容性兜底。
整个流程分四步。第一步,客户端生成code_verifier并计算出code_challenge,发起授权请求时附带code_challenge和code_challenge_method两个参数。第二步,用户完成登录授权,授权服务器记录下这个challenge,然后照常把授权码回传。第三步,客户端用授权码换取令牌时,请求中必须携带原始的code_verifier。第四步,授权服务器收到请求后,对verifier执行同样的S256转换,与之前记录的challenge比对,一致才发放令牌。
这个机制为什么能防住劫持?因为攻击者截获的只是授权码,而恶意应用无法读取真正应用的内存或私有存储,自然拿不到code_verifier。即使攻击者抢到了授权码并抢先发起令牌请求,由于它提供的verifier算不出匹配的challenge,授权服务器会直接拒绝。反过来,如果攻击者自己发起授权请求并使用自己的verifier,那么截获的授权码对应的是它自己的会话,与受害用户的账号无关,攻击同样无法得逞。也就是说,PKCE把授权码和持有秘密的客户端绑定在了一起。
移动端实现示例与常见坑
先看客户端如何生成verifier和challenge。以下代码适用于Android、iOS以及跨平台框架,逻辑完全一致:
// 生成43-128位的随机code_verifier
SecureRandom secureRandom = new SecureRandom();
byte[] code = new byte[64];
secureRandom.nextBytes(code);
String codeVerifier = Base64.getUrlEncoder().withoutPadding().encodeToString(code);
// 计算S256方式的code_challenge
MessageDigest digest = MessageDigest.getInstance("SHA-256");
byte[] hash = digest.digest(codeVerifier.getBytes(StandardCharsets.US_ASCII));
String codeChallenge = Base64.getUrlEncoder().withoutPadding().encodeToString(hash);
发起授权请求时,把code_challenge拼进授权URL,例如https://auth.example-server.com/authorize?response_type=code&client_id=mobile-app&redirect_uri=com.myapp%3A%2F%2Fcallback&code_challenge=xxxx&code_challenge_method=S256。用户授权回调拿到code后,在令牌请求中携带verifier:
POST /token HTTP/1.1 Host: auth.example-server.com Content-Type: application/x-www-form-urlencoded grant_type=authorization_code &code=收到的授权码 &redirect_uri=com.myapp%3A%2F%2Fcallback &client_id=mobile-app &code_verifier=之前生成的原始verifier
实现过程中有几个常见的坑。第一,Base64URL编码不等于标准Base64,URL安全的编码使用连字符和下划线,且要去掉填充的等号,很多新手用错编码导致校验失败。第二,verifier必须存在内存或应用的私有存储中,不要放进Intent extras或日志。第三,每次授权请求都要重新生成verifier,绝不复用旧值。第四,code_challenge_method参数不要省略,部分服务器会默认按plain处理。第五,Android 12及以上系统提供了Intent.FLAG_ACTIVITY_MATCH_EXTERNAL等机制,建议同时使用App Links替代普通自定义scheme,两者叠加防护效果更好。第六,code_verifier只在换取令牌时使用一次,令牌拿到后应立即丢弃。
授权服务器端的配置要点
客户端做了PKCE还不够,授权服务器必须正确支持才行。对于自建的授权服务器,需要在校验令牌请求时实现S256验证逻辑,并在数据库中为每个授权码记录对应的challenge。如果使用开源组件,主流方案都已经支持:Spring Authorization Server在客户端配置中启用requireProofKey(true)即可;Node生态的node-oidc-provider默认开启了PKCE支持,还会强制公开客户端必须携带challenge;IdentityServer对移动端客户端的配置中也提供了相应选项。
配置时要坚持一个原则:对公开客户端强制要求PKCE,且只接受S256方式。有的团队为了兼容老版本客户端保留了plain方式,这会大幅削弱防护强度,因为plain方式下challenge本身就是verifier,截获到请求参数的攻击者可以直接复用。正确的做法是发布新版本客户端完成强制升级,服务器端彻底关闭plain。此外,授权码的有效期应控制在30秒到60秒之间,并且严格一次性使用,配合PKCE形成双保险。
最后补充一点令牌存储上的建议。PKCE解决的是授权码被劫持的问题,但令牌本身的存储安全同样重要。移动端应使用Android Keystore或iOS Keychain保存refresh_token,条件允许的情况下优先选择基于安全硬件的证明机制,例如Android的App Attest和iOS的DeviceCheck,从设备层面进一步确认请求来源可信。安全是纵深防御的工程,PKCE是移动端OAuth链路上必须补齐的第一块短板,但不会是最后一块。