先说我最近帮人排查的一起线上问题。日志里401和403来回跳,二十个接口的认证逻辑散落在各个Controller里,有的校验了Token,有的只看"是否登录"就放行,有的把签名密钥硬编码在代码里。这套系统上线三个月,已经出过好几次越权事故。聊下去发现根因只有一个:大家把用户认证与授权当成了随手就能实现的小功能,却没有真正理解"用JWT保护API"背后那一整条链路。
这篇文章不绕弯子,直接从Session方案的死穴讲起,把JWT从结构、签发、校验、授权模型到安全坑完整过一遍。适合刚接触后端鉴权的新手,也适合那些"已经在用JWT但总觉得哪里不对"的开发者。
1. Session方案在分布式场景下的崩溃:为什么要用Token
1.1 服务端存储Session的三个硬伤
Session机制的核心逻辑很直观:用户登录成功后,服务端生成一条会话记录并分配一个SessionId,客户端每次请求带上这个Id,服务端查一下内存或数据库,确认有效就放行。单机小应用里这套流程几乎没有学习成本,也是早期很多教程的标准答案。
但系统一开始扩张,第一个硬伤就冒出来了。Session默认存在单台服务器的内存里,负载均衡把请求分散到多台机器后,登录请求落在A机器,下一次请求被路由到B机器,B机器内存里没有这条会话,用户直接被判定为未登录。社区里的经典补救方案是引入集中式存储,把Session搬到独立服务里,所有机器统一去那里查。方案可行,但引入了新的基础设施依赖,也意味着每次请求都要多一次网络往返。
第二个硬伤是客户端形态变多了。一个用户可能同时用网页、App、小程序访问同一套后端,每端都要维持各自的会话状态,服务端必须为同一用户维护多份记录。过期策略、并发踢出、多端同步,这些逻辑都要自己造轮子,复杂度成倍上涨。
第三个硬伤往往被忽视:SessionId本身不携带任何身份信息。它只是一个随机字符串,服务端拿到以后必须回查存储才能得知"这个用户是谁、有什么权限"。每一个API请求都带着至少一次存储查询,调用量上去之后,这部分开销和存储服务的压力都非常可观,而Session存储本身也成了系统中的单点风险。
1.2 无状态认证的取舍与Token的回归
为了解决这些问题,业界的思路回到了"无状态认证":把用户身份信息直接编码进凭证本身,客户端保存凭证,请求时带回来,服务端只靠验证签名就能确认凭证真实可信,不需要回查任何存储。
Token方案早年就出现过,最早的做法是"把用户ID拼成一个字符串直接返回给前端"。但这种自造Token没有任何防篡改机制,用户改一下字符串里的ID就能冒充别人,所以很快就淘汰了。JWT把这件事做完整了:用密码学签名保证内容不可被篡改和伪造,同时把过期时间、签发者、目标用户等字段标准化。
需要泼一盆冷水的是:无状态不等于零成本。JWT最大的短板是签发后无法主动使其失效,必须在设计上做补偿,这也是很多项目把JWT用崩的核心原因。选择JWT不是无脑接管Session,而是要在理解两者代价的前提下做取舍。后端接口是"客户端每次请求都要带凭证回来"的场景,JWT天然合适;反过来,如果你只有一个单体应用、用户量也不大,继续用Session加Redis完全没问题。
2. JWT内部结构拆解:Header、Payload、Signature分别管什么
2.1 三个部分从哪来、长什么样
一个JWT字符串看起来是一长串由点号分隔的三段字符,比如xxx.yyy.zzz。这三个部分分别是Header、Payload、Signature,每一段都用Base64Url编码,所谓Base64Url就是把标准Base64中的+和/换成-和_,再去掉末尾的=,这样生成的字符串可以安全地在URL、请求头等场景中传输。
| 部分 | 存放内容 | 编码方式 | 是否可读 |
|---|---|---|---|
| Header | Token类型、签名算法 | Base64Url | 直接解码可见 |
| Payload | 各类Claims数据 | Base64Url | 直接解码可见 |
| Signature | 签名结果 | Base64Url | 不可伪造 |
Header部分最典型的两个字段是alg和typ,alg声明签名用的算法,typ通常固定为JWT。Payload是真正干活的地方,所有身份信息和业务参数都在这里。Signature则由Header加上Payload拼接后,用指定算法和密钥计算得出。
很多人第一次看到JWT时以为它是加密的,这是一个非常危险的误解。JWT默认只是签名,不是加密。把任何一段JWT复制进浏览器开发者工具里,手工解码中间那段Payload,几秒钟就能看到原始内容。所以密码、手机号、身份证号这些敏感信息绝对不允许放进Payload。
2.2 签名是怎么算出来的
签名的计算过程可以理解为"给内容盖一个不可复制的章"。以最常见的HS256为例,服务端持有同一个密钥,把Base64Url(Header) + "." + Base64Url(Payload)作为输入,用HMAC-SHA256算法算出一个摘要,作为第三段Signature。服务端收到Token后,用同一个密钥重新计算摘要,和Token携带的Signature比对,一致就说明内容没有被篡改过。
另一种常见算法是RS256,它使用非对称加密:服务端持有私钥负责签发,任何需要验证Token的服务只持有公钥。公钥泄露没有关系,因为只有持有私钥的人才能生成合法签名。在微服务架构里,RS256的优势非常明显:认证服务签发的Token,其他服务组件只需要配置一把公钥就能验证,不需要共享同一个对称密钥。
2.3 标准Claims字段的正确用法
JWT规范里定义了一批标准字段,统称Registered Claims,我看过太多的项目只用一个user_id,把标准字段完全抛在脑后,结果后面全在踩坑。这里列几个必用的:
sub:主题,通常放用户唯一标识,比如用户ID。iat:签发时间,Unix时间戳格式。exp:过期时间,Unix时间戳格式,这是整个Token安全性的生命线。iss:签发者,多服务环境里用来识别Token来源。aud:受众,声明Token是给哪个服务或客户端用的。jti:Token唯一ID,登出黑名单和防重放都会用到。
基于常见实践,我建议每个项目至少在Payload里落实sub、iat、exp、jti这四个字段。其中jti最容易漏,等到需要实现"用户登出后让Token立即失效"的时候就会发现它有多重要。
3. 签发与校验完整落地:登录接口和认证中间件
3.1 登录签发的标准流程
先看签发这半边。用户拿账号密码请求登录接口,服务端校验通过后,组装Payload并签名,然后把Token返回给客户端。整个环节里最容易偷懒的就是"把一堆用不到的字段塞进Payload",这样每多一个字段,每个请求的Header就多几十字节,日活在千万级的时候这部分带宽成本会被无限放大。
以Node生态的JWT库为例,一个典型的签发逻辑长这样:
const SECRET = process.env.JWT_SECRET; function issueToken(user) { const now = Math.floor(Date.now() / 1000); const payload = { sub: user.id, role: user.role, iat: now, exp: now + 3600, // 1小时后过期 jti: crypto.randomUUID() }; // 使用JWT库的sign方法,显式指定HS256 return jwt.sign(payload, SECRET, { algorithm: 'HS256' }); }注意我在签名时显式指定了算法。后面讲安全坑的时候你会知道,这个习惯能救你一次。iat和exp都要用Unix秒,不要用可读日期字符串,否则不同语言库之间的兼容性会给你找麻烦。
3.2 认证中间件:校验、解析、挂载
客户端拿到Token后,每次请求都要放在HTTP头里带回来。业界约定俗成的格式是:
Authorization: Bearer <token>Bearer这个词没有特殊含义,只是一个约定,表示"后面跟的是一串访问凭证"。服务端在路由层加一个认证中间件,统一完成解析和校验,而不是在每个Controller里各自处理。
async function authMiddleware(req, res, next) { const header = req.headers.authorization; if (!header || !header.startsWith('Bearer ')) { return res.status(401).json({ code: 401, message: '缺少访问凭证' }); } const token = header.slice(7); try { const decoded = jwt.verify(token, SECRET, { algorithms: ['HS256'] }); req.user = decoded; next(); } catch (err) { // 这里要区分"过期"和"签名无效",返回不同的提示 return res.status(401).json({ code: 401, message: '凭证无效或已过期' }); } }中间件做的事情归纳起来是三步:第一,从请求头里取出凭证,缺失就直接返回401;第二,调用库的verify方法验证签名真实性和过期时间;第三,把解析出的身份信息挂载到请求对象上,后续的业务处理函数直接从req.user里取。
3.3 密钥管理里容易翻车的细节
登录签发和中间件校验都依赖同一个密钥,这个密钥就是整套系统的命门。
先说HS256场景。密钥必须放在环境变量或专门的配置服务里,严禁写死到代码仓库。密钥长度至少要32个字节,别用"mysecret"这种一眼就能猜出来的字符串。我见过一个项目用jwt三个字母当密钥,攻击者试三次就能撞出来。
再说RS256场景。私钥只有认证服务持有,公钥可以公开分发。这里有个经常被忽略的问题:公钥是需要轮换的。建议在公钥配置里带上版本号,比如kid字段,服务端根据kid选择对应的公钥来验证,这样密钥轮换时可以做到新旧并存,平滑过渡。
此外,过期时间设多长需要结合业务场景判断。面向内部系统的Token设半小时到两小时比较稳妥;面向第三方开放平台可以设长一些,但必须配合刷新机制。后面会详细展开。
4. 从认证到授权:RBAC权限模型在JWT里的落地姿势
4.1 认证和授权是两件事,混在一起就是事故
认证解决的是"你是谁",授权解决的是"你能干什么"。很多项目只做了认证,登录校验一过就放行,于是普通用户直接调用管理员接口,这就是越权漏洞的来源。
一个很典型的场景:系统里有一个删除订单的接口,接口只校验了"是否有Token",没有任何角色或权限判断。普通用户只要登录了自己的账号,就能构造请求把别人的订单删掉。这种问题靠前端隐藏按钮没有任何作用,因为接口是直接暴露在网络上的,攻击者完全可以绕过界面手动发请求。
所以授权必须在服务端、在接口层做强制校验。JWT在这方面有天然优势:用户身份和权限信息就在Token里,服务端不需要查询数据库就能完成判断。
4.2 把角色和权限塞进Token的正确姿势
基于常见实践,RBAC(基于角色的访问控制)是目前性价比最高的授权模型。核心思路是三层:用户归属角色,角色拥有权限,接口要求权限。落到JWT里,常见做法是在Payload里放两个自定义Claims:
const payload = { sub: user.id, role: user.role, // 角色,比如 admin / editor / viewer perms: user.permissions, // 权限点数组,比如 ['article:create', 'article:delete'] iat: now, exp: now + 3600, jti: crypto.randomUUID() };角色和权限分开的好处是:接口校验时既可以用角色做粗粒度拦截,也可以用权限点做细粒度校验。比如"编辑文章"接口要求editor及以上角色可以进,但"删除文章"接口只有拥有article:delete权限的人才允许调。
这里有一个平衡问题:Token里放太多权限信息会导致Token体积膨胀。生产环境建议只放角色或少数几个关键权限,细粒度的权限判断交给服务端查库,两者配合。这个判断要基于你的系统实际情况,没有银弹。
4.3 权限中间件:401和403要分清楚
有了身份和权限信息,授权的落地就是一层权限中间件。核心逻辑简单直接:
function requirePermission(perm) { return (req, res, next) => { if (!req.user) { return res.status(401).json({ code: 401, message: '请先登录' }); } if (!req.user.perms || !req.user.perms.includes(perm)) { return res.status(403).json({ code: 403, message: '没有操作权限' }); } next(); }; }注意401和403的区别:401表示"你都没证明你是谁",需要重新登录;403表示"我知道你是谁,但你没有权限做这件事",不应跳转登录页。很多项目把两个状态码混用,前端和后端对不上,排查起来极其浪费时间。
还有一种容易漏的多租户场景:如果系统里存在多个组织或租户,用户可能同时属于多个租户。简单地把角色塞进Token是不够的,还要把当前上下文(比如当前选中的租户ID)放进Token或请求参数里,所有接口的数据查询都强制加上这个维度的过滤。这一步漏掉,就会出现"用户切到另一个租户,顺手拉到了别人的数据"这类的横向越权。
5. 实战踩坑记录:五个JWT安全细节与一次完整排查
5.1 踩坑过程:为什么攻击者用空签名也能通过校验
讲一个我实际经历过的生产事故,排查链路很有代表性。某天同事反馈"用户账号被批量盗用,出现了一个管理员身份的操作记录",但数据库里根本不存在这个管理员账号。
第一反应是查登录日志,发现这个"管理员"的登录时间全是空白,说明根本没有走正常的登录接口。接着抓取其中一个异常请求,发现它的Authorization头里确实带了一个合法格式的JWT。把Token解码后看Header,alg字段的值是none——这意味着Token没有签名。
问题就出在库的配置上。当时的代码调用verfy方法时没有显式指定允许的算法列表,而旧版库在默认行为里允许攻击者把alg改成none,服务端就会把这个无签名的Token当作合法Token处理。攻击者只要把Payload改成{"role": "admin"},再编一个none的Header,就能给自己签出任意身份的Token。
修复方案分三步:第一,所有验证调用显式加上algorithms: ['HS256'],从根上拒绝非预期算法;第二,重新审视一遍密钥强度,确认调用方持有的密钥没有泄露;第三,在网关层把alg为none的请求直接拦截。排查完我在团队里立了一条规矩:凡是JWT库的verfy方法,必须配置算法白名单,不允许依赖库的默认行为。
5.2 Token放哪里:localStorage和Cookie之争
前端拿到Token后的存储位置直接决定整套系统的安全边界。放在localStorage里最方便,但它的读取权限对同源下的所有脚本开放,页面只要有一个XSS漏洞,攻击者就能用一小段脚本把Token偷走。
放在Cookie里配合HttpOnly属性,脚本读不到Cookie,XSS窃取这条路基本堵死了。但Cookie方案会引入CSRF风险,需要额外用SameSite和CSRF Token做防护。## 5.3 过期时间设得太长:一次数据泄露的教训
我把Token过期时间设置成了30天,理由是"用户觉得频繁登录很烦"。结果某天业务后台出现了一个异常登录记录,根据日志追查发现,这个Token是攻击者在一个月前通过钓鱼页面诱导用户授权拿到的,而因为我设的过期时间太长,这个被盗的Token整整有效了30天。
这类问题的标准解法是"双Token方案":Access Token有效期设短,通常在15分钟到1小时,用来正常访问业务接口;Refresh Token有效期设长,比如7到30天,只用于换取新的Access Token,不参与具体业务接口。Refresh Token存服务端或加密的HttpOnly Cookie里,定期轮换,使用过的Refresh Token直接作废,这样即便Access Token泄露,攻击者最多只能在其存活期内冒充用户,而且每次刷新都要重新经历校验。
5.4 登出后Token仍然有效:无状态方案的阿喀琉斯之踵
JWT没有服务端状态,签发出去就管不到了。用户点了退出登录,前端把本地Token删掉,但如果Token已经被攻击者拿走,它依然可以在有效期内使用。这是我见过所有JWT方案里最让人不舒服的短板。
补偿方案通常是维护一个"黑名单":服务端用Redis记录登出的Token的jti,存储时间设为Token剩余有效期,每次校验时先查一下黑名单。这套方案实现成本不高,但要注意黑名单服务本身又引入了共享存储,回到了Session方案的老路。所以更稳妥的设计是压缩Access Token有效期,让它短到即使无法立即作废,影响窗口也可控。
5.5 JWT不是加密:Payload被解码后的信息安全问题
最后再强调一次:JWT的Payload只是Base64编码,不是加密,任何拿到Token的人都能看懂里面的内容。有一次安全评审,对方直接把我们Token里的用户手机号解码出来了,因为我们把手机号放进了Payload用于客户端展示。
正确的做法是:Payload只放非敏感的必要身份数据,展示类信息要么从自己的接口拉取,要么用JWE做加密处理。设计Token内容时始终假设攻击者能看到你的Payload,凡是不能公开的数据一个字段都不能塞进去。
6. 性能实测与选型建议:什么时候别用JWT
6.1 体积膨胀对带宽和网关的影响
Token里每多放一个字段,客户端发起的每个请求的Header就相应变大。做一个简单的测量:一个只含sub、iat、exp的Token大约200字节,加上角色和权限字段后普遍在350到500字节。作为对比,Session方案里客户端携带的只是一个几十字节的SessionId。
高并发场景下这个差距会被放大。常规网关设备对请求头的处理方式不同,Header过大除了增加带宽消耗,还可能触发网关层的限流或拦截策略。我测试过一组模拟数据:日请求量百万级、每次请求多携带200字节,一个月下来额外消耗的带宽非常可观,虽然单个看不大,积少成多就是一笔真实成本。
6.2 三种方案的横向对比
| 维度 | Session + Redis | Opaque Token + Redis | JWT |
|---|---|---|---|
| 服务端状态 | 有状态 | 半有状态 | 无状态 |
| 分布式友好度 | 需共享存储 | 需共享存储 | 天然友好 |
| 主动失效能力 | 强 | 强 | 弱,需黑名单补偿 |
| 请求头体积 | 小 | 小 | 中等偏大 |
| 携带业务数据 | 不支持 | 不支持 | 支持 |
| 实现复杂度 | 低 | 中 | 中 |
Opaque Token指那种"返回一个随机字符串、真正信息存服务端"的Token,客户端只保存一个无法解读的凭证。它在微服务场景里也很常见,本质上和Session方案是一回事,只是把会话管理职责独立了出来。
6.3 基于实际场景的选择建议
根据我自己的使用经验,这几个场景优先选JWT:你的API要同时服务Web、App、小程序等多个客户端;系统是微服务架构,希望认证信息能在各服务间直接传递而不依赖共享存储;需要做第三方开放平台或单点登录,Token要能跨域携带。
反过来这些场景建议继续用Session或Opaque Token:单体应用,用户量不大,Redis已经存在;业务对"用户登出后必须立刻失效"有硬性要求,JWT的黑名单补偿方案会带来额外复杂度;API部署在带宽受限或流量极大的环境下,每个字节都要精打细算。
我个人在实际项目里的习惯是这样:内部系统用Session或Opaque Token,简单直接;对外提供API或遇到多端接入时,用"短有效期Access Token加Refresh Token"的JWT方案,jti必带、算法白名单必配、Payload只放必要字段。用JWT本身不难,难的是把上面这些安全细节全部落到实处。这套组合我在几个日活百万级的项目里都验证过,稳定性和安全性都没有让我失望过。