授权码流程把访问令牌的交付移到令牌端点,而不是在面向浏览器的授权重定向里直接返回访问令牌。这能减少令牌暴露在地址中的机会,却不代表浏览器绝不可能持有令牌。究竟由谁兑换授权码,以及结果保存在哪里,取决于客户端架构。审阅时应区分协议步骤、客户端类型和实际存储位置,不能把一种部署方式当成整个流程的定义。
公共客户端也能使用授权码流程
机密 Web 后端可以向令牌端点进行客户端认证,并把令牌保存在服务端。浏览器随后使用应用自己的会话,但会话安全仍需单独处理。原生应用和浏览器应用等公共客户端无法可靠地保守分发出去的客户端秘密,然而它们仍然可以使用授权码加 PKCE。因此,授权码流程并不是只有后端才能采用的方案。
若浏览器应用自行兑换,JavaScript 就会收到令牌响应。流程避免了在授权跳转地址里交付访问令牌,却不会消除脚本被入侵或令牌存储泄露的问题。后端架构改变了边界,并没有让所有浏览器侧风险消失。OAuth 解决委托访问;需要确认用户身份的应用,应采用 OpenID Connect 等适合的身份协议,并验证其中独立的身份凭据,不能把任意访问令牌直接当作登录证明。
把兑换绑定到原始请求
PKCE 为每次事务生成高熵校验串。授权请求携带它的 S256 挑战,令牌请求再提供原校验串,授权服务器检查二者是否对应。校验串不是固定的客户端口令,必须与发起它的事务关联。这样,只有截获授权码、却没有校验串的一方,不应能够完成兑换。日志和错误报告也不应同时泄露这两部分。
RFC 9700 要求公共客户端使用 PKCE,并建议机密客户端也使用。应选择 S256,避免降级成 plain。机密客户端仍需执行已配置的客户端认证,PKCE 不能代替这一职责。客户端认证也不一定意味着发送共享秘密;部署可以采用非对称认证机制。判断安全性时,要核对服务端实际执行的验证,而不是只看请求里出现了几个参数。
每枚授权码都应绑定客户端与回调地址,设置短有效期并限制只能使用一次。登记的回调地址通常必须精确匹配;原生应用的回环地址端口存在标准规定的例外。授权请求携带 redirect_uri 时,令牌请求中的值必须一致。在授权阶段就要拒绝未登记的回调,而不是等到授权码已泄露之后才靠兑换失败补救。
坏掉的样子
一次性并不能阻止攻击者抢先兑换一枚没有充分保护的授权码。过期只是缩短机会,不会让截获变得无害。回调中的授权码仍可能出现在访问日志、浏览器历史、来源信息和崩溃报告里。避免记录这些值,使用合适的 HTTPS 回调;开发原生客户端时,则按照原生回环重定向的专门规则处理,不要把例外扩展到任意明文回调。
客户端必须防止回调 CSRF 与授权码注入。事务状态应安全绑定发起请求的浏览器会话,并根据协议与提供方保证,正确组合 state、PKCE 或 OpenID Connect 的相应保护。随机 state 如果没有会话绑定,仍然不足。支持多个授权服务器时,还需防止签发者混淆;兑换成功本身,不能证明响应一定来自原本预期的身份提供方。
刷新令牌有独立的生命周期。对于公共客户端,安全最佳实践要求使用发送者约束,或者采用刷新令牌轮换。带重用检测的轮换是一种方案,并不意味着每枚没有变化的刷新令牌都无限有效。过期、撤销、不活跃时间限制和客户端绑定,共同决定真实的泄露窗口。不能只凭令牌字符串是否变化,就宣布整个刷新流程安全或失守。
快速验证
保存为 oauth_code_demo.py,执行 python3 oauth_code_demo.py。模型完全离线且单线程运行,使用合成客户端、明确登记的回调、每次新生成的随机授权码以及注入的时间。辅助函数接收校验串是为了在本地计算挑战;真正的授权请求只能发送挑战,不能把校验串一起送出去。程序不读取用户账号,也不调用任何身份提供方。
import base64
import hashlib
import hmac
import re
import secrets
REGISTERED = {'demo-client': 'https://app.example.test/callback'}
CODES = {}
NOW = 1_800_000_000
def challenge(verifier):
if not re.fullmatch(r'[A-Za-z0-9._~-]{43,128}', verifier):
raise ValueError('invalid verifier syntax')
return base64.urlsafe_b64encode(hashlib.sha256(verifier.encode('ascii')).digest()).rstrip(b'=').decode()
def authorize(client, redirect, verifier, now):
if REGISTERED.get(client) != redirect:
raise ValueError('unregistered redirect')
code = secrets.token_urlsafe(32)
CODES[code] = {'client': client, 'redirect': redirect, 'challenge': challenge(verifier),
'expires': now + 60, 'used': False}
return code
def exchange(code, client, redirect, verifier, now):
entry = CODES.get(code)
if entry is None:
raise ValueError('unknown code')
if entry['used']:
raise ValueError('code already used')
if now >= entry['expires']:
raise ValueError('code expired')
if client != entry['client'] or redirect != entry['redirect']:
raise ValueError('client or redirect mismatch')
if not hmac.compare_digest(challenge(verifier), entry['challenge']):
raise ValueError('PKCE verifier mismatch')
# Atomic only because this demonstration runs sequentially in one thread.
entry['used'] = True
return 'DEMO-' + secrets.token_urlsafe(32)
def rejected(label, operation):
try:
operation()
except ValueError:
print('rejected:', label)
return
raise RuntimeError('unexpected acceptance: ' + label)
client = 'demo-client'
redirect = REGISTERED[client]
verifier = secrets.token_urlsafe(32)
code = authorize(client, redirect, verifier, NOW)
rejected('wrong client', lambda: exchange(code, 'other-client', redirect, verifier, NOW))
rejected('wrong redirect', lambda: exchange(code, client, redirect + '/extra', verifier, NOW))
rejected('wrong verifier', lambda: exchange(code, client, redirect, 'x' * 43, NOW))
if not exchange(code, client, redirect, verifier, NOW).startswith('DEMO-'):
raise RuntimeError('valid exchange failed')
rejected('replay', lambda: exchange(code, client, redirect, verifier, NOW))
stale = authorize(client, redirect, verifier, NOW)
rejected('exact expiry', lambda: exchange(stale, client, redirect, verifier, NOW + 60))
rejected('unregistered callback', lambda: authorize(client, 'https://other.example.test/', verifier, NOW))
print('PASS: bound code accepted once; six negative cases rejected')
预期打印六条拒绝结果和 PASS。正确兑换只成功一次。错误客户端、改变后的回调和错误校验串在消耗授权码之前失败;成功之后再次兑换则被拒绝。另一枚码恰好在到期时失败,未登记回调在签发前就被拒绝。代码和令牌既不打印,也不传输,所有状态仅在这次实验的内存中存在。
模型刻意比真实授权服务器简单。顺序执行的检查和标记操作没有测试并发;生产存储必须原子地消费授权码。示例还省略了 HTTP 处理、用户授权、客户端认证、浏览器会话绑定、签发者检查及刷新令牌处理。可把这些用例转成隔离配置中的集成测试要求,但通过内存实验不等于已经验证完整登录旅程,也不意味着可以直接部署这段代码。
延伸阅读
来源
- RFC 6749: OAuth 2.0,授权码绑定与令牌兑换,已核验 2026-10-15。
- RFC 7636: PKCE,校验串语法与 S256 挑战,已核验 2026-10-15。
- RFC 9700: OAuth 2.0 Security Best Current Practice,公共客户端、重定向、CSRF 与刷新令牌保护,已核验 2026-10-15。
- RFC 8252: OAuth 2.0 for Native Apps,公共原生客户端与回环重定向,已核验 2026-10-15。