☰
OAuth2.0令牌续期:Access Token与Refresh Token的安全协作
2026/10/14 17:16:52 网站建设 项目流程

你负责的接口如果每隔十五分钟就要让用户重新登录一遍,产品经理早就拿刀找你谈话了。但如果你让access token跟信用卡额度一样动不动就给一整年,那一旦泄漏,基本就是给攻击者送了一个永久后门。OAuth 2.0的Token续期处理,本质上就是在这两个极端之间找平衡:短期凭证负责频繁验证,长期凭证负责低调续命,两者配合,既不让用户反复输密码,也不让泄露风险无限放大。

这篇文章写给所有被Token过期时间和刷新机制折磨过的后端、前端和客户端开发。我会从续期策略的设计思路讲起,把access token和refresh token的分工逻辑拆清楚,再带上完整的刷新流程、安全细节、并发场景处理,以及我实际调试接口时踩过的那些坑。不管是刚接触OAuth 2.0的新手,还是已经在生产环境里跑了好几年老系统的开发,应该都能从这里找到能直接用的东西。

1. 续期设计的核心思路:为什么非要把Token拆成两个

1.1 短期Token的安全逻辑

先说access token。它存在的意义就是让客户端在请求资源时能证明“我就是刚才那个通过登录的人”。问题在于,Token这东西一旦签发出去,服务端在大多数情况下没法主动收回——除非你愿意每次请求都查一遍数据库,那就等于把无状态认证的便利全扔了。

所以实际操作中大家达成了一个共识:把access token的生命周期压缩到很短,短到即使泄漏,攻击者的利用窗口也极其有限。一个15分钟有效的access token,就算被截获,黑客能拿它做的事也非常少,等你发现异常把它撤销的时候,它多半已经自然过期了。这个思路其实和你在停车场拿的临时票据有点类似——票据只允许你在停车期间进出,出场之后这票就没用了,丢了也不至于有人能拿它天天来蹭车位。

1.2 长期Token的续命职责

光有短期Token还不够,因为用户不可能每15分钟就重新输一次账号密码,那体验直接倒退到二十年前。所以我们需要另一个凭证,专门负责在access token快过期时去换一张新的,这就是refresh token。

refresh token是长期凭证,它的生命周期可以是几小时、几天、一个月甚至更长,完全取决于你的业务对安全性和便利性的容忍度。它只做一件事:去Token端点换新的access token。平时不会被带到业务请求里,也不该被前端JavaScript直接读取,存储位置和安全等级都要比access token更严格。

这两者分工明确,我经常用门禁卡类比:access token是临时访客卡,安保严格,几小时就失效;refresh token是你自己的员工卡,长期有效,但只用于在门卫处换取新的访客卡,不能直接刷开任何办公区大门。一个负责日常验证,一个负责后台续期,各管一段。

1.3 无状态认证的取舍

很多团队一开始用OAuth 2.0,是看中它对客户端友好、生态成熟、各种语言都有现成库。但用着用着就会发现,Token续期这件事如果要做得稳,完全“无状态”是很难的。refresh token的吊销、轮换状态的记录、用户密码修改后的token失效,这些都需要服务端存点东西。

现实中的常见做法是一种妥协:access token继续走无状态验证,靠签名和过期时间就能校验;refresh token走服务端存储,用它换新token时顺便查一下状态,确认它没被吊销、没被轮换掉、用户账号还在。这样你既保留了无状态验证的高性能,又拿到了长期凭证的可控性。

2. 续期流程完整拆解:从授权码拿到第一对Token开始

2.1 标准刷新流程长什么样

先看一个最简单的刷新请求是什么样的。客户端拿着refresh token,向授权服务器的Token端点发一个POST请求:

POST /oauth/token HTTP/1.1 Host: auth.example.com Content-Type: application/x-www-form-urlencoded grant_type=refresh_token &refresh_token=YOUR_REFRESH_TOKEN &client_id=YOUR_CLIENT_ID &client_secret=YOUR_CLIENT_SECRET

这里有几个细节值得注意。grant_type必须是refresh_token,这是告诉服务端“我现在要干的是续期这事”;refresh_token字段就是我说的长期凭证;client_id和client_secret是客户端自己的身份,用来证明谁在发起刷新。对于纯前端应用,secret没法保密,所以通常省略或者用PKCE替代。授权服务器验证通过后,返回新的Token:

{ "access_token": "NEW_ACCESS_TOKEN", "token_type": "Bearer", "expires_in": 900, "refresh_token": "NEW_REFRESH_TOKEN", "scope": "read write" }

这个响应里有三个关键信息:新的access token,有效期通常是900秒也就是15分钟;refresh_token字段可大可小,可返回新的也可不返回,这取决于服务端是否开启了刷新令牌轮换策略;scope用于告知当前这个新Token的权限范围,如果你的服务支持动态缩权,刷新时可以传一个比原来更小的scope来限制权限。

2.2 刷新令牌轮换机制

我第一次看到有些OAuth服务端每次刷新都返回一个新的refresh token时,第一反应是这玩意儿是不是太浪费了。后来才明白,这是安全上非常重要的一环,专业术语叫Refresh Token Rotation。

轮换的逻辑很简单:每次用旧refresh token换新access token时,服务端同时签发一个新的refresh token,并且让旧的那个立刻失效。这样就算某个refresh token被攻击者偷走了,他拿着偷来的token去刷新时,服务端要么发现它已经被用过、要么直接把它作废,进一步刷新就不可能了。这等于给长期凭证加了一道“一次性使用”的保护。

当然,光轮换还不够,服务端最好能把“旧refresh token是否已经用过”这个状态记下来。发现有人拿着一个已经被轮换过的token来刷新时,这基本就能判定为一次异常行为,正常用户不会反复拿同一个refresh token用两次,直接把当前会话里所有关联的token全部吊销,是守住底线的最好选择。

2.3 刷新Token的过期时间怎么定

这是我在微信群和技术社区里被问得最多的问题:access token到底该设置多久,refresh token该设置多久。答案永远是一句“看业务”,但我可以给你一个比较靠谱的起跑线。

对于大多数B端后台系统,access token设为15分钟、refresh token设为24小时,是一个安全性和体验都不错的搭配。15分钟内用户操作不会频繁掉线,24小时足够覆盖绝大部分工作日场景,第二天来了重新登录一次也合理。对于移动端App,refresh token可以放宽到30天甚至更长,毕竟手机上不会经常清数据,用户不会愿意每周都被要求重新输一次密码。对于资金操作、密码修改这类高敏操作,我倾向于在操作前强制要求重新走一次授权流程,而不是依赖长refresh token,别让安全人员来找我写事故复盘。

我给你整理了一张参考表,方便你按业务类型快速定位:

业务类型Access Token有效期Refresh Token有效期备注
高敏金融操作5-10分钟1-7天关键操作二次验证
普通B端后台15分钟24小时平衡安全与体验
移动端App15-30分钟30天可接受静默续期
低敏IoT设备1小时以上90天或更长刷新频率低,可放宽
第三方开放平台1小时15-30天需要支持主动撤销

这里要强调一句:refresh token的时间不要拍脑袋定,最好结合你的实际用户行为数据来调。如果你的用户群体是每天工作12小时的设计团队,24小时会让他们每天上班第一件事就是重新登录,体验极差;如果你的产品是应急指挥系统,那刷新周期就得往短了压,安全永远排第一。

3. 实操阶段的并发与续期策略优化

3.1 并发刷新风暴问题

项目上线一段时间后,迟早会碰上一个很经典的问题:access token有效期设置太短,客户端在token快过期时同时发起多个请求,结果每一个请求都发现token快过期了,然后每一个请求都带着同一个refresh token去刷新,服务端一次性收到好几条refresh请求,瞬间就把refresh token给轮换废了,于是大量请求全部401。

我当年第一次遇到这问题的时候,反应速度是“重启大法”,后来看了日志才确认是并发刷新把token搞废了。解决思路有两个层面。客户端层面,要做token的统一刷新管理,用一个单例的刷新任务来合并并发请求,同一时间只有一个请求真正去调refresh接口,其它请求等待这个刷新完成后,直接使用新的access token重放业务请求。服务端层面,要在refresh token轮换逻辑里做好并发保护,发现同一refresh token被并发使用时,保留其中一个成功响应,其它直接拒绝或返回,且不能轻易吊销整条链路,否则用户会被无端端踢下线。

3.2 刷新锁与单飞模式的落地

客户端实现并发控制的常见手法是给刷新任务加一个“进行中标记”,也就是Race Condition处理的标准姿势。我简化为一个状态变量加Promise队列:

let refreshPromise = null; function ensureFreshToken() { if (refreshPromise) { return refreshPromise; } if (tokenManager.isExpiringSoon()) { refreshPromise = doRefreshToken() .finally(() => { refreshPromise = null; }); } else { refreshPromise = Promise.resolve(tokenManager.getAccessToken()); } return refreshPromise; }

这段代码的思路很直白:refreshPromise存在时说明已经有人在刷新了,后面来的请求直接挂到这个Promise上,等刷新完成统一拿到新token;isExpiringSoon()是一个预判方法,提前在token过期前的某个时间点(比如还剩2分钟)就触发刷新,避免请求正好卡在过期时刻。这个模式在客户端领域叫“单飞”,在高并发后端里也有同类实现,核心就是保证同一时间的重复工作只做一次。

配套的还有一个常用策略叫“预刷新”或“静默续期”,就是在token正式过期前,客户端利用idle时间主动刷新。移动端尤其适合这个策略,用户玩着玩着token就已经悄悄续过了,真正需要弹窗重新登录的场景大大减少。

3.3 滑动续期策略

还有一种续期策略在会话管理里很常见,就是滑动过期。用户每次活跃时,refresh token的有效期自动向后延长一段固定时间,比如用户今天打开了应用,那refresh token从今天的活跃时间点重新计算30天;用户连续30天没打开应用,那token就自然失效,需要重新登录。

这个做法的好处非常明显:不活跃的用户不会被无限期保留登录态,活跃用户的体验又极其顺滑,不用每隔一个月被迫重新登录一次。实现上也不复杂,服务端每次刷新token时,除了签发新token,还可以将refresh token的过期时间更新为当前时间加固定时长。这种策略适合消费类产品、SaaS工具,但对企业内部系统和高敏操作不太友好,毕竟一个长期不退出又长期活跃的账号,等于把大门永远敞开,一旦设备丢失,后果要比普通短token严重得多。

3.4 主动登出与Token吊销

Token续期不能只聊“续”,还得聊“停”。用户点击退出登录、账号密码泄露、用户离职、后台强制下线,这些场景都需要让refresh token和access token立刻失效。

OAuth 2.0的RFC里其实没有强制要求提供Token吊销端点,但绝大多数靠谱的授权服务器都支持RFC 7009这个扩展规范。注销操作一般是请求:

POST /oauth/revoke HTTP/1.1 Host: auth.example.com Content-Type: application/x-www-form-urlencoded token=REFRESH_TOKEN &token_type_hint=refresh_token

服务端在收到这条请求后,应该把对应的refresh token标记为已撤销,如果有关联的access token也一并禁用。这里的关键是:吊销操作绝对不能只停留在客户端本地把token删掉就完事,服务端不吊销的话,攻击者手里如果还握着token,照样能继续换新的。

4. 安全细节与常见隐患排查

4.1 Refresh Token的存储位置决定了安全底线

这是整个续期机制里最容易被忽视的一环。很多团队在access token的存储上非常谨慎,指定要用内存、要放安全存储区,但轮到refresh token,随手就扔进了localStorage或者直接放在全局变量里。这等于把员工卡和访客卡一起塞到了同一个没上锁的抽屉里。

Web端处理refresh token,最安全的方案是放进HttpOnly的Cookie里,配合Secure和SameSite属性。这种方式下前端JavaScript根本读不到这个token,XSS攻击也就无从下手。原生App端则应该放进系统的安全存储区,比如iOS的Keychain、Android的Keystore,尽量别用明文SharedPreferences。桌面应用同理,Windows凭据管理器或macOS Keychain都是合理的选择。如果你的客户端完全没办法保证安全存储,那就得考虑是否要放弃refresh token,改走每次请求都重新授权的方案了。

4.2 绑定设备指纹与上下文校验

一个只依赖token本身的续期流程是不完整的。我始终建议在服务端记录re- fresh token签发时的设备上下文信息,比如设备ID、IP段、User-Agent这些,等到刷新请求到来时做一个比对,如果设备ID变了、IP段跳到了不同的大洲,那不管refresh token本身对不对,都当成可疑行为处理。

这里说的设备指纹不一定非得是特别复杂的SDK采集那种,一个简单但有效的做法是:在客户端每次刷新时携带一个自己生成的设备唯一ID,服务端在首次签发refresh token时把这个ID和token绑定,刷新时校验是否一致。这个方法便宜、实现快,能挡掉大量凭空盗用token的脚本攻击。真实业务里,我见过很多攻击事件其实都发生在token从手机A复制到电脑B的这种场景,一个简单的设备绑定就能让攻击者直接吃闭门羹。

4.3 密码修改后的Token全部作废

用户改了密码、忘了密码重置了密码、账号被管理员重置了权限,这些情况下所有已签发的refresh token都必须立刻失效。否则就会出现一个非常不安的场面:用户明明已经改了密码,攻击者却还能用旧refresh token继续续期,相当于换了一把新锁,旧钥匙照样能开门。

正确做法是每次生成refresh token时记录一下它对应的“凭据版本号”——可以简单理解成一个随密码修改而递增的整数。用户改密后,这个版本号加1,所有旧版本号的refresh token在刷新时直接被拒。这样既保证安全,也避免了每次用户改密都要清空整张token表的性能负担。

4.4 排查实录:我调试续期接口时遇到的五个高频问题

这里我把过去踩过的、以及带团队时新手最常来问我的几个问题统一整理成表格,方便你直接抄作业。

现象可能原因处理思路
刷新返回401 invalid_grantrefresh token已过期、已轮换或已被吊销引导用户重新登录,检查吊销逻辑
并发刷新导致大量401多个请求共用同一refresh token客户端加刷新锁,服务端做并发幂等保护
刷新返回的新token立刻失效授权服务器时间不同步检查NTP,统一各节点时间
用户频繁被要求重新登录refresh token有效期设置过短结合业务行为调整过期时间
刷新成功后旧token还能用一会未启用轮换且未记录吊销状态开启轮换策略,服务端记录已用状态

这里面时钟漂移这个坑特别值得展开。很多授权服务器签发access token时直接使用系统时间,如果签发节点和验签节点的系统时间差了几十秒,就会出现token刚签发就被判定过期的诡异现象。排查方法很简单,对比各服务器的系统时间,配置好NTP同步就够了。但这种事情一旦发生,定位起来非常绕,因为它不会暴露在应用日志里,而是藏在系统层,我建议所有做SSO、鉴权服务的人提前把NTP监控纳入日常巡检清单。

4.5 刷新令牌被重放的处置策略

前面提到refresh token是一次性的,但现实总会出现误触发的场景:用户客户端因为断网重发了一次刷新请求,导致前一个refresh token被轮换失效,如果服务端一刀切把整个会话全部吊销,用户就会莫名其妙被踢下线。

比较合适的处理策略是分级处置。第一次发现旧refresh token被重复使用时,正常返回新token,但标记一个可疑计数;连续多次出现,再把关联的所有token全部吊销,并触发风控告警。这样既不影响正常用户因网络抖动带来的偶发重复请求,又能对真正的盗用行为进行拦截。这个策略需要你在服务端给refresh token记录一个used状态和use_count字段,每次刷新时更新,逻辑不复杂,但效果立竿见影。

5. 续期策略的进阶与扩展场景

5.1 单页应用与PKCE场景下的续期差异

单页应用这种纯前端架构,天然没法安全保存client secret,所以在OAuth 2.0的授权码流程里必须配合PKCE(Proof Key for Code Exchange)来用。我见过很多团队一开始省事,直接走Implicit Flow,后来发现token全暴露在URL和浏览器历史记录里,安全问题一抓一大把,最后才老老实实改回授权码加PKCE。

在PKCE模式下,refresh token的使用还有一个额外的注意点:刷新时通常不携带client_secret,而是把PKCE阶段生成的code_verifier一起带上,或者至少保持相同的client_id。这个细节不同授权服务器的实现略有差异,建议接入前仔细看文档,免得写完了才发现刷新请求一直被拒。

5.2 微服务架构下的Token续期设计

微服务网关统一接Token校验的时候,续期逻辑往往会集中到网关或者认证服务里,业务服务本身不直接调授权服务器刷新,而是拿着网关校验过的用户身份信息继续干活。这个模式对性能友好,但如果某个业务服务需要拿到新鲜token去调用第三方API,就需要考虑服务间token同步或者token缓存池子了。

常见的做法是在网关内存里维护一个token缓存,预判快过期时后台自动刷新,然后所有业务服务从网关的TokenProvider里拿token。这样可以避免每个微服务各刷各的、互相覆盖的问题。我还见过更激进的方案,直接用定时任务在后台每5分钟刷新一次第三方token,然后把新token写进配置中心,业务服务启动时拿一次,运行中动态刷新。对于无法感知token生命周期变化的服务来说,这算是个省事又可靠的土办法。

5.3 与JWT搭配时的续期陷阱

很多团队选型时直接用JWT做access token,因为header里能放用户信息,不用每次查库。但JWT的天生缺陷是签发后没法主动失效,只能等它过期。这直接导致一个经典组合:JWT做access token,refresh token做撤回手段,用户权限一变,等15分钟JWT过期再刷新的前提下才能生效。

于是续期策略里有个现在越来越主流的玩法:短寿命JWT加快速刷新。把JWT的过期时间压到5分钟,refresh token的有效期拉长,配合刷新锁和预刷新机制,用户几乎无感知地完成续期,同时权限变更最多延迟5分钟生效,比原来15分钟甚至1小时时效的响应快得多。代价是签发和验签的频率提高了,但对现代CPU来说这点压力不值一提,值得。

5.4 跨域SSO与多客户端Token同步

如果你的产品线复杂一点,比如同一家公司的Web端、iOS、安卓、小程序同时接入同一套认证系统,每个客户端各有一对token,它们相互之间是不会自动同步的。用户在网页改了密码,移动端的refresh token如果没做凭据版本号校验,就还能继续用,这会给安全审计带来风险。

我在多客户端项目里的经验是,统一用userId加一个会话版本号维度来管理refresh token,任何客户端触发密码变更、设备吊销、管理员强制下线,就把该userId的版本号置为一个新值,所有已签发token集体失效。这样同时支持“单点登录”和“全局踢下线”,比逐条删token记录不知道省多少心。

结尾:我在这块摸爬滚打后的几条实在建议

Token续期这块坑不算深,但坑底全是细节。如果让我用一句话总结这套机制的核心,那就是:access token保体验,refresh token保安全,配好了体验流畅,配砸了全是安全窟窿。

我个人实际落地的项目里,最建议你开工前先想清楚三件事:第一,refresh token是一次性轮换还是固定复用,这直接决定服务端要不要记录已用状态;第二,并发刷新由谁负责兜底,客户端还是服务端,两端必须有一端做了保护,否则线上迟早要爆一次;第三,是否绑定设备上下文信息,这能在token被盗后的第一瞬间就给你发现和处置的机会。别等到线上出事故了再回来补这些,那会儿代价不会小。

最后再分享一个小技巧:给token续期接口的响应里加一个refresh_token_expires_in字段,让客户端知道长期凭证还剩多少秒到期,这样前端就能在refresh token也快过期的时候,提前弹一次“请重新登录”的提示,而不是让用户在某一次刷新请求失败时面对一个莫名其妙的401。一次友好的提示,比一次错误日志更能留住用户。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询