公司内部两套业务系统各自为政,每个系统都维护一套账号密码,用户烦、运维也烦——这个场景在三年前我负责的那条业务线上真实上演过。当时团队里现状很分裂:老系统还在用 Session 挂会话,新系统已经拆成了前后端分离的微服务结构。要做 SSO(单点登录),传统以 Redis 存会话的统一认证中心方案虽然成熟,但对微服务那一侧并不友好。权衡之后,我们改用 JWT 实现了一套“无状态”的 SSO:登录状态不落库、业务侧不查会话表,靠一个签发的令牌完成跨系统身份确认。这套方案实测下来稳定了不少,中途也踩了不少坑。这篇就把整套设计、关键代码、坑点自查完整梳理一遍,适合正在做多系统登录打通、或者在 Session 与 Token 之间犹豫不决的开发者参考。
1. 无状态 SSO 的整体设计思路
1.1 SSO 要解决的本质问题
先拆一下 SSO 到底在解决什么。普通人理解单点登录就是“一个系统登录、所有系统免登”,但把这个诉求落到工程上,其实压缩成了三个问题:账号体系是否统一、登录态如何跨系统传递、以及每个系统靠什么判定用户“有效登录”。
账号体系通常相对好办,统一往认证中心对接即可。真正麻烦的是登录态。传统方案里,一套系统登录后会在服务端种一个 Session ID,服务端内存或 Redis 里存着这个 Session 对应的用户信息。可一旦系统变多,每个业务系统维持自己的 Session 互不相同,用户还得挨个登。于是出现了基于 Redis 共享 Session 的集中式方案:所有系统共用同一个 Redis,用户登录后写一份会话数据,别人拿 Session ID 来查就行。
这种方案在单体架构下很顺手,但对我们当时那种微服务加前后端分离的结构来说,有个绕不开的痛点:每个服务为了判断用户身份,都要去查一次 Redis。查询本身的延迟倒不高,但服务多了以后,会话存储成了强依赖,Redis 一抖动所有接口的鉴权跟着抖。更别提移动端、第三方对接这类非浏览器场景,Session 天然是以“浏览器 Cookie 身份标识”为模型设计的,用起来别扭。这些问题推着我们往“无状态”方向走。
1.2 “无状态”到底无掉了什么
很多人第一次听到“无状态”会以为整个系统都不需要存储了。不是的。这里的无状态,精确讲是两层:第一,认证中心不再为每个在线用户维护一份“会话记录”,用户登录后得到的是一张可独立验证身份的票据;第二,业务系统也不需要查任何公共会话存储来判断用户是否登录,拿到票据、本地验签、通过就算数。
这个转变的本质是“身份信息跟着用户走,而不是存在服务器里”。你可以把它类比成实体门票:游客进场时检票员验一下票面上的防伪信息,就可以直接放行,并不需要给每个游客在数据库里建一条“在场记录”。每个景区入口(业务系统)只要认得门票的签发方,就能自行判断这张票是否有效。
有状态方案里,服务端是身份的“唯一裁判”;无状态方案里,“裁判”变成了签名算法和密钥。这个变化带来了几个直接收益:业务系统彻底去掉会话存储依赖、认证中心不再承担在线用户规模的压力、新系统接入 SSO 时只需要接入验签能力,不必共享任何存储。当然,代价也明显——后面要提到的令牌吊销难、登出不及时,都是无状态的“债”,选型前心里要有数。
2. JWT 为什么适合做无状态票据
2.1 一段 JWT 的解剖
JWT(JSON Web Token)是一串经过编码的 JSON 结构,标准形态是三个被点号隔开的字符串。随便截一段:
eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9 .eyJpc3MiOiJhdXRoY2VudGVyIiwic3ViIjoiYTAwMSIsImV4cCI6MTc wMDAwMDAwMCwianRpIjoiYjEifQ .签名部分第一部分是 Header,记录令牌类型和签名算法;第二部分是 Payload,也就是负载,存放声明信息;第三部分是签名。Payload 里的字段都有约定俗称的缩写含义:iss是签发者,sub是主体(通常是用户 ID),exp是过期时间戳,nbf是生效时间,iat是签发时间,jti是令牌唯一 ID。这些字段不是给业务硬凑的规范,而是 JWT 这套体系自己判断令牌“是否有效”时的关键依据。
需要注意的是,JWT 的 Payload 只做了 Base64 编码,并没有加密。用肉眼或者任意一个 Base64 解码工具就能直接看到里面的内容,所以任何敏感信息都不该往 Payload 里写。签名只保证“内容没被篡改、确实由签发方生成”,并不提供保密性,这一点项目里新人经常弄混,贴个“加密令牌”的标签到处传,实属误解。
2.2 签名算法选型:HS256 还是 RS256
JWT 标准支持很多签名算法,实际项目里绕不开的就是 HS256 和 RS256 的选择。HS256 是对称签名,签发和验签用同一个密钥;RS256 是非对称签名,签发用私钥,验签用公钥。这个差异直接决定你整个 SSO 的信任边界。
| 对比维度 | HS256(对称) | RS256(非对称) |
|---|---|---|
| 密钥 | 签发验签同一个密钥 | 签发用私钥,验签用公钥 |
| 密钥分发 | 所有业务系统都要持有同一个 secret | 业务系统只需要公钥 |
| 泄露影响 | 一处泄露全部失守 | 公钥泄露不影响私钥签发 |
| 运维复杂度 | 简单,但要严格保管 secret | 稍微麻烦,要管私钥存储 |
| 适用场景 | 认证中心和业务系统边界清晰、规模可控 | 业务系统多、有第三方接入、信任边界要收紧 |
我当时选择 RS256 不是因为它绝对更安全,而是因为我们的业务系统有外部合作方接入,不可能把统一 secret 发给别人。用 RS256 之后,每家只需要拿到公钥验签,私钥牢牢锁在认证中心这边,安全边界清晰很多。内部系统全是自己人、接入方也不多的情况下,HS256 完全够用,也没必要上非对称增加复杂度。
2.3 时间窗口:exp、nbf 和 leeway
JWT 的有效性判断,核心就是时间窗口。签发时设置nbf(生效时间)和exp(过期时间),业务系统验签时校验“当前时间是否落在这个区间内”。看起来简单,实际工程里坑不少。
最典型的是服务器时钟偏差。认证中心和业务系统如果部署在不同机器,哪怕之间只差个几百毫秒,也可能出现“令牌还没生效”“令牌已过期”这类偶发错误。标准解法是引入 leeway,即宽容时间。常见的 JWT 库基本都支持验签时传入 leeway 参数,我习惯把 leeway 设成 30 秒到 60 秒,既能容忍轻微时钟偏移,又不至于让令牌实际寿命被明显拉长。另一个工程习惯是认证中心和所有业务系统的服务器都统一走 NTP 时间同步,这一步看着基础,却能从根上把时间类问题压到最少。
exp的设置也是一门权衡:时间短安全但体验差,时间长了风险大。我们内部默认设 2 小时,配合后面要讲的刷新机制,整体体验和安全性比较均衡。
2.4 从认证中心到业务系统的完整时序
把流程画清楚,整篇文章的表就没白列。一套标准无状态 SSO 登录流程长这样:
- 用户访问业务系统 A,未登录。
- 系统 A 把用户重定向到认证中心的登录页,并带上
redirect_uri参数,也就是登录完成后要跳回的地址。 - 用户在认证中心输入账号密码,认证中心校验通过。
- 认证中心签发 JWT,通过 URL 参数或者重定向方式把令牌带回系统 A。
- 系统 A 拿到令牌,用公钥验签、校验过期时间,解析出用户 ID,完成“免密登录”。
- 用户接着访问业务系统 B,同样未登录。
- 系统 B 重定向到认证中心,但用户此时在认证中心已经处于“已登录”状态(认证中心自己留一个轻量会话或 Cookie)。
- 认证中心不再要求用户输入密码,直接给系统 B 签发一个 JWT。
- 系统 B 同样验签、解析、放行。
注意第 7 步的时候,认证中心自己也需要一个轻量会话记录“这个人刚登录过”。很多文章把 JWT SSO 说成“完全不碰 Session”,这是不对的——只是认证中心内部的会话不再承担跨系统共享的职责,它只负责判断“用户刚才来过没有”。整体上,业务系统是彻底无状态的,认证中心则保留极有限的本地状态,这个分工实际跑起来很舒服。
3. 实操:从认证中心到业务系统的落地
3.1 认证中心签发令牌的接口设计
认证中心的核心接口就两个:登录页接口和令牌签发接口。令牌签发接口内部逻辑很集中:校验用户身份、生成 JWT、返回给前端。用伪代码表达大致是:
def issue_token(user_id, iss="authcenter", expire_seconds=7200): now = int(time.time()) payload = { "iss": iss, "sub": str(user_id), "iat": now, "nbf": now, "exp": now + expire_seconds, "jti": uuid.uuid4().hex } token = jwt.encode(payload, PRIVATE_KEY, algorithm="RS256") return token这段代码有几个容易被忽略的细节。iat和nbf设置成同一个时间点,是为了防止有些 JWT 库默认按 0 处理nbf,导致业务侧校验时认为令牌还没生效。jti每次签发都要唯一,后面做吊销黑名单、审计排查都靠它,是不能省的字段。签发用的私钥不放在代码仓库里,而是通过环境变量或者独立的密钥管理服务加载,这一点要当成硬性纪律执行。
3.2 业务侧验签与用户身份还原
业务系统拿到令牌后做的事情,本质上就是认证中心签发逻辑的“反操作”。核心代码如下:
def verify_token(token): try: claims = jwt.decode( token, PUBLIC_KEY, algorithms=["RS256"], issuer="authcenter", leeway=30 ) return claims except jwt.ExpiredSignatureError: return None except jwt.InvalidTokenError: return None验签时指定issuer是很多人会漏的一步。如果不校验签发者,理论上任何一台持有配对的私钥/公钥的服务器签的令牌都能通过,这在多环境(测试、预发、生产)共存的公司里特别容易出事故。另外切记algorithms参数要明确写死,不要让库自动选择算法,否则存在算法混淆攻击的可能。
业务侧验签过后,把sub字段解析出来作为用户 ID,之后所有业务逻辑都基于这个 ID 来做。至于用户的其他信息,比如昵称、角色、权限列表,要么在验签后查业务库,要么放在解密的单独接口里获取,不要把所有信息硬塞进 JWT,否则令牌会膨胀到半屏都显示不完,每次请求传来传去既费带宽也容易穿透日志。
3.3 刷新机制与续期工程实现
无状态 SSO 最大的体验难题是“令牌过期了,用户还在用系统”。2 小时的令牌说失效就失效,总不能让用户卡在页面中间再登一次。业界的标准做法是引入刷新令牌(Refresh Token)辅助续期。
我的做法是双令牌机制:登录时认证中心同时签发一个短期的 Access Token 和一个长期的 Refresh Token。Access Token 有效期 2 小时,只管访问;Refresh Token 有效期 7 天,只允许去认证中心的刷新接口换取新的 Access Token。刷新接口收到 Refresh Token 后验签,确认有效就给用户发新的 Access Token,顺带把 Refresh Token 也一起轮换掉,降低被重放的风险。
实现上要注意一个细节:Refresh Token 需要认证中心“有状态”地管理,至少得记一个吊销状态表。这看起来又回到 Session 思路了,但这是有意的取舍——把“长期凭证”控制在认证中心一个入口上,业务系统始终不碰它,整体无状态的好处照样保留。我们线上甚至还额外限制 Refresh Token 的用途只能调用刷新接口,不允许访问任何业务资源,这样即使泄露,攻击者能拿到的也只是“临时续期的能力”,拿不到稳定的身份凭证。
3.4 登出与吊销的现实做法
无状态令牌天生难吊销,这是绕不开的短板。登录时签出去一张 2 小时有效的令牌,用户点“退出登录”后,只要令牌没到exp,理论上拿它去访问系统依然有效。对内部系统来说,这个窗口可以容忍;但对安全要求高的场景,必须想办法“主动作废”。
现实的做法有两个方向。方向一是维护一个基于jti的黑名单:把退登令牌的jti丢进 Redis,设上和exp相同的过期时间,业务侧验签后多查一次黑名单。这等于给无状态方案加了少量状态,但查的字段很小、Redis 压力可控。方向二是缩短令牌寿命,主动降低“退登后依然有效”的时间窗口。我们实际是两者结合:常规令牌 2 小时寿命不设黑名单,高权限操作(比如后台管理)签 15 分钟短令牌,同时进黑名单,保障关键操作的安全强度。
4. 实战踩坑与排查实录
4.1 服务器时间不同步引发的偶发失效
上线第一个月,运维反馈“有个别用户反馈登录状态突然失效,刷新一下又好了”。事后排查发现,业务系统某一台服务器的时间比认证中心慢了将近 20 秒,令牌签发时间比本地时间还“靠后”,验签时系统认为令牌还未生效,直接拒绝。这种情况就是nbf字段在起作用。当时我们的验签代码里已经加了 30 秒 leeway,按理能兜住,但那台机器偏差波动到 40 多秒时又出了漏子。
解决方式分两层:根因上把所有服务器统一纳入 NTP 时间同步,树一个例行巡检项;防御上把 leeway 从 30 秒提到 60 秒。同时也建议在做压测、扩缩容这类操作时顺手查一下新机器的系统时间,新拉的容器如果基于未同步时间的镜像启动,很容易埋雷。
4.2 前端存储令牌的安全红线
业务系统是前后端分离,前端拿到 JWT 之后往哪里放?localStorage 看似方便,可一旦站点存在任意 XSS 漏洞,攻击者一句localStorage.getItem("token")就能把令牌拿走。Cookie 也没有好到哪去,如果没打HttpOnly,同样能被脚本读取。
我们当时的策略是区分场景:纯浏览器端应用,把 Access Token 放在内存变量里,页面刷新前重新静默刷新拿新令牌,牺牲一点“刷新后状态保持”的体验换 XSS 泄漏面的收敛;需要长期保持登录的端,用HttpOnly + Secure + SameSite=Lax的 Cookie 承载刷新令牌,让脚本根本读不到。跨域通信的场景下,后端还要显式配置CORS白名单,不要把Access-Control-Allow-Origin设成通配加Authorization头,否则跨域站点可以直接拿着用户的 Cookie 发起请求。
4.3 私钥轮换引发大面积验签失败
有次我们做例行密钥轮换,换了认证中心的私钥,但忘了同步更新各业务系统的公钥。结果上线后十分钟内,大批用户登录状态集体失效,报错日志里全是InvalidTokenError。好在发布系统有回滚按钮,第一时间把认证中心切回旧密钥,系统恢复,但教训是永久的。
从那以后,我们定了两条规矩:第一,密钥轮换必须是一个“先加后切”的过程,先把新公钥发给各业务系统并加入信任列表,确认所有系统都支持新旧公钥并行验签后,再认证中心切换签名私钥;第二,JWT 库支持 kid(Key ID)头字段,把每个版本的密钥挂上独立编号,业务侧看到新 kid 就知道该用新公钥验签。这两条组合下来,密钥轮换的安全事故再没出现过。
4.4 本地调试 JWT 的思路
调试 JWT 的小工具值得说说。本地联调时经常要验证某个令牌是不是过期了、某个字段写没写对。我推荐直接用标准库自带的方法打印出 Payload,比如 Python 里jwt.decode(token, options={"verify_signature": False}),可以先把内容原样打出来人工确认。但要提醒,这只是调试手段,线上千万不能把验签关掉,这属于一旦放开就全线失守的低级错误。
另一个调试技巧是在开发环境生成一个永不失效的测试令牌,专供联调用。具体做法是签一个 10 年有效期的令牌,放到团队的共享配置里,前后端联调时省得每次都要走一遍完整登录流程。当然这个操作只准出现在 dev 环境,测试和预发环境验签必须和线上一致,否则环境差异会把问题掩盖到最后一刻才爆发。
5. 安全加固与方案边界
5.1 不可妥协的加固清单
无状态 SSO 少了一层“会话存储=安全闸门”的兜底,很多安全工作需要前置。下面这几条是我现在做任何 JWT 项目都会规规矩矩写进检查单的:
- 传输必须走 HTTPS,令牌绝不通过明文 HTTP 传送。
- 签名算法显式指定,不依赖 JWT 库的自动选择。
- 验签必须校验
iss、aud,并设置合理的 leeway。 sub用内部用户 ID,不直接使用手机号、邮箱这类可枚举信息。- Payload 不携带任何敏感个人信息。
- 私钥单独存放、定期轮换,轮换走“先加后切”流程。
- 对关键操作结合短令牌、黑名单、二次认证等手段做额外兜底。
这七条不是理论堆砌,全是项目里或轻或重付出过代价换来的。比如“验签校验 issuer”这步,前面提过,我们差点因为环境混用出事;“sub 不用手机号”这条,也是因为早期版本把手机号写进令牌里,日志系统一打印令牌,等于把用户手机号泄露了个干净。
5.2 什么样的场景不适合无状态 SSO
JWT 无状态方案虽好,但不是万能模板。场景里如果出现下面几种信号,建议谨慎:
第一,允许用户主动注销后,“立刻”在所有系统失效是硬性需求。前面说过,无状态令牌天然有剩余寿命,哪怕加了黑名单也只是追认式的补救,做不到随时立刻吊销。第二,用户量不大、系统集中在同一机房,Session + Redis 的方案不仅简单,排障也直观,没必要为了“无状态”而刻意引入 JWT。第三,业务系统有大量长连接、离线任务,需要频繁刷新身份的,令牌的自动续期逻辑会变成不小的复杂度,倒不如沿用有状态的会话保持。
我们选型时真正的标准是“业务系统的扩容是否会被会话存储卡脖子”。如果答案是不会,有状态的成熟方案往往更省心。
最后再分享两个实际操作体验
一个是日志。JWT 令牌别整串打进日志,我早期在某个系统里看日志排查问题时,发现 Access Token 被业务日志原样记录,吓得立刻把日志脱敏规则加上。令牌和密码一样属于敏感信息,日志打印时必须省略或者只留后四位。另一个是团队协作上的:做 JWT SSO 不仅是认证中心一个项目的事,建议主动给业务系统团队写一份接入文档,把验签、解析、签发者校验的代码模板直接发出去。我们当时就是因为模板齐全,新系统接入耗时从最初的一周慢慢降到半天。无状态 SSO 这套东西,技术细节固然多,但真正让它顺畅跑起来的,往往是工程规范和执行纪律。