刚开始接触 OAuth2 授权码模式的人,十有八九都会卡在同一个问题上:授权服务器明明可以直接发 token,为什么非要先给一个短命的 code,让客户端后端再拿 code 去换 token?多绕这一圈到底图什么?
我第一次看这个流程的时候,也是照着流程图把步骤背下来,但心里一直觉得别扭。直到后来换了个角度——把自己当成 OAuth2 的设计者,从“我要在什么前提下解决什么问题”开始倒推,才真正看明白 code 存在的意义。
这篇是授权码模式系列的第一篇,目标只有一个:帮你把 code 这个东西彻底搞懂。它是什么、为什么必须有它、它在整个流程里是怎么被生产、传递、消费掉的,以及设计者在它身上埋了哪些安全考虑。适合刚接触 OAuth2 的后端开发者、需要对接第三方登录的前端同学,也适合那些已经会调接口但没深究过原理的同行。
1. 先问自己:设计者为什么要发明 code
1.1 授权码模式到底想解决什么问题
先说场景。假设你在做一个应用,用户希望在这个应用里看到自己在某个平台上的头像、昵称、好友列表。你没有那份数据,数据在平台那边,用户已经有了平台的账号。
最原始的做法是让用户把平台账号密码填进你的应用,你的应用拿着账号密码去平台查数据。听起来简单,但放在生产环境里就是灾难:
- 用户的密码等于交给你了,你的服务器一旦被拖库,所有用户的平台密码全部泄露。
- 密码是长期有效的,用户不主动改,这个风险就一直存在。
- 用户没法控制你能拿到哪些数据。密码登录意味着你把全部资源访问权都拿走了,用户只想给你看头像,但你可能顺手把好友列表也拿走了。
- 用户想收回授权的时候,只能改密码。改完密码,你下次登录自然失败,但这属于“一刀切”,你之前拿到的数据还在自己手里。
于是设计者就有了明确的需求:用户不想把密码给别人,但又确实想让他们帮你拉数据;你不想背负存密码的巨大风险,但又确实想拿到用户的资源访问权。授权码模式就是在这种矛盾里被设计出来的。
它的核心思路其实一句话就能说清:把“用户的真实身份凭证”和“你这份应用的访问凭证”彻底分开。用户只把他的授权决定告诉授权服务器,授权服务器给你发一张独立的、可以随时撤销的凭证,你拿这张凭证去访问资源。
1.2 类比:酒店前台发的不是总钥匙
把授权码模式映射到现实生活里,我觉得最贴切的类比是酒店访客通行卡。
住酒店的客人,进房间用自己那张房卡,这就是用户的密码。前台工作人员不能让每个访客都随便拿总卡,那样风险太高。于是访客道前台报备,前台核实身份后,发一张临时卡,这张临时卡只能进指定楼层、指定时间段内有效、用一次或者一天后作废。这就是授权码模式里 code 的雏形。
设计者没有让第三方应用去复制总卡(也就是直接共享密码),而是让授权服务器把“临时访问凭证”交给第三方。这张临时凭证就是 token。但这里又冒出一个新问题:既然是访客,前台可以直接把临时卡给访客本人吗?在现实生活中完全可以,但在 Web 场景里,直接这么做会有个致命隐患——凭证的传输路径不安全。
1.3 为什么授权服务器不能直接把 token 给浏览器
这是理解 code 的重中之重。
授权码模式里,用户是在浏览器里完成登录和授权的。也就是说,授权服务器要回传结果,必然要经过用户浏览器。那直接把 access_token 放在回调 URL 里重定向给浏览器行不行?行,但非常不安全。
access_token 在有效期内就是一把万能钥匙,它能直接访问资源。而访问 token 一旦出现在浏览器地址栏,会留下这些痕迹:
- 浏览器历史记录里会记录包含 token 的完整 URL。
- 如果页面里引用了其他域名下的资源,Referer 请求头可能把完整 URL 带出去。
- 用户机器上的各种浏览器插件、企业终端监控、日志采集软件,都可能把地址栏 URL 采集走。
- 用户在公共电脑上登录完,别人翻一下历史记录就能拿到 token。
浏览器环境本质上是一个不受授权服务器完全控制的客户端环境。设计者的目标很明确:凡是不能长期安全暴露在浏览器环境里的东西,就不能出现在重定向 URL 上。
于是 code 出现了。code 是一张临时凭证,设计者在它身上加了三重保险:
- 有效期极短,通常是几十秒到几分钟。
- 只能兑换一次,兑完立即作废。
- 它自身不能直接访问任何资源,必须配合客户端的密钥(client_secret),在客户端后端的受信环境里才能换取 access_token。
换句话说,即使 code 在浏览器侧被偷了,只要攻击者没有同时拿到 client_secret,这个 code 的价值就非常有限,而且很快就会过期。
现在再回头看整个流程,你会觉得 code 并不是多此一举,它是设计者在“浏览器不安全”和“需要回传凭证”这两个约束下,精心设计的一个中间凭证层。
2. 授权码模式的核心框架和关键参数
2.1 四个角色,边界必须清晰
OAuth2 授权码模式里一共四个角色,很多人看流程时容易被绕晕,本质是因为没有把角色的边界想清楚。
| 角色 | 配置说明 |
|---|---|
| 资源所有者 | 就是用户,拥有资源,能决定是否授权 |
| 客户端 | 第三方应用,可能是前端加后端整体,也可能是纯后端 |
| 授权服务器 | 负责认证用户、询问授权、颁发 code 和 token |
| 资源服务器 | 保存用户数据,验证 token,返回资源 |
我在实际项目里最容易踩的坑,是把“客户端”当成“系统”。客户端在前端浏览器里发起跳转的那段,和后端拿 code 换 token 的那段,虽然都属于同一个应用,但在 OAuth2 模型里它们的安全边界完全不同。
前端的职责是引导用户跳转、接收回调结果;后端的职责是保存 client_secret、发送 token 请求。设计者的所有流程安排,都在围绕“如何让前端暴露尽可能少的安全敏感信息”。
2.2 授权码模式的关键参数逐个拆解
先看一次标准授权请求 URL 长什么样。下文的内容都围绕下面这个例子展开:
https://auth.service.com/authorize?response_type=code&client_id=app123&redirect_uri=https%3A%2F%2Fclient.cn%2Fcallback&scope=profile&state=abc123- response_type=code:声明本次授权流程希望得到的是授权码,而不是 token。这是授权码模式与隐式模式的分水岭。设计者通过这个参数告诉授权服务器:我接受先发 code,后续再换 token。
- client_id:客户端的公开身份证号。它不是密码,它允许出现在前端 URL 里。客户端注册时由授权服务器分配。
- redirect_uri:授权完成后跳回的地址。这个参数极其关键,后面我会专门说它的安全校验。
- scope:请求的权限范围,比如只需要 profile 头像昵称,还是需要 friends 好友列表。用户授权页上显示的“允许访问哪些数据”,就是根据 scope 生成的。
- state:防 CSRF 的随机字符串。通俗说就是客户端在跳转前自己生成的一个暗号,回调时再对一下。
授权码模式中最重要的参数其实是 code 本身。设计者给 code 定的规矩是:
- 随机生成,不可预测。通常使用加密安全随机数。
- 与特定授权请求绑定:必须绑定当时的 client_id、redirect_uri、scope 和用户标识。
- 一次性:每次兑换 token 后立即失效。
- 短时效:一般几分钟内有效,超过直接拒绝。
2.3 code 的设计哲学:临时、绑定、一次性
为什么设计者要把 code 设计成“临时性的绑定凭证”,而不是一个自包含的加密串?
access_token 往往是自包含的,比如 JWT,token 里直接带用户信息,资源服务器只要验签就能用。但 code 不需要自包含,它只需要是一串随机数,授权服务器在服务端保存这个随机数与授权请求的绑定关系。
这个设计让 code 具备了三个工程上特别舒服的特性:
- 无法被推测出用户信息。code 本身不含任何用户数据,拿到 code 看不到用户是谁。
- 可以强制绑定 redirect_uri 和 client_id。如果回调地址和请求时不匹配,兑换阶段直接拒绝。
- 失效机制简单。设置过期时间、标记已使用,两个字段就能控制。
我在自研授权服务器的时候,存储 code 的结构基本就是一张小表:
| code | client_id | user_id | redirect_uri | scope | expires_at | used |
|---|---|---|---|---|---|---|
| x8k2d... | app123 | user001 | https://client.cn/callback | profile | 几分钟后 | false |
设计者没有让 code 携带业务信息,反而让它像一个“抽屉钥匙”:只有授权服务器才知道这把钥匙对应的抽屉里放了什么。这也让后续的撤销、审计、排查都变得非常方便。
3. 授权码模式完整流程:从用户点击登录到拿到 token
3.1 第一步:客户端引导用户跳转授权页
用户在你的应用里点击“第三方账号登录”,你的后端生成两个东西:
- 一个随机的 state 值,存到会话里,比如加密 cookie 或服务端 session。
- 目标授权 URL,把上面的参数拼接好。
常见做法是后端返回一个 302 重定向,前端跟着跳。URL 中的 state 此时一定要带上,因为后面对不上号时,流程必须终止。
这个阶段最容易犯的错是把 state 作为固定的静态字符串写死在代码里。state 必须是每次请求都不一样的随机值,否则 CSRF 防护就是摆设。
跳转到授权服务器之后,用户会看到登录页。注意,这个登录页属于授权服务器,与你的应用没关系。用户输入的是他在授权服务器上的账号密码,而不是你应用的密码。
3.2 第二步:用户认证并且确认授权
用户在授权服务器登录成功后,授权服务器需要让用户确认本次授权范围。页面上会展示:某应用(client_id 对应的应用名)请求访问你的头像、昵称,是否允许。
这里有个容易被忽略的细节:授权服务器生成 code 的前提,是用户已经“认证”并“同意”。认证解决用户是谁,同意解决你是否允许该客户端访问。两者缺一不可,所以 step2 实际上是两个动作。
user 点击“允许”后,授权服务器执行以下逻辑:
- 校验 client_id 是否存在、redirect_uri 是否在白名单内。
- 生成一个加密安全的随机 code。
- 在服务端存储 code 与用户、客户端、重定向地址、scope 的绑定关系,写入过期时间。
- 构造重定向 URL,把 code 和原 state 一起拼到 redirect_uri 上。
这整段逻辑中,授权服务器唯一会主动做的检查就是 redirect_uri 的精确匹配。如果客户端传来的 redirect_uri 不在注册白名单里,直接拒绝,不给 code。
3.3 第三步:浏览器带着 code 回调客户端
授权服务器向浏览器返回 302:
Location: https://client.cn/callback?code=x8k2d...&state=abc123这个重定向会经过浏览器。所以 code 会短暂暴露在地址栏里,这就是设计者为什么不敢直接放 token 的现场。好在这个 code 只有几分钟寿命,而且单靠它换不了 token。
回到你的应用回调接口后,前端要做的事只有一件:把 code 和 state 取出来,交给后端。state 的校验必须在这里做:对比 session 中保存的 state 与回调参数中的 state 是否一致,不一致说明流程异常,立即终止。
这里如果校验失败,大概率是两类问题:一是用户打开的授权链接是别人伪造的,二是用户浏览器里存在前一次流程残留的旧 state。无论如何,终止流程是最稳妥的做法。校验 state 时建议使用恒定时间比较函数,比如 Ruby 的 secure_compare、Node 的 timingSafeEqual、Python 的 hmac.compare_digest,避免时序侧信道。
3.4 第四步:后端拿着 code 换 token
前端拿到 code 后把它传给后端,真正的重头戏才刚开始。后端拿着 code,向授权服务器发起一个 token 请求。下面是一个典型的 token 请求格式:
POST /token HTTP/1.1 Host: auth.service.com Content-Type: application/x-www-form-urlencoded grant_type=authorization_code&code=x8k2d...&redirect_uri=https%3A%2F%2Fclient.cn%2Fcallback&client_id=app123&client_secret=secret789这个请求为什么必须由后端发出,而不是由前端直接通过 ajax 发过来?关键就在于参数里的 client_secret。
用户在你的应用浏览器里看到的任何东西,都等于公开给所有人的东西。client_secret 一旦暴露,任何人都能冒充你的客户端去走授权流程,这是灾难性的。因此这个请求必须在你的后端服务器上发出,client_secret 只存在环境变量或配置中心里。
再说 grant_type,这里固定是 authorization_code。它告诉授权服务器:我提交的是授权码,请按授权码流程给我换 token。授权服务器收到请求后,会核对:
- code 是否存在、是否过期、是否已使用。
- client_id 与 client_secret 是否匹配,并且 code 确实是发给这个 client_id 的。
- redirect_uri 是否与请求授权时的一致(这个细节最容易漏)。
全部通过后,授权服务器做两件事:把 code 标记为已使用;生成 access_token 和 refresh_token,返回给客户端。
{ "access_token": "eyJhbGciOi...", "token_type": "Bearer", "expires_in": 3600, "refresh_token": "abcRefreshToken...", "scope": "profile" }如果失败,常见的错误响应是 invalid_grant。表示 code 无效或已被使用,或参数不匹配。
3.5 第五步:用 token 访问资源
拿到 access_token 后,后续访问资源的请求就在请求头带上:
Authorization: Bearer eyJhbGciOi...资源服务器收到请求,验证 token 的签名、有效期和作用域。合法则返回用户数据。token 过期后,后端再用 refresh_token 刷新,刷新一次换一个全新的 access_token,旧 token 作废。
整个流程到这里就形成了一个闭环。用户不用交密码,你的应用不需要存密码,access_token 不经过浏览器,唯一经过浏览器的 code 又被设计得短命且不可单独使用。
4. 安全细节与边界情况:设计者没写在明处的约束
4.1 code 的一次性,落到工程上必须原子操作
规范里说 code 只能使用一次。但“只能使用一次”一旦落到多线程、分布式环境,就不是简单写个 if 判断那么简单了。
假设你的后端在换 token 时同时收到两个相同 code 的请求,如果代码逻辑是:
- 读 code,发现未使用。
- 发 token。
- 写 code 为已使用。
两个请求都通过了第一步,就会出现一个 code 发了两次 token 的情况。虽然两个请求都来自你自己的后端,正常业务不会自己攻击自己,但攻击者完全可以在截获 code 后,和用户回调请求同时触发兑换,看谁先到。
所以我自己实现授权服务器时,会用一个具备原子性的操作来完成“检查并标记已使用”。如果用数据库,可以使用 UPDATE ... WHERE used = false 这种乐观锁方式,或者使用 Redis 的 Lua 脚本。核心原则是:检查与标记必须作为一个不可分割的操作,而不是两步。
伪代码如下:
# 伪代码:原子性兑换 code result = redis.eval(" local code = redis.call('GET', KEYS[1]) if not code then return -1 end redis.call('DEL', KEYS[1]) return code ", ARGV[1]) if result == -1: return error('invalid_grant')这个调用的核心是:只要 code 从存储里被读出来,就立刻删掉。删除成功才算兑换成功,第二个请求再读时已经不存在了。
另外,code 的存储格式也必须保存绑定信息。
key: code:store:{code} value: JSON.stringify({ client_id: '...', user_id: '...', redirect_uri: '...', scope: '...' }) TTL: 300 秒4.2 redirect_uri 白名单校验,是重定向类漏洞的命门
授权码模式里有个非常经典的攻击:攻击者把授权请求里的 redirect_uri 改成自己的地址。授权服务器如果没有严格校验,就会把 code 直接发到攻击者服务器上。
很多人会想:发过去又怎样?code 不能换 token 啊。但这里忽略了一种情况:如果同时泄露了 client_secret,或者客户端本身是恶意注册的呢?
更常见的攻击场景是:攻击者诱导用户访问一段 URL,把一个合法客户端的 client_id 与攻击者控制的 redirect_uri 拼在一起,让用户完成授权,然后用户操作后,攻击者拿到 code。在某些实现不严的授权服务器里,后续还可能结合 token 请求里的 redirect_uri 不一致导致的问题扩大风险。
所以规范里明确要求:授权服务器必须对客户端注册时填写的 redirect_uri 做精确校验。校验时不是比较前缀,也不是做模糊匹配,而是对完整地址(协议、域名、路径)做精确比对。比如客户端注册了 https://client.cn/callback,那代码里就不能允许 https://client.cn/callback/other 这种变化,更不应该允许 http://evil.com/callback。
我见过很多项目在自建授权服务器时,直接把前端传来的 redirect_uri 当成信任值使用,这是重定向漏洞最常见的成因。正确做法是把 code 与当初授权请求的 redirect_uri 绑定起来,换 token 时要求 redirect_uri 与授权请求时完全一致。
4.3 state 参数就是用来防 CSRF 的
state 参数的地位,和在表单里放 CSRF token 完全等价。
场景是这样的:攻击者先在自己浏览器里完成一次授权流程,拿到一个合法的 code。然后攻击者构造一个链接,让受害用户带着这个 code 访问客户端的回调地址。如果客户端不校验 state,就直接用这个 code 换 token,结果变成受害用户登录了攻击者的账号,或者受害用户把自己的资源授权绑定到了攻击者的 session 上。
解决方案就是在授权流程开始时生成一个随机 state,存到会话语境中。回调时比对 state,一致才继续。这样攻击者拿到的 code,因为缺失了和用户会话匹配的 state,会在第一步被拦下来。
具体操作上,state 的生成必须用加密安全随机数生成器,不能用固定值,也不能用可预测的时间戳。
state = crypto.randomBytes(32).toString('hex') session.oauth_state = state回调接口收到 state 后,比对逻辑只有一句话:
if (request.state !== session.oauth_state): return error('state 校验失败')这句话能防住我前面说的整个攻击链。
4.4 code 与 token 解耦后,撤销逻辑才变得干净
如果把 access_token 直接发给浏览器,授权服务器想在用户投诉、账号异常时撤销权限,需要维护一个庞大的 token 黑名单。但有了 code 这一层,撤销逻辑就从授权码阶段开始控制。
用户想要撤销某应用的权限,通常是去授权服务器上点一个“回收授权”按钮。授权服务器只需要断开用户与客户端的授权关系,同时配置下一轮 code 兑换时检查授权关系是否存在。如果关系已经撤销,即使 code 还没过期,换 token 这一步也会被拒绝。
更重要的是,code 与 token 解耦让授权服务器可以在换 token 时重新评估授权决定。比如用户刚改过密码、权限范围变了、账号状态异常,换 token 请求里都能在最后一刻拒绝。如果是直接发 token,想在这些场景下做拦截,就得额外引入 token revocation 端点,工程实现复杂得多。
所以设计者坚持先发 code 再换 token,不只是为了防泄露,也是为了让“授权决定”这一动作拥有一个可撤销、可重评估的边界点。
5. 常见问题与排查技巧实录
5.1 授权码模式常见问题速查表
| 现象 | 可能原因 | 排查思路 |
|---|---|---|
| 回调地址没有 code 参数 | 用户拒绝授权、response_type 写错、授权请求被拦截 | 看 browser 返回的 error 参数,确认授权页是否正常展示 |
| 换 token 报 invalid_grant | code 过期、已使用、client_secret 不匹配、redirect_uri 不一致 | 确认 code 是否在一分钟内请求,检查 token 请求的 redirect_uri 是否与授权请求时的完全一致 |
| state 校验不通过 | 用户从旧页面访问回调、攻击者构造回调链接 | 打印 session 中的 state 与请求中的 state,确认是否同一个会话 |
| 换了 token 后无法访问资源 | access_token 作用域不够、token 没放在请求头 | 检查请求头 Authorization 格式是否为 Bearer token,查看 scope |
| code 重复兑换 | 没有做原子标记 | 改成先删除再使用的原子操作 |
我在实际对接第三方登录的时候,遇到最多的问题基本都是 invalid_grant。排查时先不要怀疑代码,先自己手动用 curl 把 token 请求发一遍。curl 能成功,说明授权服务器和 code 都正常,问题在代码参数拼接;curl 失败,再逐个参数排查。
一个能快速复现 token 请求的命令示例:
curl -X POST https://auth.service.com/token \ -H "Content-Type: application/x-www-form-urlencoded" \ -d "grant_type=authorization_code" \ -d "code=拿到的那串code" \ -d "redirect_uri=https://client.cn/callback" \ -d "client_id=app123" \ -d "client_secret=secret789"手动排查的好处是能清楚看到授权服务器返回的完整错误体。很多测问题里,错误提示已经明确告诉你“code expired”还是“redirect_uri mismatch”,只是被代码吞掉了。
5.2 实现授权码模式的最小骨架
下面是一段描述性的伪代码,说明授权服务器端主要要做哪些事。以 Python 风格为例,但重点不是语法,而是逻辑顺序。
# 伪造码类说明:授权端点逻辑 def handle_authorize(client_id, redirect_uri, scope, state, user): # 1. 校验客户端身份 client = get_client(client_id) if not client: return error("invalid_client") # 2. 校验重定向地址精确匹配 if redirect_uri != client.redirect_uri: return error("invalid_redirect_uri") # 3. 校验 state 是否存在且合法 if not state or len(state) < 16: return error("missing_state") # 4. 生成一次性 code,并保存绑定关系 code = generate_secure_code() store_code( code=code, client_id=client_id, user_id=user.id, redirect_uri=redirect_uri, scope=scope, expires_at=now + 300 # 5分钟 ) # 5. 重定向回客户端 return redirect(f"{redirect_uri}?code={code}&state={state}")这里每一步的校验顺序都很重要。如果先生成 code 再校验 redirect_uri,会让无效请求也消耗存储空间;如果校验顺序颠倒,某些参数错误可能把错误信息泄露到日志里。
token 端点逻辑更简单,但校验条件更多:
def handle_token(code, client_id, client_secret, redirect_uri): # 1. 校验客户端密钥 client = authenticate(client_id, client_secret) if not client: return error("invalid_client") # 2. 一次性兑换 code(原子操作) record = atomically_consume_code(code) if not record: return error("invalid_grant") # 3. 校验 code 是否属于该客户端 if record.client_id != client_id: return error("invalid_grant") # 4. 校验 redirect_uri 与授权请求时一致 if record.redirect_uri != redirect_uri: return error("invalid_grant") # 5. 生成 token access_token = generate_token(client_id, record.user_id, record.scope) return { "access_token": access_token, "token_type": "Bearer", "expires_in": 3600, "refresh_token": generate_refresh_token(client_id, record.user_id) }我强调一下,第 2 步一次性兑换必须原子,这一点在工程实现里是硬要求,不是优化项。
5.3 实操过程中的几个独家心得
第一个心得是:code 存储不要存到应用业务数据库的普通表里,除非你有很强的清理机制。因为它是一个高频写、单次读、必然过期的数据,放业务表里会产生一堆无效行。用 Redis 这类带 TTL 能力的存储最合适,天然支持过期。
第二个心得是:code 生成一定要用加密安全随机数,长度至少 16 字节,建议 32 字节。很多人会用 UUID 或者随机数字,但 UUID 是带结构、部分可预测的,换 token 失败时安全风险较高。用 crypto.randomBytes(32).toString('hex') 之类的方案更稳妥。
第三个心得是:整个过程里最容易出问题的不是 code 的生成和兑换,而是回调链路的日志泄露。我在一个项目里看到,有人把完整回调 URL(包含 code)直接打进了应用日志。一旦日志系统被读取,攻击者拿 code 配合泄露的 client_secret 就能换 token。实际项目中我习惯把 code 和 token 在日志里脱敏,只显示前四位和后四位。
第四个心得是关于客户端的。如果你们做的是纯前端应用,没有后端来安全保存 client_secret,那授权码模式的基础前提就不再成立。这种情况下不要再试图把 code 换 token 的逻辑放在前端,换来的 access_token 也存在本地存储里。更合适的方案是使用带 PKCE 的授权码模式,但这部分内容适合单独开一篇展开。
结尾
最后再分享一个小技巧:以后无论看什么协议,先别急着背步骤。你把它拆成一个“遇到什么问题 → 有哪些约束 → 为什么这么设计”的推导过程,很多东西会自己串起来。
授权码模式里 code 这层,本质上就是设计者为了应对“浏览器环境不可信”和“长有效期凭证不能经过浏览器”这两个约束而加出来的中间层。它短命、一次性、绑定上下文,看起来像多绕了一步,实际是把可暴露的风险面压到了最小。我做了几年授权相关的开发,回头看这个设计,仍然觉得它非常精致。
这个系列后面我会继续往下写,把 token 刷新、PKCE、资源服务器的验证细节这些内容逐个说透。下一篇可以先聊聊 token 请求里 redirect_uri 为什么必须原样传递,那个坑我见过太多人踩了。你如果正好在调试授权码流程,可以先拿这篇文章里的排查思路试一遍,很可能就定位到问题所在了。