做企业级Web开发的时候,几乎每个项目都逃不开"第三方登录"这个需求。第一次接OAuth 2.0授权流程时,我面对一屏术语——authorization code、access_token、refresh_token、scope、state——每个单词单独看都认识,连在一起就看不懂。后来我把整个流程画成一张图,顺着箭头的方向把关键步骤一个个走通,这才发现它骨子里是个很朴素的东西。我会用文字把这整张图拆开讲清楚,把授权码模式这条主线上的关键步骤串起来,顺带解释每个步骤背后的安全设计逻辑和实际接入时的坑。不管你是写Vue前端、做后端接口,还是被期末大作业或者技能大赛里的登录模块困住过,这套流程都值得花半小时彻底搞明白。
1. 为什么说OAuth 2.0是"授权票"而不是"交钥匙"
1.1 没有OAuth的年代,授权是怎么做的
在很多老系统或者内部项目里,"账号共享"是最粗暴的集成方案。A系统要用B系统的用户数据,就让B系统把用户名密码直接交给A系统,A系统存到自己的数据库里,需要时模拟用户身份去调接口。听起来省事,但风险悉数集中于:密码泄露面凭空扩大一倍,用户完全不知道自己密码被存进了哪里,更无法单独撤销A系统的访问权——除非把密码改掉,但改完B系统也得跟着改。这就像我把家门钥匙直接配了一把递出去,对方随时能进门,我还收不回那把钥匙。
OAuth 2.0换了个思路:不给钥匙,给一张带时限、带权限范围的通行证。这张证由授权服务器统一签发,上面写清楚"这张证是谁发的""能访问哪些资源"(scope)、"到什么时候失效"(expires_in)。第三方应用拿到它,也只能在限定范围内访问资源,过期或越权都会被挡回来。这就是整个协议最核心的哲学:资源所有者不交出核心凭证,只授予一个可撤销、可限权的临时凭证。
1.2 画图之前,先认全四个角色
我建议任何人在画这张流程图的线之前,先把四个参与方钉在脑子里,不然箭头一多就容易乱。
- 资源所有者:通常是用户本人。数据归他,他有最终决定权。
- 客户端:想访问用户数据的应用,可以是传统Web后端、SPA单页应用、移动App。我们做开发时写的那个项目,在这里就叫"客户端"。
- 授权服务器:负责验证用户身份、询问用户是否同意授权,然后签发code和token。第三方平台侧的认证中心就是它。
- 资源服务器:真正存放用户数据的地方,比如用户资料接口、订单接口。它校验access_token,校验通过才放行数据。
一个常见的类比是酒店入住:用户是房客,客户端是前台办的房卡,授权服务器是前台本人,资源服务器是客房的门锁。前台确认你的身份和意愿后,给你房卡,门锁只认卡不认人,卡到期就失效。这个类比基本能把整个OAuth 2.0的运作方式讲明白。
1.3 授权流程本质上在传递什么
拨开协议细节,OAuth 2.0在不同实体之间传递的东西只有两类:一类是"授权凭据",另一类是"资源访问凭据"。授权码、access_token、refresh_token通通属于凭据,区别在于它们各自的生命周期、使用位置和机密等级不同。整个流程要解决的核心问题只有一个:如何安全地把"用户允许客户端访问某些资源"这个决定,转化成资源服务器能够验证的凭据,并且全程不让用户密码经过客户端之手。
理解了这个本质,后面所有步骤看起来都顺理成章——既然不能给密码,那就用"先证明用户同意,再换取专用凭据"的方式多走几步。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 从一张流程图开始:授权码模式的七步链路
2.1 整张图的样子
授权码模式(Authorization Code)是OAuth 2.0四种grant type里用得最多、也最规范的流程。如果把这整张图从左边到右边铺开,大概是这样的:最左边是浏览器,中间是客户端服务器,右边并列授权服务器和资源服务器。链路上边自顶向下画了七条带箭头的线:
- 用户点击"使用第三方登录",客户端生成授权链接。
- 浏览器重定向到授权服务器,带client_id、redirect_uri、scope、state。
- 用户在授权服务器上登录,并点击"同意授权"。
- 授权服务器302重定向回redirect_uri,带上authorization code和state。
- 客户端后端拿code + client_secret,到授权服务器token端点换access_token。
- 客户端后端携带access_token,请求资源服务器的受保护资源。
- 资源服务器校验access_token有效且权限足够,返回用户数据。
这七步是理解OAuth 2.0的地图。下面我把每一步拆开,讲清楚每步里发生了什么,以及为什么要这样做。
2.2 第一步:发起授权请求时,URL里放了什么
用户点击"使用第三方登录"的一瞬间,前端做的事其实非常简单,就是一次页面跳转,跳到授权服务器的authorize端点。跳转链接长这样:
text复制https://auth.example.com/oauth/authorize
?response_type=code
&client_id=YOUR_CLIENT_ID
&redirect_uri=https%3A%2F%2Fapp.example.com%2Fcallback
&scope=openid%20profile%20email
&state=hJ9xT2mP
逐个看这里的参数:
response_type=code,声明本次走的是授权码模式,授权服务器看到这个值,就知道应该返回一个授权码而不是其他东西。client_id是客户端在授权服务器上的公开身份标识,不保密,它让授权服务器确认"是哪个应用在发起请求"。redirect_uri是用户授权完成后的回跳地址,必须是事先在授权服务器后台注册过的,而且要精确匹配。scope声明希望获得哪些权限,通常用空格分隔,是token生效后的权限边界。state是一个随机字符串,由客户端生成并保存,目的是防止CSRF攻击,后面我会专门讲它。
前端这一步有一个不能碰的红线:授权请求URL里绝不能带client_secret。在纯浏览器侧的跳转中,URL会出现在浏览器的历史记录、收藏夹、各种埋点日志里,secret一旦出现在这里就等于公开。
2.3 第二步:用户在授权服务器的登录与授权页
浏览器到达授权服务器后,授权服务器要确认"当前用户是谁"以及"该用户是否愿意授权给这个应用"。这里会经历用户登录(可能有MFA多因子认证),然后出现一个授权确认页,页面内容大致是:
"app.example.com 想要访问你的:个人资料、邮箱地址。你确认允许吗?"
很多开发者不重视这个页面,但它恰恰是OAuth 2.0的灵魂所在——用户的知情同意是整个授权合法性的来源。授权服务器在实现这个页面时,需要把scope翻译成用户能看懂的自然语言,同时给用户提供"拒绝"选项。用户点了同意后,授权服务器需要记录下这个授权决定,这个记录可能是临时的(每次同意都要重新确认),也可能是长期的(在用户主动撤销前一直有效),具体看平台策略。
2.4 第三步:回调带回授权码,前端拿到后该怎么办
用户点击同意后,授权服务器会向浏览器返回一个302重定向,指向刚才注册的redirect_uri,并在URL上追加两个参数:
text复制https://app.example.com/callback?code=AUTHORIZATION_CODE&state=hJ9xT2mP
code就是一个短期有效的授权码,通常有效期只有几十秒到几分钟,而且一次性使用,用完后立刻失效。它在这一步之所以敢出现在浏览器的URL里,是因为它本身没有太大价值——要把code换成真正的access_token,还需要client_secret(或PKCE验证),这两样东西在浏览器里拿不到。
前端拿到code之后,有且只有一个正确动作:把它通过后端接口转交给服务器,让服务器拿着code去换token。很多刚接触这个流程的人会想"既然拿到了code,我前端直接发请求换token不就行了",这是错误的。浏览器不是可信环境,前端无法保密任何密钥,真正的token交换必须发生在拥有client_secret的后端。
2.5 第四至七步:后端换token,再用token换数据
后端收到前端传来的code后,自己向授权服务器的token端点发起一个POST请求。下面是用curl模拟的请求:
bash复制curl -X POST https://auth.example.com/oauth/token \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "grant_type=authorization_code" \
-d "code=AUTHORIZATION_CODE" \
-d "redirect_uri=https%3A%2F%2Fapp.example.com%2Fcallback" \
-d "client_id=YOUR_CLIENT_ID" \
-d "client_secret=YOUR_CLIENT_SECRET"
注意这里多了个grant_type=authorization_code,它是告诉授权服务器"我在用什么模式换token"。授权服务器收到请求后,会校验:
- code是否存在且未被使用过;
- code是否过期;
- client_id和client_secret是否匹配且有效;
- redirect_uri是否和授权请求时的完全一致。
校验通过后,授权服务器返回一个JSON响应:
json复制{
"access_token": "eyJhbGciOiJSUzI1NiIs...",
"token_type": "Bearer",
"expires_in": 3600,
"refresh_token": "dGhpcyBpcyBhIHJlZnJlc2ggdG9rZW4..."
}
expires_in的单位是秒,3600表示这个access_token一小时后失效。到这里第五步结束,客户端终于拿到了可以访问用户资源的通行证。
第六步,客户端后端拿着access_token去请求资源服务器:
bash复制curl https://api.example.com/user \
-H "Authorization: Bearer ACCESS_TOKEN"
资源服务器校验token的签名、有效期、scope,全部通过才返回用户数据。这里有一个值得注意的惯例:access_token要放在HTTP的Authorization请求头里,格式固定为Bearer <token>,而不是放在URL参数或请求体里。URL会被代理服务器和浏览器日志记录,token放URL上有很大的泄露风险;Authorization头不会出现在一般访问日志的明文参数里,相对安全得多。
第七步是整个流程的终点:资源服务器把数据返回给客户端,客户端再组织页面呈现给用户。到这里,一轮授权码模式就走完了。
3. 授权码、state和refresh_token:每个机制都在防什么
流程走通只是第一步,真正让你和"只会CRUD的开发者"拉开差距的,是对这几个机制存在理由的理解。授权服务器在流程上的每一步设计,几乎都是针对某种攻击方式的防御。
3.1 授权码为什么不能直接被替换成token
如果授权服务器在回调时直接返回access_token,会比返回授权码少一个步骤,看起来更高效,但安全性会塌方。回调是经过浏览器的,浏览器是多进程、多扩展、多脚本的环境,URL会被各种中间环节记录——历史记录、扩展插件、代理日志、Referer头。token直接出现在这些地方,等于把通行证贴在了人来人往的公告栏上。
授权码相当于一个"一次性换票券"。就算攻击者从日志里偷到code,他也缺少client_secret,无法向后端换token端点完成第二层校验;即便防不住机密泄露,code本身一次性失效也把时间窗口压缩到了极小。这就是OAuth 2.0里著名的"两条腿走路"思路:浏览器只运输低价值的短期凭据,高价值的token在服务器之间私密传输。
3.2 state参数:防御CSRF的最后一道闸门
很多教程把state当成一个可选项,这是极其危险的理解。RFC 6749明确规定客户端必须使用state防止CSRF,OpenID Connect上也延续了同样的要求。
具体攻击场景是这样的:攻击者事先用自己的账号走一遍授权流程,拿到一个合法的code,然后把授权链接发给受害者。受害者如果当前已经登录过授权服务器,点击链接后会被引导完成授权(甚至可能无感),随后带着攻击者的code回跳到客户端回调地址。如果客户端不校验state,它会拿着攻击者的code去换token——换回来的却是攻击者账号下的token,然后客户端把这个token绑到受害者的会话上。之后受害者做的任何操作,会全部写进攻击者的账号,造成数据串号和逻辑混乱。
防御方法很简单:客户端在发起授权请求前生成一个随机state,存到session或本地状态里;回调时取出state与授权服务器带回来的值比对,不一致立即拒绝请求,比对完成后清除state。整个过程就三个动作,生成、保存、校验,缺一个等于没防。
3.3 redirect_uri必须一字不差地匹配
授权服务器在两步都会用到redirect_uri:授权请求时校验它是否在注册列表中,换token时校验它是否与授权请求时的值一致。为什么要这么严格?因为redirect_uri一旦能被攻击者操纵,整个授权流程就会被重定向到攻击者的地盘。
假设授权服务器只做前缀匹配,攻击者可以把redirect_uri设置为https://app.example.com.evil.com/callback,表面上看起来像是example.com域名,实际上是evil.com的子域。用户完成授权后,code会被送到攻击者服务器,攻击者再配合自己控制的client_secret(如果攻击者自己就是合法client)就能换取到受害者在攻击者应用视角下的token。如果目标是受害者在某个第三方网站的账号,这个漏洞就能被用来串号。
所以,redirect_uri配置必须精确到每个字符,不能有前缀匹配、不补默认端口、不留通配符。我在对接多个平台时见过最典型的错误,就是注册回调时多写了一个尾斜杠,结果线上回调地址不带斜杠,授权服务器直接拒绝回跳,用户卡在授权页上。
3.4 access_token短命,refresh_token兜底
access_token如果长期有效,一旦泄露,攻击者就有足够长的窗口去滥用。所以授权服务器普遍把access_token的有效期设得很短,常见值是一小时、半小时甚至15分钟。短命token有两个直接收益:泄露后的危害窗口变小;用户在用户中心撤销同意后,最长只需等待token自然过期就能彻底回收权限。
但用户不可能每隔一小时就让用户重新登录一次,这不现实。于是OAuth 2.0引入了refresh_token:它有效期长(几天、几周甚至几个月),唯一用途是换新的access_token,本身不能直接访问资源。客户端在access_token过期后,用refresh_token去token端点换新token:
bash复制curl -X POST https://auth.example.com/oauth/token \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "grant_type=refresh_token" \
-d "refresh_token=REFRESH_TOKEN" \
-d "client_id=YOUR_CLIENT_ID" \
-d "client_secret=YOUR_CLIENT_SECRET"
refresh_token的存续和吊销可以独立于用户会话,用户在账号安全设置里"撤销某应用授权"时,实际撤销的就是refresh_token的效力。因为refresh_token权重大、生命周期长,它存储要求比access_token更高,应该放在后端,由服务端保管,前端永远不应该看到它。
3.5 为什么token交换必须在后端,而不是前端
一句话:token交换需要client_secret,而浏览器环境保不住任何秘密。
client_secret和client_id不同,它是客户端的机密身份凭证,类似账号密码。如果把它写进前端代码、写在某个SPA的配置项里、或者放在会被打包进静态资源的文件里,它就不再是机密了——任何打开控制台的人都能看到,任何被打包进公共CDN的JS文件都会被爬虫和审查工具扒出来。token交换在前端还有一个问题:CORS策略和CORS预检会让token端点对浏览器请求格外敏感,正规授权服务器通常不会开放token端点的跨域访问权限。
所以在传统Web架构里,token交换永远发生在服务端:前端把code传给自己的后端接口,后端完成后续密钥交换、token存储、刷新逻辑,前端只拿最终的业务结果。
4. 不只是授权码:四种grant type怎么选
4.1 四种grant type一表看懂
OAuth 2.0不止授权码模式。RFC 6749一共定义了四种授权方式,每种面向的客户端类型和信任模型不同,选错了后面全是坑。
| 授权方式 | 适用场景 | 信任模型 | 当前推荐度 |
|---|---|---|---|
| Authorization Code | 传统Web应用、前后端分离 | 有后端,可保护client_secret | 推荐 |
| Implicit | 纯前端SPA(旧方案) | 无后端,不适用client_secret | 不推荐 |
| Resource Owner Password Credentials | 自家第一方应用 | 用户密码直接交给客户端 | 不推荐 |
| Client Credentials | 服务端到服务端通信 | 客户端以自己的身份换取token | 适合机器对机器 |
4.2 简化模式:SPA的旧阵容,现在不推荐
Implicit模式诞生时是为了解决纯前端应用没有后端换token这个窘境,它的做法是授权服务器在回调URL的fragment里直接返回access_token。fragment不会发给服务器,解决了token被Referer带走的隐患,但它仍然有致命短板:token会在浏览器历史记录里留下痕迹、无法下发refresh_token、客户端身份不可验证。如今业界公认的正确方案是"授权码+PKCE",连简化模式最经典的适用场景SPA都不推荐再用它了。
4.3 密码模式和客户端凭证模式
密码模式(Password Grant)是把用户密码拿给客户端,由客户端直接换token。它违反OAuth 2.0"不共享密码"的核心原则,只适用于第一方绝对可信的应用(比如官方App登录自家服务器),而且即便如此也有更好的替代方案。客户端凭证模式则完全不同:整个流程没有用户参与,客户端凭client_id和client_secret直接换token,然后以应用自身的身份访问资源。定时任务拉取数据、内网服务互相认证都属于这个模式,它是合法且高效的工具。
4.4 SPA与移动端现在的标准姿势:授权码+PKCE
如果你在做一个Vue或其他框架的纯前端单页应用,又要对接第三方登录,正确选择是授权码+PKCE。PKCE(RFC 7636)允许客户端在没有client_secret的情况下安全地使用授权码流程。
PKCE的核心是两次摘要校验:
- 客户端生成一个随机字符串code_verifier;
- 对code_verifier做SHA256哈希,得到code_challenge;
- 授权请求时带上
code_challenge和code_challenge_method=S256; - 换token时带上原始的code_verifier;
- 授权服务器把code_verifier做同样哈希,比对是否与code_challenge一致。
这样即使授权码在传输过程中被截获,没有code_verifier也换不到token。对没有后端或者不想引入后端的纯前端应用,PKCE是用最小成本补齐安全短板的方式。我在实际项目里见过不少团队直接用implicit模式接第三方登录,换到PKCE后才发现改造量并不大,安全性却高了一个层级。
5. 实际接入时最容易翻车的五个环节与排查思路
5.1 最大的坑:把client_secret写进了前端
这个错误在我的职业生涯里见过太多次,严重到每次都要拿出来单独说。搜索结果里排在最前面的安全问题,永远是Github上泄露的client_secret。一旦secret进入Git历史,即使立刻删除并轮换,仓库里所有历史版本中都还留着它,等于长期后门。
正确的保存位置是后端的环境变量或专用的密钥管理服务,前端代码一律不接触client_secret。项目里如果使用.env文件,记住让.gitignore把它排除掉,很多人在本地开发方便,顺手把.env提交进了仓库,这是典型的"方便一时,灾难一世"。
5.2 重定向URI不匹配的排查链路
现象很典型:用户在授权服务器上完成授权后,页面没有跳回应用,而是停留在授权服务器的一个错误提示页,写着redirect_uri不匹配。
排查时按这个顺序逐项核对:
- 授权服务器后台注册的redirect_uri和代码中生成的授权URL里的redirect_uri是否完全一致;
- 注意尾斜杠、大小写、端口号和协议——
https://app.example.com/callback和https://app.example.com/callback/是两个完全不同的URI; - 检查重定向URL是否做了合法编码,尤其是query参数是否被浏览器解析后再拼接导致变化;
- 确认代码里没有对URI做额外的前缀或后缀拼接。
这类问题九成是配置不一致,极少是协议本身出错。用"复制粘贴而不是手敲"的方式配置两个端的URI,是避免这种低级问题的实际手段。
5.3 state丢失与CSRF防护失效
回调时发现state校验不过,很多人第一反应是"先临时去掉state吧",这是典型的饮鸩止渴。真正的排查思路是看state为什么会不一致:
- 如果用户开了多个浏览器标签页先后发起授权,旧标签页回调时携带的state可能已被新标签页覆盖,校验就会失败。这种情况可以在生成state时绑定会话标识,并容忍一定程度的旧state失效;
- 如果应用是多实例部署,state存到单机内存里,请求被负载均衡分流到不同实例时就会对不上。用分布式会话或把state做成有签名的自包含token能解决这个问题;
- 如果用户直接从收藏夹打开回调URL,state参数为空,这种请求应直接拒绝并重新走授权。
5.4 access token存哪里:前端存储方案
前端拿到access_token后应该存到哪里?这是个没有标准答案但很有讲究的问题。
- localStorage是最常见的做法,但XSS攻击一旦得手,攻击者可以轻易读取localStorage里的token,后续完全控制用户会话。
- 内存变量存储(比如状态管理store)能缓解XSS后的token窃取,但刷新页面token就没了,需要靠refresh_token或静默续期来恢复会话。
- httpOnly Cookie是相对更稳妥的方案:cookie由服务端配置好httpOnly和SameSite属性,前端JS读不到cookie内容,XSS即便执行也偷不走。CSRF的问题则交给SameSite和自定义请求头来防护。
对于一般的业务系统,我建议至少做到:不把refresh_token给前端,access_token根据安全要求选择内存或cookie,敏感操作再做二次校验。没有万能的存储方案,重要的是明确每个选择背后的威胁模型。
5.5 调试OAuth 2.0的实用技巧
调这套流程时,我经常用的几个方法:
- 打开浏览器开发者工具的Network面板,勾选Preserve log,观察授权请求的302链,能看到每一次跳转的完整URL,排查redirect_uri和state非常直观;
- 用curl手动复现整个流程。先拿授权URL在浏览器里手动授权,从地址栏复制code,再用curl去换token,这样能把前端和后端的干扰彻底隔离,快速定位是请求参数问题还是服务端校验问题;
- 在授权服务器和客户端的回调接口附近打日志,记录收到的时间、code前缀、state,但绝对不要打印完整token值;
- 先跑通本地mock的授权服务器,再切真实第三方,可以省去大量排查时间。
手动模拟一次全流程后,OAuth 2.0在你心里的清晰度会有质的提升。我甚至建议你没事就手动拿curl导一遍,效果远好于反复看文档。
以我实际带项目的经验来说,给团队讲OAuth 2.0时,让每个人拿白纸亲自画一遍这条七步链路,比对着PPT讲十页都有效。谁负责用户侧交互、谁做code交换、谁去校验token,边界立刻清晰。最后送一个小技巧:画图时把浏览器、客户端服务器、授权服务器、资源服务器四个框固定下来,然后按职责而不是按时间顺序去填箭头,你会发现理解协议的速度快得多。
