资讯详情

JWT原理拆解:无状态令牌如何替代Session实现身份认证

📅 2026/10/17 9:24:26 | 华诺云谱 👁 阅读
JWT原理拆解:无状态令牌如何替代Session实现身份认证
1. 从一次登录说起会话状态到底存在哪我第一次接触JWT时是接手一个前后端分离的老项目。登录功能用的是最传统的Session方案用户登录后把sessionId塞进Cookie服务器内存里存一份用户信息。单机跑得好好的后来要扩展成多实例部署问题马上来了用户在A实例登录下一次请求被负载均衡转到B实例B实例的内存里根本没有这个session用户直接被判定为未登录。当时的解决办法是引入共享存储把session挪到Redis里算是解决了问题但每次请求都要查一次Redis延迟增加了不说Redis一挂所有人集体掉线。那时我就在想有没有一种方案能把“会话状态”从服务器里解放出来后来接触到JWT才明白这类无状态令牌的思路完全是另一种玩法。JWT的全称是JSON Web Token听起来很唬人拆开看就三样东西JSON格式的数据、Web场景下的传输方式、以及一个用来防篡改的签名。它的核心思路是服务器不存你的登录状态而是给你发一张“身份证”你每次请求把这张身份证带上服务器只看身份证就能确认你是谁、有没有权限。这篇文章是JWT学习系列的第一篇我不打算直接甩一堆代码而是先把“JWT到底是个什么东西、它要解决什么问题、内部结构是怎么设计的”这件事讲透。无论你是刚入门的新手还是已经用JWT写过几个接口但没细究过原理的人这篇文章都适合作为你的第一份参考资料。后边我会在系列里接着写实际的签发与校验代码、刷新策略、以及和Session的对比测试。1.1 HTTP协议天生不记得你是谁要想理解JWT存在的意义得先理解Web应用的痛点。HTTP协议本身是“无状态”的也就是说每一次请求之间没有任何关联。你把请求发过去服务器处理完返回结果这段连接就结束了。服务器根本不记得你上一秒请求过什么。打个比方你去便利店买东西第一次进店店员不认识你你付完钱走了。第二次你又来了店员还是不记得你是谁。HTTP就是这样一个永远“失忆”的店员。但真实的业务不能失忆网购要记录购物车论坛要记住你的登录身份网盘要识别你的会员等级。没有“状态”这个概念这些功能全都没法做。于是大家发明了各种各样的“记忆”方式。最经典的是Session方案你第一次访问时服务器给你分配一个唯一的编号也就是sessionId同时服务器内存里存一份和你相关的数据。这个sessionId通过Cookie下发到浏览器之后每次请求浏览器自动带上它服务器拿着编号去内存里查查到就说明你登录过。这个方案直观好用但有个致命前提sessionId对应的数据只存在当前那一台服务器的内存里。一旦系统扩容成多台服务器或者进程重启内存里的session就没了用户的登录状态也会跟着消失。1.2 Session方案的解法把状态搬到共享区域为了解决多实例共享session的问题最常见的做法是引入Redis这类外部存储。所有服务器实例不再往自己内存里写session而是统一写到Redis里。用户请求进来服务器从Redis里查sessionId对应的数据。这样不管请求落在哪台实例上都能查到同一份数据。这套方案非常成熟至今仍有大量系统在用它也确实能稳定解决“分布式环境下会话不共享”的问题。但它有几个绕不开的代价每一次需要身份信息的请求都要多一次跨网络的Redis查询延迟上必然有损耗。存储中间件成了整个鉴权链路的单点依赖Redis一旦抖动所有需要登录的接口全部受影响。在纯前后端分离的场景下跨域、移动端发请求Cookie的携带和域名的处理都比较别扭处理起来总有那么一点不顺手。我在这里无意否定Session方案的合理性它在很多场景下依然是首选。但它的“有状态”特性决定了服务器必须保存每一份会话数据这个负担是结构性的。我当时的直觉是如果存在一种办法让服务器完全不需要保存会话数据只在请求到来时通过某种凭证直接判断身份那很多麻烦就能从根上省掉。1.3 JWT的思路转变让凭证本身就能证明一切Session方案中凭证sessionId本身是随机字符串不含任何用户信息。真正的用户数据存在服务器侧凭证只是个“钥匙”。这就好比你去银行办事银行给你发一张存单存单上只写着一串编号你的存款信息存在银行系统里。你取钱时必须带存单银行拿编号去系统里查。存单本身什么也证明不了。JWT的思路恰好反过来。服务器签发给你的是一个JSON字符串里面直接写入了用户ID、过期时间、权限角色等信息。这些信息经过签名之后交给客户端。之后客户端每次请求把这段字符串带回来服务器不需要去数据库或缓存里查任何东西只需要验证这段字符串的签名是否有效然后直接读取里面的用户信息就能完成身份确认。这就像是把银行系统改成了“存单即凭证”的模式存单上写清楚你是谁存了多少还印着银行盖的防伪章。你取钱时柜员看一眼存单上的印章就能确认这是真的连系统都不查。这就是JWT最本质的转变把会话状态从服务器内存转移到客户端持有的凭证本身。服务器变成“无状态”的这也成了JWT最大的卖点。理解了这一点后续再去看它的结构、签名、校验细节才会觉得每一步设计都有它的道理。2. JWT的真面目一段由三部分拼成的“身份证明书”光知道JWT是“无状态令牌”还不够得把它拆开看看里面的构造。我第一次看到JWT长什么样时以为是一串乱码。人和人之间的语言是文字服务器和服务器之间的语言是格式。JWT就是通过一套严密的格式把信息编码成一个不怕人看的、自包含的字符串。一个标准的JWT长这样eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6ImplcnJ5IiwiaWF0IjoxNzE3MDAwMDAwfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c肉眼看上去确实是一串乱码但它是可以拆开的。整段字符串由两个英文句点.分成三个部分。按顺序分别是Header头部、Payload载荷、Signature签名组合格式就是Header.Payload.Signature。理解了这三个部分各自干了什么JWT就已经理解了八成。2.1 Header自我介绍说明我用了什么规则Header是JWT的第一段内容是一个JSON对象最常见的长这样{ alg: HS256, typ: JWT }它里面放的是元信息。alg告诉服务器“我用的是哪种签名算法”比如HS256表示使用HMAC-SHA256RS256表示使用RSA签名。typ则说明这是一个JWT类型的令牌。这段内容本身不神秘就是给服务器在验签时一个提示该用哪把钥匙、用什么算法来打开这个令牌。如果把JWT类比成一份盖章生效的合同Header就相当于合同封面上写的“编制依据”。它不涉及具体业务数据只负责说明规则。2.2 Payload真正的“内容区”用户信息都在这里Payload是JWT的第二段也是信息量最大的一段。它同样是一个JSON对象用来承载与用户、业务相关的声明数据。官方标准里定义了一组保留字段常见的有subsubject令牌的主体通常存用户ID。iatissued at签发时间Epoch时间戳格式。expexpiration time过期时间超过这个时刻令牌作废。nbfnot before生效时间早于该时刻的请求会被拒绝。audaudience受众表明这个令牌给谁用。ississuer签发者标明令牌来源。除了这些保留字段业务里也可以自由添加自定义字段比如{ sub: 1234567890, name: jerry, role: admin, iat: 1717000000, exp: 1717003600 }这个JSON对象里sub标明用户是谁name和role是业务扩展的自定义信息iat记录签发时间exp要求这个令牌必须在签发后一小时内使用。注意一个关键点**这一段内容只是经过Base64Url编码并没有加密。**任何人拿到JWT后都可以自行解码看到里面的内容。所以千万不能把密码、手机号、身份证号这类敏感信息直接写进Payload这是新手最容易犯的错误。2.3 Signature整个令牌的“防伪章”JWT的第三段是Signature也是整段令牌的灵魂。签名的生成过程是把前两段内容即Header.Payload用某种算法结合一个密钥计算出一串固定长度的摘要值。以HS256为例签名生成的大致逻辑是签名结果 HMAC-SHA256( Base64Url(Header) . Base64Url(Payload), 密钥 )这个签名结果再经过Base64Url编码拼到第二段后面最终形成完整的JWT。服务器收到令牌后用同样的密钥、同样的算法重新计算一遍签名再拿来和令牌自带的签名做比对。如果一致说明这个令牌在传输过程中未被篡改而且确实是由持有密钥的签发方签发的。这就是整段JWT的安全根基。举个例子如果有人在途中把Payload里的role: user改成role: admin签名校验立刻会失败因为改动后的Payload算出的签名和原来的签名对不上。服务器只需要做一个数学比对就能识破篡改的企图。为了加深理解下面这张表把这三个部分的分工做了个总结组成部分存放内容是否加密作用Header签名算法、令牌类型仅编码不加密说明令牌的规则Payload用户ID、角色、过期时间等仅编码不加密承载身份与业务数据Signature前两段摘要加密钥计算结果不可逆摘要确保令牌完整、防篡改2.4 为什么用的是Base64Url而不是传统Base64细心的读者可能注意到JWT的编码方式叫Base64Url和平时常见的Base64有点区别。传统Base64编码后的字符串可能包含、/、这三个字符这在URL里是有问题的会被解析成空格/会干扰URL路径的分隔在某些传输协议里也有歧义。JWT是要靠在请求头里传来传去的这些歧义字符会造成不必要的麻烦。Base64Url的做法很简单把替换成-把/替换成_再去掉末尾的填充字符。这样编码出来的字符串可以直接放进URL、放进HTTP头、放进Cookie不会产生任何歧义。服务器解码时会做反向替换。这是一个非常细的细节但正是这类细节决定了JWT能不能在真实网络环境中顺畅工作。实际调试的过程中我用工具直接解码带的假令牌时还会看到校验失败换成Base64Url实现之后一切正常。3. 签名机制为什么改一个字符都逃不过服务器的眼睛有些读者可能对“签名”这个说法感到抽象好像又加密又签名的听上去离得很远。其实可以换个思路理解加密是让别人看不懂签名是让别人改不了。JWT的第二段本身是明文可见的所以它不能保护内容的隐蔽性但它能保证内容的完整性。你发出去的令牌是什么样到了服务器手里就必须还是什么样。这一节重点说清楚签名是怎么算出来的以及它到底在防谁。很多网上的教程一句话带过“HS256就是对Header和Payload做HMAC-SHA256散列”但这一步具体做了什么、为什么能防御篡改值得展开聊一聊。3.1 HMAC-SHA256是怎么“盖”上这个章的HMAC-SHA256的全称是“基于哈希的消息认证码”使用特征一把共享密钥参与运算对任意长度的数据输出固定长度的摘要。运算时不是简单地把数据和密钥拼在一起再哈希而是经过一个标准的填充和压缩流程。不过对理解JWT来说只需要掌握最关键的一点同样一段数据配合同样的密钥每次算出来的摘要都是一样的数据只要改动任何一个字节或者密钥不对算出的摘要就会面目全非。可以把HMAC-SHA256想象成一枚刻有特定纹路的印章。你拿这枚印章盖下去纸上会留下一个图案。如果合同正文被人改了一个字原来盖的那个章图案和合同就不匹配了核查人员拿原章重新盖一次比对两个图案立刻能发现问题。密钥则等同于“只有你自己有印章”。别人哪怕知道这套签名算法没有你手里的密钥也无法伪造一个能通过校验的签名。服务器校验JWT时秘诀就在于我拿我手里的密钥重新算一遍签名如果和你令牌里带的签名一致说明这个令牌就是我签发的中途没人动过手脚。3.2 服务端校验签名时到底做了什么理解了签名生成的方式校验流程顺理成章。假设服务端收到一个JWT完整的校验步骤大致如下第一步按句点拆分字符串得到Header、Payload、Signature三段。第二步解析Header读取alg字段确认签名算法。第三步用服务端持有的密钥配合Header和Payload重新计算签名。第四步将计算出的摘要与令牌携带的Signature做比对。第五步签名通过后再校验exp是否过期、nbf是否到了生效时间。这里有一个必须警惕的安全习惯不要盲目遵循Header里的alg字段。曾经出现过一类知名攻击攻击者把alg改成none导致某些实现跳过签名校验令牌内容为所欲为。成熟的语言库大多已经修复了这类问题默认只接受白名单里的算法但在自行实现或使用比较小众的库时应特别小心。我去年排查一个线上问题时就发现有业务代码直接读取alg字段来选择验签算法这算是一个典型的安全隐患。3.3 “JWT加密了吗”这个问题答案是分情况的很多人误以为JWT是加密的看不到内容才是安全的。这个误解需要纠正一下。JWT的默认形态是“签名但不加密”的。任何拿到令牌的人都可以把第二段Payload用Base64Url解码直接看到里面写的用户ID、角色和过期时间。真正有加密保护需求的场景应当使用JWEJSON Web Encryption方案它会把Payload的内容加密后再编码只有持有私钥的一方才能解密。但JWE和JWT不是一回事应用范围也窄得多。日常做登录鉴权绝大多数使用的是JWT签名模式——它保证“不可篡改”但完全不保证“内容不可见”。所以说写进Payload的数据只能是那些“即便被人看到也无所谓”的信息。比如用户ID、用户名、角色这类不算秘密的身份信息。一旦涉及密码、身份证号、手机号这些敏感数据坚决不能放进去。这不是谨慎不谨慎的问题而是这个协议本身就没提供那层保护。拿我自己踩过的坑来说早期有个项目图省事把用户手机号写进了Payload结果前端联调时随手把token贴到日志里测试环境又不设防手机号全部通过解码暴露了。从那之后我对Payload的内容做了很严格的约束只保留必要的ID和角色信息需要用户详情时一律再查一次数据库。4. JWT的完整生命周期签发、携带与验证前面讲了结构讲了签名对JWT的“零件”应该已经很熟悉了。但一个令牌从头到尾是怎么运转起来的还缺一条完整的时间线。这一节系统走一遍流程用户登录时令牌怎么签发出来、前端拿到后放哪里、后续请求怎么携带、服务端收到后又怎么处理。4.1 登录成功后服务端如何签出这张“身份证”用户输入用户名密码发起登录请求服务端校验通过之后就到了签发JWT的环节。签发过程说白了就是组装数据、计算签名两步。典型代码如下const jwt require(jsonwebtoken); const payload { sub: user.id, name: user.name, role: user.role, iat: Math.floor(Date.now() / 1000) }; const token jwt.sign(payload, process.env.JWT_SECRET, { algorithm: HS256, expiresIn: 2h });这段代码做了几件事构建Payload对象包含用户ID、用户名、角色以及签发时间调用jsonwebtoken库的sign方法传入密钥和过期时间库内部会把这些信息组合成Header、Payload再计算出签名最终返回一个完整的JWT字符串。注意expiresIn参数实际运行中它会在Payload里自动加上exp字段。iat记录当前时间exp是签发时刻加上两小时。之所以要同时放这两个时间戳是为了让令牌的生命周期可以被审计也能防止有人拿一个手工修改过iat的令牌蒙混过关。签发出来的令牌服务端通过JSON响应的方式返回给前端。这里的一个常见问题是服务端要不要把token保存起来答案是不需要。这也是JWT和Session最本质的分野——服务端签完就可以把这件事忘掉后续全靠token本身说话。4.2 前端拿到令牌之后存储位置决定安全底线前端拿到token后面临一个存储位置的选择。主流方案有两种localStorage和Cookie。不少团队为了省事直接放进localStorage代码简单取用方便。但localStorage的缺点是可被JavaScript直接读取。只要页面里有一段恶意脚本比如通过第三方依赖注入进来的token可以被悄无声息地掳走。存放在Cookie里则可以设置HttpOnly属性让JavaScript无法读取。这样即使页面被注入了脚本也无法直接偷走token。但Cookie方案也有它的麻烦需要解决CSRF跨站请求伪造问题因为浏览器会在每个同域名的请求里自动带上Cookie攻击者可能诱导用户发起恶意请求。通常的应对是校验SameSite属性、额外的自定义请求头或者CSRF Token。哪种更好业界没有绝对定论。眼下更稳妥的实践是短有效期的token配合刷新令牌机制尽量压小token泄露带来的损害窗口。我自己比较推荐的做法是访问令牌放在内存里不落盘刷新令牌放在HttpOnly Cookie里用于换取新访问令牌。这样既保证了多数场景下访问令牌不容易被脚本偷取又不用时刻担心CSRF。4.3 后续请求token挂在Authorization头里前端存好token之后每次向后端发请求都要把token带过去。最标准的做法是放在HTTP请求头Authorization中使用Bearer Token格式Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIx...Bearer这个前缀是约定俗成的用法意思是“携带以下令牌来访问受保护资源”。后端从请求头里取出这段值去掉Bearer前缀剩下就是JWT本体。在Axios这类前端请求库里通常会用拦截器统一处理所有请求自动加上这个头不需要在每个接口里手动传axios.interceptors.request.use(config { const token getToken(); if (token) { config.headers.Authorization Bearer ${token}; } return config; });4.4 服务端验证流程三步走缺一不可后端拿到请求头里的JWT进入验证阶段。完善的步骤是验签名用服务器持有的密钥重新计算签名比对一致性。这一步过滤掉伪造和篡改的令牌。验有效期检查exp和nbf。已经过期和尚未生效的令牌直接拒绝。验身份从Payload中取出sub、role等字段把它作为当前请求的用户身份。如果系统还有用户状态管理需求可以再查一次用户表确认账号未被禁用。这里有个很值得注意的点签名合法不代表用户一定还有权限。假设某个用户被管理员封禁但他手里仍有一个未过期的合法token签名校验仍然能通过。如果业务对这类状态的实时性有要求就必须在验证流程里增加额外检查比如查库里用户状态是否正常。这会让JWT不再是那么纯粹的“无状态”但业务约束往往优先于架构纯度。5. JWT的边界优势很明显坑点也不少把原理和流程都过了一遍之后最后来谈谈边界问题。JWT不是银弹它有自己的舒适区也有明显的短板。作为学习系列的第一篇把这些边界说明白比多敲几段代码更有价值。毕竟选错技术方案的成本远大于改一段代码的成本。5.1 无状态、跨域友好、天然适应分布式JWT最亮眼的优势就是让服务端从会话数据里彻底解脱出来。不再占用服务器内存不需要额外部署Redis来共享session不用考虑多实例环境下“这台机器不认识那个sessionId”的尴尬。接口服务的水平扩展变得极其轻松新加一台实例直接部署即可没有会话同步的负担。其次JWT对前后端分离和移动端都足够友好。token是纯字符串放在请求头里传输完全不依赖Cookie的域和路逻辑。跨域场景下前端和API域名不同免去了大量处理Cookie跨域传递的麻烦。移动端App也没有Cookie这种概念它们天然就适合处理token。还有一个实际收益容易被忽略令牌自带了用户的基础信息省去了一次数据库查询。在请求量很大的读接口场景里这下性能提升是可感知的。每一个接口都少一次存储查询积少成多效果还是很明显的。5.2 避坑清单过期、撤销、密钥、体积优势明显的同时JWT带了一批“伴随争议”。用之前最好把这些坑提前想清楚。第一个是过期时间短的问题。token一旦签发在有效期内就是“通行证”服务端无法主动让它失效。想踢某个用户下线改了他的密码他手里的旧token依然能通过签名校验。只能靠等它自然过期。所以实际项目里访问令牌的有效期往往要压到比较短比如15分钟到2小时再配合刷新机制把风险窗口压到最小。第二个是撤销机制缺失。Session可以随时从缓存里删掉JWT做不到。要实现类似功能得额外引入黑名单或白名单机制在服务端记录已撤销的token标识。这又靠向有状态了JWT的纯粹性自然大打折扣。第三个是密钥泄露等于全线失守。签名密钥相当于整枚“公章”一旦泄露攻击者可以为所欲为地签发任意身份的token。这个密钥的存放和轮换非常关键。我见过有人把密钥硬编码在代码里然后推到代码仓库的这基本等于把大门钥匙放在门垫底下。密钥至少应该放在环境变量或专用的机密管理服务里并建立定期轮换的机制。第四个是token体积膨胀。Payload里每多放一个字段token就会变大一点。请求头里背着一个几KB的token来回传输在高频接口场景下无疑是一种负担。所以Payload的设计原则是“越少越好”只放验证身份必需的字段其余信息放回服务端查询。5.3 什么时候选JWT什么时候继续用Session聊完边界给出选型建议。以我这些年实际接手的项目来看没有绝对优的方案只有更合适的选择。JWT适合这几类场景大规模分布式部署服务端无状态化需求强烈。前后端完全分离多端共用一套认证体系。接口对外部开放需要一个自包含的访问凭证。相对地如果系统还是以服务端渲染为主Cookie和Session用得非常顺业务又需要频繁主动踢人那Session继续用下去完全没毛病。有些内网系统用户量小、又要求精细化的会话管理Session方案确实省心得多。我这个系列后续的文章会接着写JWT的签发校验完整代码、刷新令牌的设计思路、以及JWT与Session在同一系统里混用的实践。第一篇就到这里核心要记住的就一句话JWT把身份信息写进一个带防伪签的令牌服务器不再需要存会话令牌本身就能证明身份。下一次写代码的时候把这三段结构在脑子里过一遍很多问题自然就绕开了。
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑