1. 从一次登录翻车说起为什么我又回头啃JWT前段时间帮一个朋友排查他那个小项目的登录问题现象很典型用户登录成功之后刷新页面就掉登录态偶尔又莫名其妙地保持住了前端同学一口咬定是后端接口抽风后端同学说token明明发出去了。折腾了大半天最后发现问题出在token的存储和过期校验上——他们用的是JWT但把过期时间设成了永不过期前端又存在内存变量里刷新自然就没了。这事让我意识到JWT这东西看起来简单一个字符串三段点号拼起来但真正把它用对、用稳坑其实不少。很多教程上来就贴一段jwt.sign()的代码告诉你这样就生成token了但没人告诉你签名密钥怎么管、过期时间怎么定、刷新机制怎么设计、注销到底能不能真正注销。这篇内容我打算把JWT的基本使用从头到尾捋一遍不是那种API文档式的罗列而是按一个真实项目落地的顺序来讲它到底是什么、为什么选它、怎么签发、怎么校验、怎么处理过期和注销、以及我在实际项目里踩过的那些坑。适合刚接触JWT的同学也适合用过但总觉得没吃透的开发者。看完你至少能做到自己独立设计一套基于JWT的登录鉴权流程并且知道每个参数为什么这么设。2. JWT到底是什么把三段字符串拆开看2.1 一个JWT长什么样先看一个真实的JWT字符串长这样eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IuW8oOS4iSIsImFkbWluIjp0cnVlLCJpYXQiOjE3MDAwMDAwMDAsImV4cCI6MTcwMDAwMzYwMH0.dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk肉眼看上去就是三段用点号.隔开的乱码。这三段分别叫Header头部、Payload载荷、Signature签名格式是header.payload.signature。很多人第一次看到会觉得神秘其实拆开一看特别朴素。第一段Header是Base64Url编码的JSON解码出来是{ alg: HS256, typ: JWT }alg表示签名算法typ表示类型。第二段Payload也是Base64Url编码的JSON解码出来是{ sub: 1234567890, name: 张三, admin: true, iat: 1700000000, exp: 1700003600 }这里面sub是主体、name和admin是自定义的业务字段、iat是签发时间、exp是过期时间。第三段Signature是用Header里声明的算法把前两段加上一个密钥算出来的签名值。注意Base64Url编码不是加密任何人拿到token都能把前两段解码看到明文内容。所以Payload里绝对不能放密码、身份证号这类敏感信息。这一点是新手最容易犯的错我见过有人把用户手机号、甚至密码哈希塞进Payload的等于把敏感数据挂在公告栏上。2.2 它和传统Session的区别在哪传统Session的思路是用户登录后服务端生成一个sessionId存到服务端的内存或Redis里然后把sessionId发给客户端通常放Cookie。之后每次请求服务端拿sessionId去存储里查查到就认查不到就拒。JWT的思路完全不同服务端不存任何东西所有需要的信息都塞进token本身客户端每次带着token来服务端用密钥验一下签名签名对且没过期就认。这就是所谓的无状态stateless。这个区别带来的影响是连锁的。传统Session的存储是服务端的负担用户多了要扩容RedisJWT把负担转移到了客户端和每次请求的传输上服务端只做一次签名校验理论上可以无限水平扩展。但代价也很明显服务端失去了主动让某个token失效的能力。Session时代你想踢人下线删掉Redis里那条记录就行JWT时代token一旦签发在过期之前它就一直有效除非你额外搞一套黑名单机制。所以选不选JWT本质是在服务端存储成本和主动失效能力之间做权衡。用户量不大、需要精细控制登录态的场景Session反而更省心分布式、多服务、跨域、要给第三方发凭证的场景JWT的优势才真正体现出来。2.3 为什么是签名而不是加密这里要澄清一个高频误解JWT默认是签名sign不是加密encrypt。签名保证的是内容没被篡改而不是内容别人看不到。打个比方签名就像你在信封封口处盖了个火漆印。别人拆开信封看了内容再重新封上火漆印就对不上了你一看就知道被动过。但信封本身是透明的内容谁都能看。JWT的Signature就是这个火漆印它由Header、Payload和密钥共同算出攻击者不知道密钥改了Payload里的admin: false改成true签名立刻对不上服务端校验就会失败。如果你确实需要内容保密JWT规范里还有JWEJSON Web Encryption这一套那是加密的但日常登录鉴权99%的场景用的都是JWS签名也就是我们平时说的JWT。搞清楚这一点你就不会纠结为什么我token里的用户ID别人能看到了——能看到是正常的改不了才是关键。3. 签发一个Token参数怎么设才不踩坑3.1 选算法HS256还是RS256签名算法分两大类对称加密HMAC系列如HS256和非对称加密RSA/ECDSA系列如RS256。HS256只有一个密钥签发和校验用的是同一个。优点是快、简单适合单体应用或者签发方和校验方是同一套服务的场景。缺点是密钥必须严格保密任何持有密钥的一方都能签发token所以密钥一旦泄露等于任何人都能伪造身份。RS256有一对密钥私钥签发公钥校验。私钥只有认证服务持有其他服务只拿公钥校验即使公钥泄露也无法伪造token。适合微服务架构认证中心签发各个业务服务只校验。我个人的选择习惯是单体项目或者小团队HS256够用别过度设计一旦涉及多个服务、或者要把校验能力开放给第三方果断上RS256。切换成本其实不高主要是密钥管理方式变了。用Node.js的jsonwebtoken库举例HS256签发const jwt require(jsonwebtoken); const token jwt.sign( { userId: 1001, role: user }, process.env.JWT_SECRET, { algorithm: HS256, expiresIn: 2h } );RS256签发则需要读私钥const fs require(fs); const privateKey fs.readFileSync(./keys/private.pem); const token jwt.sign( { userId: 1001, role: user }, privateKey, { algorithm: RS256, expiresIn: 2h } );3.2 过期时间短了烦人长了危险expiresIn这个参数是新手最容易拍脑袋设的。设成7d图省事结果token被盗了七天都失效不了设成5m安全是安全用户五分钟就得重新登录一次体验直接崩。我的经验值是分场景场景建议有效期理由后台管理系统30分钟到2小时操作敏感用户在线时长可控普通C端应用2小时到24小时平衡体验和安全移动端App7天到30天用户期望长期免登录第三方API凭证按调用方约定通常较短配合刷新但光设一个短过期时间是不够的用户不可能每两小时重新输一次密码。这就引出了双token机制一个短命的access token负责日常请求一个长命的refresh token专门用来换新的access token。这个机制我在第5节会详细展开。3.3 Payload里该放什么、不该放什么Payload分两类字段注册声明Registered Claims和自定义声明Custom Claims。注册声明是JWT规范预定义的常用的有iss签发者sub主体通常是用户IDaud接收方exp过期时间必须nbf生效时间iat签发时间jtitoken唯一ID做黑名单时很有用自定义声明就是你自己加的比如role、username、tenantId。放什么的原则很简单校验身份和权限所必需的最小信息。用户ID、角色、租户ID这些放进去合理因为每次请求都要用放进去能省一次数据库查询。但下面这些坚决不放密码、密码哈希手机号、身份证号、银行卡号完整的用户资料对象任何你不想让用户看到的内部字段还有一个坑Payload越大token越长每次请求的传输开销越大。我见过有人把整个用户对象塞进去token长到几百个字符Header都快放不下了。记住token是每次请求都要带的能精简就精简。3.4 密钥管理别把密钥写死在代码里这是我最想强调的一点。我见过太多项目把JWT_SECRET mysecret123直接写在代码里然后提交到代码仓库。这等于把家门钥匙插在门上。正确做法密钥通过环境变量注入代码里只读process.env.JWT_SECRET密钥长度要够HS256至少32字节的随机字符串别用secret这种弱口令不同环境开发、测试、生产用不同密钥定期轮换密钥轮换时要考虑旧token的兼容可以用kid字段标识密钥版本RS256的私钥文件权限要收紧别放进代码仓库生成一个靠谱的随机密钥可以这样node -e console.log(require(crypto).randomBytes(32).toString(hex))4. 校验与解析服务端到底做了什么4.1 校验流程拆解服务端收到请求从Authorization头里取出token通常是Bearer token格式然后做几件事格式检查是不是三段点号分隔的结构签名校验用密钥重新计算签名和token里的签名比对过期校验检查exp是否早于当前时间生效时间校验检查nbf是否晚于当前时间如果设置了签发者校验检查iss是否符合预期如果设置了任何一步失败都应该返回401。用jsonwebtoken的verify方法这些校验大部分是自动完成的try { const decoded jwt.verify(token, process.env.JWT_SECRET, { algorithms: [HS256], issuer: my-auth-service }); // decoded 就是 Payload 的内容 console.log(decoded.userId, decoded.role); } catch (err) { if (err.name TokenExpiredError) { // token 过期 } else if (err.name JsonWebTokenError) { // 签名不对或格式错误 } }注意verify的时候一定要显式指定algorithms白名单。这是一个经典的安全漏洞——早期有些库如果不指定算法攻击者可以把Header里的alg改成none或者把RS256改成HS256然后用公钥当HMAC密钥来伪造签名。显式指定算法能堵住这个口子。4.2 中间件封装别在每个接口里重复写真实项目里不可能每个接口都手写一遍校验逻辑肯定要封装成中间件。以Express为例function authMiddleware(req, res, next) { const authHeader req.headers.authorization; if (!authHeader || !authHeader.startsWith(Bearer )) { return res.status(401).json({ code: 401, msg: 未提供凭证 }); } const token authHeader.slice(7); try { const decoded jwt.verify(token, process.env.JWT_SECRET, { algorithms: [HS256] }); req.user decoded; next(); } catch (err) { if (err.name TokenExpiredError) { return res.status(401).json({ code: 40101, msg: 凭证已过期 }); } return res.status(401).json({ code: 401, msg: 凭证无效 }); } }这里有个细节值得说过期和无效要返回不同的错误码。因为前端拿到过期的错误码应该触发刷新token的流程拿到无效的错误码应该直接跳登录页。如果都返回401前端就没法区分该刷新还是该重新登录。4.3 前端怎么存tokentoken存哪里是个老生常谈的问题。三个选项localStorage持久化刷新不丢但能被同域的JS读取有XSS风险sessionStorage关掉标签页就没了同样有XSS风险HttpOnly CookieJS读不到能防XSS但会带上CSRF风险需要配CSRF token我的实际选择是如果是纯前后端分离、前端自己控制请求头的项目用localStorage配合严格的XSS防护CSP、输入转义如果后端能统一设置Cookie优先用HttpOnly Cookie加SameSite属性安全性更好。不管存哪里有一点是共通的别把token存在内存变量里。我开头提到的那个朋友的项目就是栽在这刷新页面变量重置token就没了。5. 过期与注销JWT最让人头疼的两件事5.1 双Token刷新机制前面说了access token设短一点安全但用户体验差。解决办法是配一个refresh token。流程是这样的用户登录服务端签发一对tokenaccess token2小时 refresh token7天前端每次请求带access tokenaccess token过期前端拿refresh token去换新的access tokenrefresh token也过期了才要求用户重新登录refresh token和access token的区别在于refresh token只在刷新接口用不参与日常请求所以暴露面小而且refresh token应该存到服务端数据库或Redis这样才能实现主动失效。刷新接口大概长这样app.post(/auth/refresh, async (req, res) { const { refreshToken } req.body; try { const decoded jwt.verify(refreshToken, process.env.REFRESH_SECRET); // 检查这个 refresh token 是否还在白名单里 const stored await redis.get(refresh:${decoded.jti}); if (!stored) { return res.status(401).json({ msg: 凭证已失效 }); } // 签发新的 access token const newAccess jwt.sign( { userId: decoded.userId, role: decoded.role }, process.env.JWT_SECRET, { expiresIn: 2h } ); res.json({ accessToken: newAccess }); } catch (err) { res.status(401).json({ msg: 刷新失败 }); } });这里用到了jtitoken唯一ID签发refresh token时给它一个唯一值存到Redis里。注销时删掉这个keyrefresh token就失效了。这是JWT实现主动失效的常见套路。5.2 注销到底怎么实现纯JWT是没法真正注销的因为服务端不存状态。要实现注销必须引入额外机制常见的有三种方案一黑名单。注销时把token的jti或整个token存到Redis设一个和token剩余有效期相同的过期时间。每次校验时先查黑名单。缺点是每次请求都要查一次Redis失去了部分无状态的优势。方案二短access token 服务端refresh token。access token设得很短比如15分钟注销时删掉服务端的refresh token。这样最多15分钟后access token自然过期用户就彻底登出了。这个方案兼顾了安全和性能是我最推荐的。方案三用户级失效时间。在用户表里存一个tokenValidAfter字段签发token时把iat记进去校验时比对iat是否早于tokenValidAfter早于就拒绝。改密码或强制下线时更新这个字段。这个方案不用存每个token只需要存一个时间戳很省空间。我实际项目里用得最多的是方案二加方案三的组合日常靠短token自然过期需要强制踢人时更新用户级失效时间。5.3 一个容易忽略的坑时钟偏移JWT的过期校验依赖服务端时间。如果签发token的服务器和校验token的服务器时间不一致就会出现刚签发的token立刻过期或者过期了还能用的诡异现象。jsonwebtoken的verify支持clockTolerance参数允许一定的时钟偏移jwt.verify(token, secret, { clockTolerance: 30 // 容忍 30 秒的时钟偏移 });分布式部署时务必保证所有机器的时间同步NTP并设置合理的容忍值。这个坑我在一次多机房部署时踩过两个机房时间差了将近一分钟导致部分请求间歇性鉴权失败排查了很久才定位到。6. 实战踩坑记录与排查速查表6.1 我踩过的几个典型坑坑一Payload塞太多导致请求头超限。有个项目把用户的权限列表全塞进token结果权限多的用户token超过8KB触发了网关的请求头大小限制报431错误。后来改成token里只放角色具体权限查缓存。坑二密钥轮换没做兼容。有次为了安全轮换了密钥结果所有在线用户的token全部失效被迫全员重新登录。正确做法是保留旧密钥一段时间校验时先试新密钥再试旧密钥或者用kid字段标识。坑三refresh token没设过期。图省事给refresh token设了永不过期结果用户注销后token还能用。refresh token一定要设过期而且要比access token长但有限。坑四跨域时Authorization头被拦。CORS配置里忘了允许Authorization头前端请求一直报跨域错误。这个在前后端分离项目里很常见记得在CORS配置里加上allowedHeaders: [Authorization, Content-Type]。6.2 常见问题速查表现象可能原因排查方向刷新页面掉登录态token存在内存变量改用localStorage或Cookie刚登录就提示过期服务器时钟不同步检查NTP加clockTolerance签名校验一直失败密钥不一致或算法不匹配核对签发和校验的密钥、算法注销后token还能用没有失效机制引入黑名单或用户级失效时间请求头过大报431Payload太大精简Payload权限查缓存跨域请求被拦CORS没允许Authorization头检查CORS配置token被篡改仍通过没指定algorithms白名单verify时显式指定算法6.3 几条实操心得第一开发阶段把过期时间设短比如5分钟这样能快速验证刷新逻辑是否正确不用等两小时。上线前再调回正常值。第二给token加个jti哪怕暂时不用黑名单将来要加的时候不用改签发逻辑。这是低成本的前瞻性设计。第三日志里别打印完整token。token等同于密码日志泄露等于凭证泄露。要打就打印前几位加省略号或者只打jti。第四写单元测试覆盖过期和篡改场景。用jwt.sign时传一个过去的expiresIn比如-1s就能造出过期token测试校验逻辑是否返回正确的错误码。第五前端拦截器统一处理401。在axios的响应拦截器里判断401过期就触发刷新刷新失败就跳登录。别在每个请求里单独处理会写疯。7. 关于JWT我最后想说的几句JWT不是银弹它解决的是无状态鉴权这个特定问题代价是失去了主动失效的便利。我见过不少项目明明是个单体应用、用户量也不大非要上JWT结果为了处理注销和刷新又搞了一套Redis黑名单绕了一圈回到Session的思路还多了复杂度。所以选型的时候先问自己我的服务是不是分布式的是不是要给第三方发凭证是不是真的在意服务端那点存储成本如果答案都是否Session可能更省心。如果答案是是那JWT确实合适但一定要把刷新机制、失效机制、密钥管理这三件事想清楚再动手。最后分享一个我自己的习惯每次设计鉴权流程我都会先画一张时序图把登录、请求、过期、刷新、注销这五个动作的token流转标清楚再开始写代码。这张图能帮你提前发现很多逻辑漏洞比写完再调试省事得多。JWT的基本使用不难难的是把边界情况都想周全希望这篇内容能帮你少走点弯路。