做了多年后端,认证授权这块几乎每个项目都绕不开。后台管理系统、开放平台、小程序后端、App接口网关,无论哪种形态,最终都需要回答两个问题:请求的人是“谁”,以及这个人能“做什么”。OAuth 2.0、OIDC、JWT这三个标准组合起来,恰好把这两个问题拆解得很干净:OAuth 2.0负责授权流程,OIDC在授权之上补充了身份认证,JWT作为token载体统一了凭证格式。这篇文章我从实际项目视角,把整个认证授权流程的选型逻辑、授权码模式完整流程、JWT漏洞防范、token续签设计,以及SPA落地时的验证码和拦截器实现,逐个拆开讲清楚。无论是刚开始接触认证授权的后端开发,还是被老系统遗留token方案坑过的同学,这篇文章应该都能给你一些可落地的参考。
1. 组合拳怎么打:OAuth 2.0、OIDC、JWT各自负责什么
很多团队在做用户登录时,习惯从一张用户表开始自研:密码哈希、登录态、token签发、权限校验全部自己写,短期内看起来没问题,一旦遇到多端登录、第三方账号接入、多个内部系统互相调用,这套自研方案就开始处处掣肘。问题不在于自研不行,而在于没有把“身份认证”和“资源授权”这两个概念分开。
1.1 自研账号体系的问题在哪里
自研账号体系最典型的痛点是重复造轮子,而且造得不够安全。手机验证码要自己对接短信服务商,密码找回要自己设计防滥用逻辑,多设备登录要自己维护会话表,权限模型要从RBAC开始一点点建。更麻烦的是token的格式没有标准,有的团队随手拼一个随机串存Redis,有的团队把用户ID直接塞进Base64然后加个签名,前端拿到token之后解析方式五花八门。
这类方案最大的隐患不在正常流程,而在异常场景。密钥管理松懈导致token能被伪造,日志里打出完整token导致泄露,退出登录只清了前端存储导致旧token还能继续用,这些坑我在不同项目里都见过不止一次。后来大家越来越倾向用成熟标准来兜底,就是因为标准协议把常见攻击面都提前想过了。
1.2 三个标准的边界与分工
OAuth 2.0解决的是授权问题。它的核心产出是Access Token,描述的是“某个客户端经过用户授权后,可以代表用户访问哪些资源”。注意,这里OAuth 2.0本身并不关心用户是谁,也不关心token里装的是什么内容,它只负责定好整个授权流程怎么走:谁发起、谁同意、谁发token、资源服务如何校验。它更像一个授权框架,而不是一个身份协议。
OIDC,也就是OpenID Connect,是构建在OAuth 2.0之上的身份认证层。它在授权流程里增加了一个ID Token,用于告诉客户端当前登录的用户是谁,以及这个身份是否经过核实。有了OIDC,第三方应用才可以放心地说“用户已登录,就是这个sub对应的那个人”,这是OAuth 2.0本身没有的能力。
JWT则是一套token格式标准。它把信息封装成三段式结构,用签名保证内容没有被篡改。在OIDC流程里,ID Token约定必须用JWT,Access Token也常用JWT承载作用域和用户标识。三者之间的关系可以这样理解:OAuth 2.0是订好的酒店授权规则,OIDC是前台核对客人身份的环节,而JWT是那张写着房号和权限的房卡。
1.3 方案选型对比:Session方案与JWT方案的适用边界
不少团队纠结选Session还是JWT,这其实不是纯技术问题,而是状态管理方式的问题。传统Session方案把会话状态留在服务端,客户端只保存一个Session ID,优点是服务端可以随时踢人、强制下线、控制设备数量,缺点是分布式环境下要解决Session共享,要么做粘性会话,要么引入Redis统一存储。
JWT方案的核心区别是把状态“下放”到客户端。服务端无状态,只需要用密钥或公钥验证签名,天然适合水平扩展,也方便在多个服务之间共享身份信息。但代价是token无法在服务端直接撤销,过期时间只能靠exp来控制,一旦token泄露,在到期之前都是可用的。所以我见过不少团队最终的落地方式是:用户登录后,用OIDC拿到的身份信息去签发自己系统的Access Token,access token做短生命周期,再配一个服务端可撤销的Refresh Token。这样一来,无状态校验的快和可主动撤销的稳都有了,这也是我建议大多数项目参考的架构。
2. JWT结构解析:一个Token被拆开后能看到什么
做认证授权绕不开JWT,而理解JWT最简单的方法是拆开一个真实token看看里面是什么。一个标准JWT长这样:eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJodHRwczovL21heS5jb20iLCJzdWIiOiIxMjM0NTYiLCJleHAiOjE3MDAwMDAwMDB9.tX7jKQfYVzVZkXZQm9pW4sZqJ9vL8nXzM5nF0Yp0JvU。
2.1 三部分结构:Header、Payload与Signature
JWT由三个用点号分隔的部分组成。第一部分是Header,声明token的类型和签名算法,通常长这样:{"alg":"RS256","typ":"JWT"}。第二部分是Payload,存实际需要传递的声明信息,比如iss是签发者、sub是用户唯一标识、aud是接收方、exp是过期时间,还可以塞自定义业务字段。第三部分是Signature,用Header里声明的算法,把前两部分编码后的字符串加上密钥一起做签名。
这里的编码方式是Base64URL,它跟普通的Base64编码有区别:把Base64里的加号换成减号、斜杠换成下划线,并且去掉补齐用的等号。这样做是为了让token能安全地放在URL参数、Authorization头以及Cookie里,不需要额外转义。
签名生成的过程本质上是对前两段内容做了完整性保护。哪怕有人把Payload里的角色从“普通用户”改成“管理员”,只要他没有私钥或对称密钥,改动后的第三段签名就无法通过服务端校验,服务端直接拒收。
2.2 签名算法怎么选:HS256还是RS256
HS256是对称签名算法,签发和校验用的是同一个密钥。它的优点是实现简单、性能好,缺点是密钥一旦泄露,攻击者既能签也能验,而且服务端和客户端如果共享密钥,客户端可以直接伪造token。所以HS256只适合单服务内部使用,密钥仅存在于后端服务之间。
RS256是非对称算法,私钥放在授权服务器用于签发,公钥放在资源服务器用于校验。这样一来,签发和校验分离,多个下游服务只需要持有公钥,就能验证token是否由信任的授权服务器签发,不需要共享任何敏感密钥。开放平台、微服务架构、第三方接入场景下,RS256是唯一合理的选择。
我建议新项目直接采用RS256,配合授权服务器的JWKS端点动态获取公钥。这样密钥轮换也方便,授权服务器更换私钥时,下游服务只需要定时拉取新的公钥即可,不用改配置。
2.3 关键Claims与有效期设计
Payload里的claim是JWT的灵魂。最核心的几个字段包括:
- iss:token签发者的标识,通常是授权服务器的URL,校验时必须与预期值完全一致
- sub:主题,即用户唯一标识,客户端应该用它作为用户的内部ID
- aud:token接收方,防止一个token在多个客户端之间串用
- exp:过期时间戳,服务端必须严格校验当前时间是否早于exp
- iat:签发时间,可用于统计和审计
- jti:token唯一编号,可用于实现token级撤销和黑名单
有效期设计上,Access Token建议控制在15分钟到2小时之间。太短会导致请求频繁401,太长的风险则是泄露后长时间可用。Refresh Token可以放到7天到30天,按业务对安全性的敏感程度设定。我见过一些项目把Access Token设成7天,理由是“用户不想频繁登录”,这其实是用Refresh Token能解决的问题,不该牺牲access token的安全性。
3. 授权码模式实操:从跳转到换Token的全流程拆解
OAuth 2.0定义了多种grant type,授权码模式是目前Web和移动端最佳实践里唯一被推荐的交互方式。之所以说“唯一”,是因为授权码模式全程经过两个步骤:先从前端跳转拿到一个一次性授权码,再由后端用授权码换取token。好处是Access Token不经过浏览器和SPA的JS环境,泄露面大幅缩小。
3.1 四种许可类型速览,为什么推荐授权码模式
OAuth 2.0最初的4种许可类型里,implicit模式因为在URL上直接返回access_token、有泄露和劫持风险,已经被业界淘汰;password模式要求客户端直接收集用户名密码,和“客户端可信”的假设绑定,现在主流厂商也不再支持;client_credentials只适合机器之间调用,没有用户参与。剩下的authorization_code模式,配合PKCE扩展,是当前唯一能同时覆盖Web、SPA、移动端的通用方案。
授权码模式还有一个关键优势:后端服务器在换取token时才能拿到Access Token和Refresh Token。因为这一步发生在服务端,token没有暴露给浏览器的JS环境,XSS攻击想偷token的难度会大很多。对于有自己后端的项目,我建议一律走授权码模式。
3.2 授权URL参数逐项拆解
一个典型的授权URL长这样:
GET /oauth2/authorize HTTP/1.1 Host: auth.example.com response_type=code &client_id=web_app &redirect_uri=https://app.example.com/callback &scope=openid%20profile%20email &state=xyz_random_string &code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM &code_challenge_method=S256每个参数都有明确的安全意图。redirect_uri必须精确匹配注册值,否则攻击者可以把授权码跳到自己的服务器上。state是一个随机值,用户在发起授权前生成并存在本地,回调时如果state不匹配,整条流程直接终止,这是防CSRF的关键。code_challenge和code_challenge_method是PKCE的核心,SPA或移动端没有client_secret,只能用这个方式证明授权码确实是从自己发起的请求那里换来的。
实际操作里,我见过有人把state写死或者用固定时间戳,这等于没有防御。正确做法是每次授权请求都生成一个足够随机的state,并用短期Session或内存保存,回调时逐一校验。
3.3 换取Token与ID Token校验
用户完成授权后,浏览器重定向到redirect_uri,带上了code和state。后端第一步校验state,第二步用code加上client_id、client_secret、redirect_uri以及之前的code_verifier,去换取token。
POST /oauth2/token HTTP/1.1 Host: auth.example.com Content-Type: application/x-www-form-urlencoded grant_type=authorization_code &code=replace_with_authorization_code &redirect_uri=https://app.example.com/callback &client_id=web_app &client_secret=your_client_secret &code_verifier=dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk授权服务器返回的响应里通常包含四个字段:access_token、id_token、refresh_token和token_type。id_token是OIDC的核心,客户端必须校验它的签名、iss、aud和exp,还要校验nonce,防止重放攻击。校验通过后,从id_token的sub字段里取用户唯一ID,再结合profile、email这些scope对应的claim,就可以在本地建立用户会话。
3.4 一个可复用的Node.js接入示例
我这里用一个Node.js最小的接口示例,演示后端如何完成授权URL构造和token换取。实际项目中可以把这些逻辑封装成中间件。
import crypto from 'crypto'; // 第一步:生成并保存state和code_verifier,再拼授权URL function buildAuthorizeUrl() { const state = crypto.randomBytes(16).toString('base64url'); const codeVerifier = crypto.randomBytes(32).toString('base64url'); const codeChallenge = crypto .createHash('sha256') .update(codeVerifier) .digest('base64url'); // 把 state 和 codeVerifier 存入短期存储,比如 Redis // await redis.set(`oauth:${state}`, codeVerifier, { EX: 600 }); const authorizeUrl = new URL(`${ISSUER}/oauth2/authorize`); authorizeUrl.searchParams.set('response_type', 'code'); authorizeUrl.searchParams.set('client_id', CLIENT_ID); authorizeUrl.searchParams.set('redirect_uri', REDIRECT_URI); authorizeUrl.searchParams.set('scope', 'openid profile email'); authorizeUrl.searchParams.set('state', state); authorizeUrl.searchParams.set('code_challenge', codeChallenge); authorizeUrl.searchParams.set('code_challenge_method', 'S256'); return authorizeUrl.toString(); } // 第二步:回调接口换token async function handleCallback(code, state) { const codeVerifier = await redis.get(`oauth:${state}`); if (!codeVerifier) { throw new Error('state校验失败'); } const params = new URLSearchParams({ grant_type: 'authorization_code', code, redirect_uri: REDIRECT_URI, client_id: CLIENT_ID, client_secret: CLIENT_SECRET, code_verifier: codeVerifier, }); const tokenResponse = await fetch(`${ISSUER}/oauth2/token`, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: params, }); const tokens = await tokenResponse.json(); // 校验 id_token 后,再签发自己系统的 token return tokens; }这里面有个细节:code_verifier不带进前端回调,只用state关联起来。这样做即使授权码在URL上被捕获,攻击者没有code_verifier也无法完成token换取。
4. JWT漏洞总结:攻击者最常用的几个切入点
JWT看着简单,真正部署后容易踩的坑比想象中多。这里我把常见的JWT漏洞归结为几类,每类都有对应的攻击方式和防御要点,这部分内容是我在实际攻防测试和代码评审中反复遇到的。
4.1 最容易翻车的算法混淆攻击
算法混淆攻击是JWT最经典的漏洞,攻击思路是利用服务端对Header里的alg字段过于信任。比较典型的有两种:第一种把alg改成none,直接把Signature留空,服务端如果不校验算法白名单,就会把无签名的token当成合法token接受,攻击者可以任意篡改Payload;第二种是把alg从RS256改成HS256,如果服务端拿着RS256的公钥当作HS256的对称密钥去验签,攻击者由于已经知道公钥,就能用这个公钥生成合法的签名。
防御方式很简单,校验token时先检查Header里的alg是否在允许列表内,比如服务端只接受RS256,就明确拒绝alg为none、HS256的token。大多数JWT库都支持指定算法,比如jsonwebtoken的algorithms参数,代码里必须显式传参,不能依赖库的默认配置。
4.2 弱密钥与暴力破解
HS256如果用了短密钥,比如一串英文单词或者小于32字节的随机串,攻击者拿到一个真实token后,可以离线跑字典,很快就能爆破出密钥。爆破出密钥之后,攻击者就能任意伪造token,这个危害是彻底的。
我在某个项目里就碰到过把HS256密钥放在前端静态资源里的情况,这种属于把对称密钥暴露给了客户端,基本等于把大门钥匙挂在门上。防御上要么直接用RS256彻底规避对称密钥爆破问题,要么确保HS256的密钥长度至少256位,且只能存在于服务端环境变量或密钥管理服务里。
4.3 过期时间、敏感信息与日志泄露
还有三个问题虽然不像算法混淆那么“炫技”,但实际危害一点不小。
第一个是exp缺失或过期时间过长。有的库解析JWT时如果不校验exp,攻击者拿着一个过期的旧token,稍微篡改Payload里的exp字段就能继续用。防御是在校验逻辑里强制检查exp和nbf,并且Access Token生命周期控制在合理范围内。
第二个是把敏感信息直接塞进Payload。很多新手误以为JWT是加密的,实际上Payload只是Base64编码,任何人拿到token解码就能看到你的手机号、邮箱甚至身份证号。JWT只适合放不敏感的身份标识,比如sub,其他用户资料应该通过用户信息接口按需获取。
第三个是token被打印到日志里。一次正常的HTTP请求,如果框架日志把Authorization头完整打出来,那token就等于被明文记录了。我在日志系统里见过大量业务服务打印完整Authorization头,这类泄露很难在第一时间发现,但危害是长期的。日志里至少要把token做脱敏,比如只保留前四位和后四位。
4.4 服务端安全配置清单
经过这些整理,我一般会在项目里固定一份JWT安全清单作为代码评审的检查项。服务端要用RS256,算法参数显式指定;JWKS公钥来源只能是配置的信任地址;始终校验iss、aud、exp、nbf和nonce;Payload里不存放敏感信息;日志中对token脱敏;密钥和私钥只放环境变量或密钥管理服务,不进代码库;级联系统之间禁止共享单一对称密钥。别小看这份清单,走一遍能挡掉绝大多数常见攻击手法。
5. Token续签方案设计:Access Token过期之后怎么办
Access Token和Refresh Token双token机制,是目前解决“安全性和体验”这对矛盾的主流方案。核心思路是Access Token设置短过期时间,Refresh Token用来换取新的Access Token,避免用户频繁重新登录。这个机制看似简单,落地时仍然有几个关键点容易出问题。
5.1 为什么不能把Access Token设得很长
有人会问:直接把Access Token设成7天不就没有续签这回事了吗。这个想法最大的问题在于无状态JWT一旦签发,在过期前服务端无法主动让它失效。用户修改密码、账号被封禁、设备被顶号,都要求凭证立刻失效,但Access Token的exp还在远处,资源服务依然接受它。把过期时间缩短,配合刷新机制,相当于把“撤销风险”的窗口缩小到几分钟级别,即使泄露,攻击者能利用的时间也大为缩短。
所以推荐策略是Access Token设15分钟到2小时,Refresh Token设7天到30天。如果业务对安全要求比较严格,比如涉及支付管理,Access Token可以压到15分钟,Refresh Token设7天;内部管理系统可以把Access Token设到2小时,Refresh Token设14天。
5.2 Refresh Token的正确打开方式
Refresh Token本身尽量不要用JWT,而是用随机不透明字符串,并把它存储在服务端,比如Redis里。这样一来,服务端拥有了主动撤销能力,用户改密码、被顶号、安全风控拦截时,直接删除Redis里的refresh_token即可下线。配套的有效期用Redis的TTL控制,不需要在token里费劲维护exp。
换取新token的接口设计上,有一个容易被忽略的安全习惯:旧的Refresh Token一旦成功换取了新token,必须立刻作废,并返回一个新的Refresh Token,这叫Refresh Token轮换。这样即使旧token被攻击者截获,也只能用一次,攻击者下一次拿到的是一个已经被服务端标记失效的token。轮换机制会让重放攻击变得非常困难。
5.3 滑动续签与Refresh Token轮换
滑动续签是另一个常见的策略,指的是用户只要活跃,Refresh Token就不会过期。每次刷新token时按固定时间窗口顺延有效期,例如本来30天过期,用户在第20天刷新了一次,新的refresh token就再给30天。这个策略让长时间不活跃的用户必须重新登录,而活跃用户几乎感受不到会话过期。适合电商、办公类产品,能平衡安全性和体验。
不过滑动续签会和轮换机制产生冲突:如果每次都返回新refresh token并作废旧token,那旧token到底算不算“过期”。实际落地时要把这两个机制一起设计。我的建议是:刷新接口同时做轮换和顺延,新token和新refresh token一起返回,旧refresh token删除。客户端只需要负责在本地替换掉旧值就行。
5.4 续签场景下的并发与撤销问题
有前端经验的读者应该已经想到了,SPA里的并发请求会导致多个接口同时收到401,然后同时触发刷新逻辑,在这个瞬间可能产生多个并发的刷新请求。第一个请求成功后,旧refresh token已经被轮换作废,后续几个并发请求拿着相同旧token再刷新就会失败。
前端的解法是用一个刷新状态锁和等待队列。第一个401触发刷新时,后续401不再触发新刷新,而是排队等待第一个刷新完成,然后共享新token重放请求。后端的解法是把刷新接口做成幂等或加锁,同一个refresh token在同一时刻最多只能成功一次。两端的防护最好都做,因为客户端网络环境不可控,总会有漏网之鱼直接打到后端。
另一个容易出现的问题是多设备登录。用户开了手机App、浏览器网页、桌面客户端三个会话,对应的refresh token是三个不同的随机串。踢掉某一台设备时,只能删除对应的那个refresh token,而不是把用户ID下所有会话全部清掉。所以Redis的key设计上要注意,不能只存“userId -> refreshToken”,而应该存“userId:deviceId -> refreshToken”,这样才具备按设备撤销的能力。
6. SPA项目落地:验证码、存储与拦截器全套方案
最后把视角切到前端。SPA项目的认证授权落地,不只是把token存在localStorage里那么简单,存储位置、验证码、HTTP拦截器、路由守卫这些环节每一项都有讲究。
6.1 Token存储位置怎么选
SPA下token存哪,是一个讨论很多的问题。localStorage最简单,但XSS攻击可以把它直接读走;内存变量相对安全,刷新页面就丢,体验太差;httpOnly Cookie安全属性最好,但需要后端额外协作,跨域场景还要处理CORS和CSRF。
我当前比较推荐的做法是分情况处理。access token放内存,让JS环境通过内存变量访问,页面刷新后虽然会丢失,但可以用refresh token恢复;refresh token放httpOnly Cookie里,HTTP Only属性让JS读不到,能有效防止XSS窃取,同时配合SameSite=Lax或Strict来缓解CSRF风险。如果项目确实没有条件用httpOnly Cookie,那至少不要把access token和refresh token一起放在localStorage里,这是底线。
6.2 图形验证码的实现方案
登录页的验证码,主要为了防止自动化脚本暴力破解和撞库。这里直接给出一个基于Node.js后端和Redis的实现思路。
后端生成验证码接口,返回captchaId和SVG图片内容,验证码答案只存在Redis里,不返回给前端。验证码绑定captchaId,有效期5分钟,取过一次后必须删除,防止同一个验证码被反复尝试。
import svgCaptcha from 'svg-captcha'; import { randomUUID } from 'crypto'; router.get('/auth/captcha', async (req, res) => { const captcha = svgCaptcha.create({ size: 4, noise: 3, color: true }); const captchaId = randomUUID(); await redis.set(`captcha:${captchaId}`, captcha.text, { EX: 300 }); res.json({ captchaId, svg: captcha.data }); });登录时,后端先校验验证码,验证码错误直接拒绝,成功则立刻删除验证码,再走密码校验和OIDC登录流程。这里有一个细节值得注意:失败的验证码校验也必须删除验证码,否则攻击者可以反复用同一个captchaId撞验证码,直到通过。连续失败还需要加接口级限流,比如同一IP每分钟最多5次登录请求,或者同一用户名失败5次后临时锁定,这个能让自动化脚本的效率大打折扣。
6.3 Axios拦截器与路由守卫
前端处理401和自动续签的逻辑,我习惯放在axios拦截器里。核心思路是:请求发出前统一带上Authorization头;响应收到401时,判断是否已经有刷新流程在跑,如果在跑就排队,没在跑就触发刷新,刷新成功后重放原请求,刷新失败则清空本地状态并跳转登录页。
一个简化版的示例:
let isRefreshing = false; let pendingTasks = []; async function refreshNewToken() { return axios.post('/auth/refresh', {}, { withCredentials: true }); } service.interceptors.response.use( (response) => response, async (error) => { const { response, config } = error; if (response.status === 401 && !config._retry) { if (isRefreshing) { return new Promise((resolve) => { pendingTasks.push((token) => { config.headers.Authorization = `Bearer ${token}`; resolve(service(config)); }); }); } config._retry = true; isRefreshing = true; try { const { data } = await refreshNewToken(); const newToken = data.access_token; pendingTasks.forEach((cb) => cb(newToken)); pendingTasks = []; config.headers.Authorization = `Bearer ${newToken}`; return service(config); } catch (refreshError) { pendingTasks = []; // 跳转登录页,清理本地状态 window.location.href = '/login'; return Promise.reject(refreshError); } finally { isRefreshing = false; } } return Promise.reject(error); } );这段代码能解决绝大多数并发401刷新问题。路由守卫则负责在前端路由层面拦截未登录用户,检查内存或状态管理器里有没有用户身份信息,没有就跳转登录页。注意这里不要只检查有没有token,因为过期token也存在,更好的做法是结合token的过期时间判断,如果已过期且刷新失败,路由守卫应该把用户送回登录页。
6.4 登出与清理细节
登出操作会暴露很多项目对认证授权理解的深浅。最简单也最不完整的做法是前端清掉本地token,这样用户确实回不到页面了,但只要旧token没到过期时间,资源服务仍然接受它,这是典型的安全隐患。
完整的登出应该分为四步:前端调用后端登出接口;后端删除当前设备对应的refresh token,并且如果有服务端维护的会话状态也一并清理;后端响应成功后,前端再清掉本地access token;如果需要单点登出,再考虑调用OIDC的end_session_endpoint,把所有使用同一身份的应用会话都退出。访问Access Token因为没有状态,本身做不到服务端撤销,我们依赖短过期时间把这个风险窗口压小,再由refresh token撤销来兜底。
6.5 一个完整的登录到请求链路
把这些环节串起来,一个典型的SPA登录流程应该是这样的:用户打开登录页,输入账号密码和验证码;前端先请求验证码接口,拿到captchaId并展示SVG;提交登录请求时带上验证码和用户凭证;后端校验验证码通过后,走OIDC授权码流程向认证服务器换取id_token和refresh_token;后端从id_token取出用户身份,签发自己的access token和refresh token返回给前端;前端把access token存在内存、refresh token放在httpOnly Cookie;后续每一次API请求由拦截器自动带上Authorization头;一旦收到401,触发刷新逻辑并获得新的access token后重放请求;用户点击退出,调用后端登出接口,后端删除refresh token,前端清理内存和本地状态。
回看整个认证授权流程,最核心的体会是:标准协议本身不会让你直接变得安全,它只是把复杂问题拆成了清晰的分工。OAuth 2.0管授权过程,OIDC管身份认证,JWT管凭证格式,每一层都有固定的校验点。把这些校验点老老实实做全,再把token续签和撤销机制设计好,认证授权体系基本就稳了。最后分享一个我个人的习惯:每次上线认证相关功能,我都会花半天时间用攻击者视角自己过一遍,拿着官方文档里的JWT漏洞清单逐项测试,这套流程能提前挡掉好多次线上事故。