写了好几年微服务架构,Token鉴权这件事我前前后后重做过三次。为什么重做?因为每次都是业务跑到一定规模,才发现当初拍脑袋选的方案撑不住了。微服务的Token鉴权方案不算少,但网上大部分教程都只讲一种,讲“JWT怎么做”,很少讲清楚“为什么你的场景不该用JWT”。这篇文章我就把主流的几种方案放在一起拆一遍,各自的原理、实现细节、坑,以及到底该怎么选。
我准备把这几种方案都讲明白:纯JWT无状态鉴权、Redis集中式Token校验、JWT和Redis结合的混合方案、双Token刷新机制,以及网关统一鉴权的架构模式。不管你是刚开始搭微服务,还是已经在维护一套老系统想要改造,这篇文章应该都能给你一些参考。先说明一下,文里的实现代码大多是示例性质,重点是讲思路和取舍,不是直接给你一套可以盲抄的完整代码。
1. 先想清楚:微服务场景下Token鉴权到底难在哪?
1.1 从单体到微服务,认证问题变成了什么样
在单体应用里,Token鉴权其实挺省心的。用户登录一次拿到一个Token,请求经过中间件校验,校验过了就放行,整个流程线性清晰。那时候最常用的方案是Session加Cookie,服务端存Session,浏览器存Cookie,一个进程搞定一切。
到了微服务架构下,问题就完全变了。假设一套系统拆成用户服务、订单服务、支付服务、通知服务,请求进来以后可能要先过网关、再经过两三个服务协同处理。这时候你会发现,原来单体里那套“登录后所有请求都带同一个Session”的思路行不通了——每个服务都是独立部署的进程,Session存哪个服务里?A服务创建的Session,B服务怎么知道它有效?
于是Token登场了。Token本质上就是一段“用户身份的凭证”,由认证服务签发,后续每个服务都能通过Token来确认“这个请求是谁发起的”。看起来很简单,但真正设计起来,你会遇到的第一个灵魂拷问就是:Token到底做成无状态还是有状态?
无状态的意思是Token本身包含了足够的信息,服务端只需要验签就能相信它;有状态的意思是Token只是一串随机的“钥匙”,真正的用户信息存在Redis这类共享存储里,拿到Token要去查一下才知道是谁。这两个思路几乎是所有方案的分水岭,下面讲的几种方案,本质上都是在这两者之间做权衡。
1.2 方案选型要看哪几个维度
我见过很多团队选方案的时候,只看“JWT很流行”就冲了,或者看别人用什么就抄什么。其实Token鉴权方案没有绝对的好坏,只有适不适合。这里我整理一下选型时比较关键的维度,后面的方案对比也会围绕这些维度展开。
第一是安全性。Token被窃取后能撑多久?服务端能不能主动让某个Token失效?用户改密码之后,旧Token是立即失效还是要等到自然过期?这些需求用无状态方案很难满足。
第二是性能。微服务场景下,一次请求可能要穿透好几个服务。如果每个服务都要去查一次Redis,多出来的网络开销你受不受得了?高并发情况下,Redis能不能顶住?
第三是运维复杂度。看似简单的方案,上了规模之后可能会很麻烦。比如纯JWT看起来简单,但密钥轮换、过期时间管理、多环境配置,每一项都要花心思。Redis方案则要考虑Redis的高可用、Key的清理策略。
第四是扩展性。业务如果以后要拆更多服务、或者要接入第三方应用,当前的鉴权方式能不能平滑延伸?OAuth2.0、SSO、开放平台这些需求,对方案选型的影响非常大。
把这些维度想清楚,再回头去看方案,思路会清晰很多。下面我逐个拆方案。
2. 方案一:纯JWT无状态鉴权
2.1 JWT的结构与签名逻辑
JWT(JSON Web Token)是纯JWT方案的核心。它由三段组成:Header、Payload、Signature,三个部分用点号连接,整体长这样:
eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6ImRlbW9fdXNlciIsImV4cCI6MTc0MDAwMDAwMH0.4B4FvzKQvC0Header部分一般声明签名算法和Token类型,Payload部分放自定义的claims,比如用户ID、角色、过期时间,Signature则是前两段加密钥算出来的签名,用来保证内容没被篡改过。
如果使用HMAC SHA-256算法,签名的计算过程本质上就是:
HMACSHA256( base64UrlEncode(header) + "." + base64UrlEncode(payload), secret )服务端校验Token的时候,只需要做三件事:拆开Token,重新计算签名跟Token自带的签名比对,然后检查exp这些时间claims。整个过程不依赖任何外部存储,这是JWT最核心的优点。
2.2 纯JWT的落地细节与适用场景
我实际项目中有一组内部报表服务用的就是纯JWT方案。这套服务只对内部系统开放,用户量不大,没有“踢人下线”的需求,用户登录后Token过期时间设成24小时,接口拿到Token验签通过就放行。
落地的时候有几个细节要注意。签发Token的时候,claims不能随便乱放,应该遵循最小化原则。sub放用户ID,exp放过期时间,iat放签发时间,这是基本操作。千万别把用户的手机号、身份证号、密码哈希这些敏感信息放进去,因为JWT的Payload只是Base64Url编码,不是加密,任何拿到Token的人解码就能看到内容。
密钥管理是另一个重点。HS256是共享密钥,签名和验签用的是同一个secret,一旦泄漏攻击者就能随意伪造Token。所以生产环境一定不要硬编码在代码里,建议放配置中心或者密钥管理系统,并且定期轮换。更稳妥的做法是改用RS256,用私钥签名、公钥验证,这样密钥分发和轮换的压力小很多,但代价是验签性能比HS256略慢。
纯JWT方案的优点很突出:服务完全无状态,水平扩展想加几个实例就加几个,什么都不用同步;验签过程在本地完成,性能是所有方案里最高的。但它的缺点同样致命:Token一旦签发,在过期之前无法主动失效。用户点了注销,只要Token还在有效期内,照样能访问接口;用户改了密码,旧Token还是能用的。如果你对这类“主动失效”的诉求比较强烈,纯JWT就需要打一些补丁,这就引出后面的混合方案。
3. 方案二:Redis集中式Token校验
3.1 有状态Token的校验原理
如果说纯JWT方案是“Token自己说明一切”,Redis方案就是“Token只是一把钥匙,用户信息放在储物柜里”。具体做法是:用户登录成功后,服务端生成一串随机的Token字符串(也叫不透明Token),然后把Token作为key、用户信息作为value存进Redis,同时设置过期时间。客户端后续请求带上这个Token,服务端拿着Token去Redis查,查到了就说明这个会话有效,顺便把用户信息取出来用。
这个方法在单体应用的时代非常常见,本质就是Session的另一种实现方式。它和传统Session的区别只是把存储从内存换到了Redis,让所有服务都能共享会话数据。在微服务场景下,这套方案的优点是任何服务都能直接用同一个Token去Redis确认用户身份,不用关心Token是怎么签发的。
Redis里的数据设计一般是这样的:
key: auth:token:6f8b2c9e4a1d3f5e value: {"userId": 10001, "userName": "demo_user", "role": "admin"} ttl: 7200秒3.2 落地细节和性能考量
我在做一套B端管理系统的时候用的是这套方案。当时的需求很明确:管理员要能查看所有在线用户,并且能把某个人强制下线。这种诉求用纯JWT根本实现不了,而Redis方案就很简单——想把谁踢下线,直接删掉对应的key就行了。
落地的时候有这样几个细节要注意。
过期时间的设计。如果用户连续操作2小时会话都不应该断,而Redis的过期时间又是从登录那一刻开始只减不增的,那用户在第119分钟发一个请求,到第121分钟就会被踢下线。解决这个问题要考虑做成滑动过期:每次请求的时候顺便对Redis里的key做一个TTL续期操作,保证“只要一直在操作,会话就一直有效”。续期Redis的过期时间本身没多少开销,大多数场景值得做。
Key的设计要带前缀和后缀的统一规范,比如格式固定成auth:token:{tokenId}。这样以后可以按前缀批量查询、按业务线区分,运维的时候不至于抓瞎。
性能方面,Redis的读操作通常1到2毫秒能完成,绝大多数业务系统完全够用。但如果你的核心链路是那种千万级并发、对每个请求都要求极致延迟的场景,每多一次Redis往返就会多出开销,这时候就要考虑请求合并、本地缓存之类的优化手段了。
这套方案的优点是“说失效就失效”,安全性可控,逻辑简单直观。缺点则是它对Redis的可用性要求很高——如果Redis挂了,所有请求都会因为查不到Token而失败,整个系统直接不可用。所以上了Redis方案,Redis的高可用(比如主从复制加哨兵)就不是可选项,而是必选项。
4. 方案三:JWT + Redis混合策略
4.1 混合方案的设计思路
有没有一种方案,既能保留JWT无状态验签的高性能,又能解决纯JWT无法主动失效的问题?这就是JWT + Redis混合方案,也是我目前最推荐多数业务系统采用的方案。
混合方案的思路分两条线:
第一条线是“白名单”思路。签发JWT的时候往claims里加一个唯一标识jti,同时把jti存进Redis,value可以写用户信息,TTL和JWT的过期时间保持一致。校验Token的时候,先本地验签名,再查一下Redis里这个jti还在不在。在,就放行;不在,就拒绝。这样用户注销的时候,只需要把对应的jti从Redis删掉,剩下的Token马上就失效了。
第二条线是“黑名单”思路。正常验签流程和纯JWT一样,区别只是在用户注销时把要作废的jti写进黑名单,TTL设置为Token剩余的过期时间。校验的时候,查一下jti在不在黑名单里,不在就放行。不过这里有个细节要想清楚:黑名单Redis本身会越存越大,每次校验都要做一次查询,但查询一次Redis的代价很低,通常不是瓶颈。
白名单和黑名单哪个好?我个人的经验是,如果系统里需要支持“用户只能保留一个会话”这种强管控需求,白名单更顺——登录后直接把该用户的旧jti全部清掉;如果只是常规的注销、改密码失效,黑名单更灵活,至少不用把所有有效Token都塞进Redis。
4.2 版本号机制与黑名单粒度的优化
这里再分享一个我自己觉得很有用的进阶做法:版本号机制。它的思路是,在用户表或者Redis里维护一个sessionVersion字段,签发JWT的时候把这个版本号写进claims。每次校验的时候,把claims里的版本号和Redis里的当前版本号比对一下。如果用户改密码或者被强制下线,就把版本号加一,所有之前签发的Token瞬间全部失效。这个机制非常适合“密码修改后所有端强制下线”的场景,相当于一个超级黑名单,粒度是整个用户而不是单个Token。
黑名单粒度方面容易犯的错是:一上来就给所有Token都加Redis查询,结果缓存里存了几百万个jti,白白浪费内存。更好的做法是区分场景——常规请求走纯JWT验签,只有在注销、改密、高危操作复核这些少数场景下才动用Redis状态查询。这其实是一种“把无状态作为默认路径、把有状态作为例外路径”的设计哲学,能让大部分请求保持在最快路径上。
混合方案的性能介于纯JWT和Redis方案之间,因为多了一次Redis查询,但通常不会成为瓶颈——Redis单机每秒能处理几万到十几万次读请求,你每次请求只查两次,绰绰有余。配置上还有一些可以琢磨的点,比如有些团队会把读请求打到Redis从库上、或者用本地缓存缓存“白名单命中结果”几秒钟。这些优化都能把性能再往上顶一顶。
5. 方案四:双Token机制(Access Token + Refresh Token)
5.1 双Token的分工逻辑
如果上面的方案关心的是“怎么做校验”,双Token机制关心的是“怎么让Token既短命又好用”。在很多互联网产品里面,Access Token如果做得太短,用户十分钟就要重新登录一次,体验非常差;如果做得太长,Token被攻击者拿走之后盗用的窗口期又太长。双Token机制就是为了缓解这个矛盾。
它的分工是这样的:Access Token有效期很短,通常15分钟到2小时,一般直接做成JWT,无状态验签;Refresh Token有效期长,7天到30天,用于在Access Token过期之后向认证服务换取新的Access Token。客户端平时请求接口只带Access Token,只有遇到401的时候才走一遍刷新流程。
用户在整个生命周期里,几乎感受不到Token在不停地轮换。对服务端来说,短命的Access Token大幅降低了盗用风险,而长命的Refresh Token又保证了用户体验的连续性,这是个比较优雅的折中。
5.2 刷新接口的设计细节与安全注意点
双Token机制真正有技术含量的地方在刷新流程。我之前做过一个平台,一开始刷新接口设计得很天真——只要Refresh Token没过期,就直接发一个新的Access Token,旧Refresh Token继续用。后来发现一个问题:如果攻击者盗走了Refresh Token,他可以无限地刷新Token,用户本人反而毫无察觉。
后来改成刷新即轮换(Refresh Token Rotation)的模式。具体流程是:客户端拿Refresh Token来换取新Access Token时,服务端验证Refresh Token有效后,立即签发一组新的Refresh Token和Access Token,同时把旧Refresh Token标记为已使用或直接删除。这样一来,Refresh Token变成了一次性令牌,攻击者即使拿走了,也只能用一次,暴露窗口大幅缩短。
还有一个很坑的问题:客户端并发请求导致的刷新竞争。多个请求同时因为Access Token过期而返回401,如果它们同时拿同一个Refresh Token去刷新,按照“旧Refresh Token要被作废”的逻辑,就只有一个能成功,其他请求会拿着已作废的Token刷新失败。我见过有的团队为了解决这个问题,直接放弃了轮换机制,这是因噎废食。正确的处理方式是在刷新接口里做并发控制——比如用Redis的分布式锁或者原子操作,确保同一个Refresh Token只能被消费一次,其他并发请求要么重新拿新的Refresh Token重试,要么排队等第一个请求完成后再刷新。
Refresh Token的存储位置也是个隐患。对Web端来说,很多人习惯把Access Token放localStorage,Refresh Token也放localStorage,这样XSS漏洞一打,两个Token全被拿走。我个人的建议是Access Token可以放内存或localStorage,Refresh Token放进HttpOnly的Cookie里,让JavaScript读不到它,这样即使有XSS漏洞,攻击者也只能偷到短命的Access Token。
6. 方案五:网关统一鉴权 + 服务间透传
6.1 网关统一处理鉴权的架构模式
当微服务数量多了以后,你会发现一个问题:如果几十个服务每个都写一遍Token验证逻辑,代码重复不说,只要算法或者密钥一变,就要同步改所有服务,非常痛苦。于是“把鉴权收敛到网关”就成了一种很自然的架构演进。
在这种架构下,API Gateway成为全部用户请求的唯一入口。用户登录后拿到的Token,只有网关会做严格校验。校验通过之后,网关会把解析出来的用户身份信息通过HTTP头透传给下游的各个业务服务。业务服务在网关的保护之后,默认信任网关传过来的身份头。
具体落地的时候,透传的HTTP头命名需要统一约定,比如X-User-Id、X-User-Name、X-User-Role。网关在转发请求之前把这些头写进去,业务服务从这些头里面取用户信息即可。这样业务团队在开发新接口时,根本不需要关心Token怎么校验,直接读header就行,开发效率提升非常明显。
6.2 网关系透传的安全边界与内网部署防护
网关集中鉴权也带来一个安全上的大坑:透传头的信任边界。如果网关后面的某个服务本身也是可以被外部直接访问的,攻击者完全可以绕过网关,直接伪造X-User-Id: 1这样的头去请求业务服务。这样一来,整个鉴权体系形同虚设。
所以这种架构要求内部服务之间的网络必须做严格隔离。业务服务绝对不能暴露在公网,只允许通过内网或者服务网格访问。可用的手段包括:内部服务监听内网IP、用安全组或防火墙限定来源IP、在服务之间启用双向TLS认证(mTLS),这些手段能保证“能碰到业务服务的只有网关和可信的服务”。
另外还有一个很多人会忽略的点:网关作为新架构里的新单点,它的高可用和水平扩展必须提前规划好。网关崩了,所有鉴权都失效,整个系统都进不去。所以网关本身要支持集群部署,要有健康检查和自动恢复机制。
网关统一鉴权的优点是把复杂度集中收口了,对业务代码侵入最小;缺点是引入的架构组件更多,运维要求更高。如果团队规模大、服务数量多、有平台化运维能力,这套架构会非常舒服。
7. 五种方案对比与选型指南
7.1 核心参数横向对比
说了这么多,我把五种方案放在一张表里对比,方便你直接对照自己的场景来评估。这里的评价是基于常规配置和实践经验,具体数值取决于你的系统压力。
| 对比维度 | 纯JWT | Redis Token | JWT+Redis混合 | 双Token | 网关统一鉴权 |
|---|---|---|---|---|---|
| 状态性 | 无状态 | 有状态 | 半状态 | 混合 | 取决于Token实现 |
| 主动失效 | 不支持 | 支持 | 支持 | 支持(刷新+吊销) | 取决于Token实现 |
| 屏蔽盗用窗口 | 依赖短过期时间 | 依赖过期时间 | 依赖过期时间 | 短Access+轮换 | 取决于Token实现 |
| 校验性能 | 最高(本地计算) | 中(查Redis) | 中高(本地计算+查Redis) | 中高(刷新时才查库) | 中(受网关单点影响) |
| 复杂度 | 低 | 中 | 中高 | 高 | 高 |
| 依赖组件 | 无 | Redis | Redis | Redis(刷新/吊销) | 网关+可能Redis |
| 典型场景 | 内部服务/低安全诉求 | 管理后台/强管控 | 大部分Web/App业务 | 对安全要求高的互联网产品 | 超大规模微服务团队 |
7.2 按场景选型的决策思路
结合上面这张表,我给出比较接地气的选型建议:
如果是内部系统、工具类平台、或者并发很高但对安全要求不那么苛刻的场景,纯JWT完全够用。它的实现成本最低,横向扩展最舒服,别为了“别人都用Redis”就无脑加复杂度。
如果是管理后台、B端产品,有“强制下线”“查看在线用户”“单点登录”这类强管控需求,Redis集中式方案最直接。逻辑简单,团队成员上手快。
如果是用户量不小的C端App或Web系统,需要频繁登出、改密、多端管理,同时对接口性能还有要求,JWT + Redis混合方案是比较稳的选择。先用纯JWT保持高性能,再用Redis做必要的黑名单/白名单控制。
如果是面向公众的互联网产品、对账号安全极其敏感,建议在混合方案之上叠加双Token机制。这样即便Access Token被盗,盗用窗口也只有十几分钟;Refresh Token即使被偷,刷新轮换机制也会很快暴露异常。
如果你所在团队的微服务数量已经很多,服务治理能力也比较强,那网关统一鉴权是你必须考虑的架构方向。它不只解决鉴权问题,还有限流、灰度、监控等一系列附加价值。但要注意它的前提是服务间网络隔离做得足够好。
8. 踩坑实录:Token鉴权日常运维中我踩过的那些坑
8.1 时钟偏移导致的“已过期”误判
很多人在自测JWT的时候遇到过一种灵异现象:明明Token刚签发,服务端却告诉它“Token过期”。排查到最后,大概率是签发服务和校验服务的服务器时间不一致。JWT里exp、iat都是绝对时间戳,如果校验服务所在的机器时间比签发服务快了一两分钟,本来就临界的Token就会被判定过期。不止JWT,Redis的TTL、Session的过期时间都有这个问题。
解决思路有两个:一是做系统层面的时间同步,这个基础工作不用我多说了;二是在校验逻辑里给时间比较加上一个可容忍的偏移量(也就是leeway),比如校验exp的时候允许比当前时间多30秒或1分钟。Java的jjwt、Python的python-jose这些库都支持设置leeway,随手配一下就能避免很多莫名其妙的问题。
8.2 密钥管理和轮换是隐藏的坑
纯JWT用得久了,最容易被忽视的是密钥管理。我见过有团队把JWT的secret直接写死在代码里,还是Git仓库里的明文,这基本上是给攻击者递刀。也见过HS256的secret设成“123456”的。这类问题往往不是技术问题,而是流程问题,建议强制走配置中心或密钥管理系统。
再讲一个更隐蔽的坑:密钥轮换。你决定三个月轮换一次密钥,但轮换之后,用旧密钥签发的Token全部变成非法,已经登录的几万个用户全部被踢下线。正确做法是密钥带上版本概念,校验的时候先用当前密钥验,验证失败再尝试用上一版本的密钥验,这样新老Token能平滑过渡。如果用的是RS256这类非对称算法,还需要额外管理公钥的发布渠道,让所有服务能及时拿到最新公钥。
8.3 Redis故障引起的“全员登出”
Redis方案最怕的一件事是缓存击穿叠加缓存雪崩。如果Redis里存了Token,而Redis在某个时间点集中宕机,那就不是“几个用户登出”的问题,而是所有用户的Token都无法验证。在这种故障下,即使Redis恢复,也得让所有用户重新登录一次,体验非常差。
所以在做Redis方案的时候,一定要把“缓存降级”想清楚。例如Redis主从切换期间读请求打到从库,从库数据可能稍有延后,可以通过短暂容忍或者剔除缓存来解决。再比如可以在Redis恢复正常之后,通过对Token的ttl检查和延迟续期,恢复用户的会话,而不是让用户重新登录。
我这几年做Token鉴权的体会是:没有全能的方案,只有适配当前业务阶段的选择。很多时候问题不是出在方案本身,而是出在“不清楚自己的场景要求什么”。搞清楚业务对主动失效、性能、运维复杂度、安全窗口的诉求,从这几种方案里挑一个主栈,再结合一两个技巧补齐短板,基本就能构建一套长期不用推翻的鉴权体系了。