1. 从一次登录翻车说起:为什么我要重新梳理JWT
去年帮一个朋友排查线上问题,现象很典型:用户登录成功后,前端拿着后端返回的token去请求订单列表,十次里有三次返回401。更诡异的是,同一个账号在A浏览器正常,在B浏览器就间歇性失效。当时团队里有人怀疑是缓存问题,有人怀疑是跨域,折腾了一整天才定位到根因——token的过期时间设置成了30分钟,但前端在每次路由跳转时都会重新拉一次用户信息,而刷新token的逻辑写错了,导致新旧token在并发请求里互相覆盖。
这件事让我意识到,JWT这东西看起来简单,几行代码就能签发和校验,但真正把它用对、用稳,需要把它的生命周期、存储方式、刷新策略、安全边界全部想清楚。很多教程只告诉你“JWT由三部分组成,用点号连接”,却很少讲清楚:为什么你的token会被篡改、为什么退出登录后token还能用、为什么分布式系统里校验总是失败。
这篇内容就是把我这些年踩过的坑、总结的经验,按照一个可复现的项目思路完整梳理一遍。不管你是刚接触JWT的新手,还是已经在项目里用了但总觉得哪里不对劲的开发者,都能从中找到可以直接抄作业的方案。核心关键词就一个:JWT token的基本使用,但我会把它拆到你能真正落地的程度。
2. 项目整体设计与思路拆解
2.1 为什么选JWT而不是Session
在动手写代码之前,先想清楚一个问题:你的系统到底适不适合用JWT。我见过太多项目,明明是一个后台管理系统,用户量不大,却非要上JWT,结果带来一堆刷新和注销的麻烦。也见过一些面向多端的API服务,用Session导致横向扩展时各种粘性会话配置,苦不堪言。
JWT的核心价值在于无状态。服务端不需要存储会话信息,token本身就携带了用户标识和过期时间,任何一台服务器拿到token都能独立完成校验。这意味着你可以随便加机器、随便做负载均衡,不用担心会话同步问题。对于微服务架构、移动端API、跨域场景,这个优势非常明显。
但无状态也意味着服务端失去了对token的绝对控制权。你没法像Session那样,在服务端一删就立刻失效。token一旦签发,在过期之前理论上一直有效。所以如果你的业务需要“强制下线”“实时踢人”“单点登录互斥”,JWT就需要配合额外的机制来实现,比如黑名单、版本号、短过期时间加刷新token。
我的建议是:面向多端、服务无状态、API优先的场景用JWT;传统Web后台、需要强会话控制的场景用Session更省心。不要为了技术而技术。
2.2 整体架构设计
这个项目的目标很明确:实现一套完整的JWT token使用方案,覆盖签发、校验、刷新、注销四个核心环节。我采用的架构是双token机制:访问token(access token)短期有效,刷新token(refresh token)长期有效。访问token过期后,前端用刷新token换新的访问token,刷新token本身也可以设置过期时间,但比访问token长得多。
为什么这么设计?因为如果只有一个长期token,一旦泄露,攻击者可以长期使用;如果只有一个短期token,用户会频繁被踢出登录,体验极差。双token机制在安全性和体验之间找到了平衡点。访问token设置15到30分钟,刷新token设置7到30天,具体数值根据业务敏感度调整。
存储方面,访问token放在前端内存或sessionStorage里,刷新token放在httpOnly的cookie里。这样即使XSS攻击拿到了访问token,它也会很快过期;而刷新token因为httpOnly,JavaScript读不到,降低了泄露风险。当然,如果你的前端是纯移动端App,没有cookie机制,那就需要把刷新token存在安全存储里,比如Keychain或Keystore。
2.3 技术选型与依赖
后端我选的是Node.js + Express + jsonwebtoken库,这是最经典的组合,文档全、社区大、坑都被踩得差不多了。数据库用Redis来存刷新token和黑名单,因为Redis的过期机制天然适合这种场景。前端用axios做请求拦截,自动在请求头里带上token,并在401时触发刷新逻辑。
jsonwebtoken这个库有几个关键配置项需要特别注意:expiresIn控制过期时间,algorithm指定签名算法(默认HS256),issuer和audience用于校验签发者和接收者。很多人只填一个expiresIn就完事了,结果token被别的系统拿去用也能通过校验,这是很危险的。
Redis的选型理由很简单:刷新token需要设置过期时间,黑名单也需要自动清理,Redis的TTL机制完美匹配。如果用数据库存,还得自己写定时任务清理过期数据,麻烦且容易出错。
3. 核心细节解析与实操要点
3.1 JWT的三段式结构到底怎么理解
JWT由三部分组成:Header、Payload、Signature,用两个点号连接。很多人背过这个结构,但未必真正理解每一部分的作用。
Header是一个JSON对象,通常长这样:{"alg":"HS256","typ":"JWT"}。alg表示签名算法,typ表示token类型。这部分会被Base64Url编码,注意不是加密,任何人拿到token都能解码看到内容。
Payload是存放实际数据的地方,包含标准声明和自定义声明。标准声明有iss(签发者)、exp(过期时间)、sub(主题)、aud(接收者)等。自定义声明就是你自己的业务数据,比如用户ID、角色。这里有一个大坑:Payload只是Base64编码,不是加密,所以绝对不要放密码、身份证号等敏感信息。我见过有人在Payload里放用户手机号,以为别人看不到,实际上随便找个在线工具就能解出来。
Signature是签名部分,用Header里指定的算法,把编码后的Header、编码后的Payload、一个密钥(secret)拼在一起计算出来的。它的作用是防篡改:如果有人改了Payload里的用户ID,签名就对不上,服务端校验就会失败。
注意:Base64Url编码和Base64编码不完全一样,它把
+换成-,/换成_,并且去掉末尾的=。自己手动拼接token时如果搞错了,校验必然失败。
3.2 签名算法怎么选:HS256还是RS256
HS256是对称加密,签发和校验用同一个密钥。优点是快、简单;缺点是密钥必须保密,且所有校验方都持有同一个密钥,一旦泄露全盘皆输。RS256是非对称加密,私钥签发、公钥校验。优点是公钥可以公开分发,校验方不需要持有私钥;缺点是计算开销大一些,密钥管理更复杂。
怎么选?如果你的系统是单体应用,签发和校验都在同一个服务里,HS256足够了。如果是微服务架构,多个服务需要校验token但只有认证服务能签发,那就用RS256,认证服务持有私钥,其他服务只拿公钥。
我实测下来,HS256在普通服务器上每秒能签几万个token,RS256大概几千个,差距明显但通常够用。除非你的QPS特别高,否则性能不是选型的主要考量。
3.3 过期时间与刷新策略的参数计算
访问token的过期时间怎么定?太短用户体验差,太长安全风险高。我的经验公式是:访问token过期时间 = 用户单次操作最长耗时 × 2。比如一个报表导出操作最多花5分钟,那访问token设10到15分钟比较合理。大部分业务场景下,15分钟是个不错的起点。
刷新token的过期时间取决于业务对安全的要求。金融类应用可能设7天,社交类应用设30天甚至更长。关键是刷新token要能主动失效,比如用户修改密码后,所有旧的刷新token都应该作废。
刷新时机也很讲究。不要等访问token过期了才刷新,那样会有一次请求失败。正确做法是在访问token还剩5分钟有效期时,前端主动触发刷新。这样用户完全无感知。如果并发请求同时发现需要刷新,要加锁避免重复刷新,否则会生成多个刷新token,导致混乱。
3.4 存储位置的安全权衡
前端把token存哪里,这个问题争论了很多年。localStorage方便但容易被XSS读取;sessionStorage稍好,关闭标签页就清除;cookie可以设httpOnly防XSS,但容易受CSRF攻击;内存最安全,但刷新页面就丢了。
我的方案是组合使用:访问token放内存 + sessionStorage双写,刷新token放httpOnly + Secure + SameSite=Strict的cookie。这样兼顾了安全性和体验。SameSite=Strict能防大部分CSRF,Secure保证只在HTTPS下传输。
提示:如果你的前端和后端不同域,cookie需要设SameSite=None,同时必须加Secure,否则浏览器会拒绝。跨域场景下还要配置CORS的credentials。
4. 实操过程与核心环节实现
4.1 环境准备与依赖安装
先初始化项目,安装必要的依赖。我用的是Express框架,数据库操作直接用ioredis,JWT用jsonwebtoken,密码哈希用bcrypt。
mkdir jwt-demo && cd jwt-demo npm init -y npm install express jsonwebtoken ioredis bcrypt dotenv npm install -D nodemon目录结构这样组织:src/config放配置,src/middleware放中间件,src/routes放路由,src/utils放工具函数。配置文件用.env管理,密钥绝对不能硬编码在代码里。
# .env ACCESS_TOKEN_SECRET=your_access_secret_here REFRESH_TOKEN_SECRET=your_refresh_secret_here ACCESS_TOKEN_EXPIRES=15m REFRESH_TOKEN_EXPIRES=7d REDIS_HOST=127.0.0.1 REDIS_PORT=6379密钥怎么生成?用Node的crypto模块:require('crypto').randomBytes(64).toString('hex')。每个环境用不同的密钥,开发和生产的密钥必须分开。
4.2 签发token的核心代码
签发逻辑我封装成一个工具函数,接收用户信息,返回访问token和刷新token。
const jwt = require('jsonwebtoken'); function generateTokens(user) { const payload = { userId: user.id, username: user.username, role: user.role }; const accessToken = jwt.sign(payload, process.env.ACCESS_TOKEN_SECRET, { expiresIn: process.env.ACCESS_TOKEN_EXPIRES, issuer: 'my-app', audience: 'my-app-client' }); const refreshToken = jwt.sign( { userId: user.id, tokenVersion: user.tokenVersion }, process.env.REFRESH_TOKEN_SECRET, { expiresIn: process.env.REFRESH_TOKEN_EXPIRES, issuer: 'my-app', audience: 'my-app-client' } ); return { accessToken, refreshToken }; }这里有几个细节值得展开。第一,访问token的payload里放了角色信息,方便后续做权限校验,不用每次都查数据库。第二,刷新token里放了tokenVersion,这是用来实现“强制下线”的关键。用户每次修改密码或点击退出所有设备,就把数据库里的tokenVersion加一,旧的刷新token因为版本号对不上就失效了。第三,issuer和audience一定要加,校验时也要检查,防止token被其他系统冒用。
4.3 校验中间件的实现
校验中间件负责从请求头提取token、验证签名、检查过期时间、把用户信息挂到req上。
function authenticate(req, res, next) { const authHeader = req.headers['authorization']; const token = authHeader && authHeader.split(' ')[1]; if (!token) { return res.status(401).json({ message: '未提供token' }); } try { const decoded = jwt.verify(token, process.env.ACCESS_TOKEN_SECRET, { issuer: 'my-app', audience: 'my-app-client' }); req.user = decoded; next(); } catch (err) { if (err.name === 'TokenExpiredError') { return res.status(401).json({ code: 'TOKEN_EXPIRED', message: 'token已过期' }); } return res.status(403).json({ message: 'token无效' }); } }注意这里区分了TokenExpiredError和其他错误。过期返回401并带上特定code,前端看到这个code就去刷新token;签名错误返回403,说明token被篡改了,直接让用户重新登录。这个区分很重要,否则前端无法判断是该刷新还是该跳登录页。
4.4 刷新token的完整流程
刷新接口接收刷新token,校验通过后签发新的访问token,同时可以选择是否轮换刷新token。
async function refreshToken(req, res) { const { refreshToken } = req.cookies; if (!refreshToken) { return res.status(401).json({ message: '未提供刷新token' }); } try { const decoded = jwt.verify(refreshToken, process.env.REFRESH_TOKEN_SECRET, { issuer: 'my-app', audience: 'my-app-client' }); const storedToken = await redis.get(`refresh:${decoded.userId}`); if (storedToken !== refreshToken) { return res.status(403).json({ message: '刷新token已失效' }); } const user = await getUserById(decoded.userId); if (user.tokenVersion !== decoded.tokenVersion) { return res.status(403).json({ message: 'token版本不匹配' }); } const tokens = generateTokens(user); await redis.set(`refresh:${user.id}`, tokens.refreshToken, 'EX', 7 * 24 * 3600); res.cookie('refreshToken', tokens.refreshToken, { httpOnly: true, secure: true, sameSite: 'strict', maxAge: 7 * 24 * 3600 * 1000 }); res.json({ accessToken: tokens.accessToken }); } catch (err) { return res.status(403).json({ message: '刷新token无效' }); } }这段代码里有三个校验层次:JWT签名校验、Redis存储校验、版本号校验。为什么要存Redis?因为JWT本身无法主动失效,存一份在Redis里,退出登录时删掉,就能实现主动失效。虽然这在一定程度上破坏了无状态性,但安全性和可控性大大提升。我的观点是:纯无状态的JWT只适合对安全性要求不高的场景,真实业务里加一层Redis存储是值得的。
4.5 退出登录与强制下线
退出登录要做两件事:删除Redis里的刷新token,把访问token加入黑名单直到它过期。
async function logout(req, res) { const { refreshToken } = req.cookies; const accessToken = req.headers['authorization']?.split(' ')[1]; if (refreshToken) { const decoded = jwt.decode(refreshToken); if (decoded) { await redis.del(`refresh:${decoded.userId}`); } } if (accessToken) { const decoded = jwt.decode(accessToken); if (decoded && decoded.exp) { const ttl = decoded.exp - Math.floor(Date.now() / 1000); if (ttl > 0) { await redis.set(`blacklist:${accessToken}`, '1', 'EX', ttl); } } } res.clearCookie('refreshToken'); res.json({ message: '已退出登录' }); }黑名单的key用token本身,value随便填,过期时间设为token剩余的有效期。这样token自然过期后,黑名单记录也会自动清理,不会无限增长。校验中间件里要加一步检查黑名单,如果在黑名单里直接拒绝。
强制下线所有设备就更简单了:把用户的tokenVersion加一,然后删除Redis里的刷新token。所有旧的访问token虽然还在有效期内,但刷新token已经失效,用户最多再用15分钟就会被踢出。
5. 常见问题与排查技巧实录
5.1 高频问题速查表
| 问题现象 | 可能原因 | 排查方法 | 解决方案 |
|---|---|---|---|
| 401 Unauthorized | token未携带或格式错误 | 检查请求头Authorization字段 | 确保格式为Bearer <token> |
| 403 Forbidden | 签名校验失败 | 检查密钥是否一致 | 统一各环境的密钥配置 |
| token立即过期 | expiresIn单位错误 | 检查是否误写为秒数 | 使用15m而非15 |
| 刷新后仍401 | 新旧token并发覆盖 | 查看前端并发请求日志 | 加锁或队列化刷新请求 |
| 跨域cookie丢失 | SameSite配置不当 | 检查浏览器cookie面板 | 跨域设SameSite=None; Secure |
| 分布式校验失败 | 各节点密钥不同 | 对比各节点环境变量 | 集中管理密钥配置 |
5.2 那些文档里不会写的坑
第一个坑:时钟偏移。JWT的exp和iat都是基于服务器时间的。如果签发服务器和校验服务器的时钟差了几分钟,就会出现token刚签发就过期,或者过期了还能用的情况。解决方案是给校验加一个容错窗口,jsonwebtoken的verify支持clockTolerance选项,设个30秒到1分钟比较稳妥。
第二个坑:并发刷新。前端同时发五个请求,都返回401,然后五个请求同时去刷新token,结果生成了五个新的刷新token,最后一个覆盖前面的,导致部分请求拿着已经失效的访问token重试。解决方案是在前端加一个刷新锁,第一个请求触发刷新,其他请求等待刷新完成后再重试。
第三个坑:Payload过大。有人把用户的完整信息都塞进Payload,导致token长度超过几KB,每次请求都带着这么大的头,浪费带宽。Payload里只放必要的标识信息,比如用户ID和角色,其他信息用时再查。
第四个坑:Base64解码误解。很多人以为JWT是加密的,把敏感信息放进去。记住:JWT的Payload是明文,只是编码不是加密。任何拿到token的人都能看到里面的内容。
提示:调试时可以用
jwt.decode(token, { complete: true })查看token的完整结构,但注意decode不校验签名,只做解码,不要用它来做安全判断。
5.3 安全加固的几条硬建议
第一,密钥强度要够。HS256的密钥至少32字节,最好64字节。用crypto.randomBytes(64).toString('hex')生成,不要用“secret”“123456”这种。
第二,必须用HTTPS。token在网络上传输,如果是HTTP,中间人可以直接截获。HTTPS是底线,没有商量余地。
第三,设置合理的过期时间。访问token不要超过30分钟,刷新token不要超过30天。时间越长,泄露后的风险窗口越大。
第四,校验所有声明。不要只校验签名,iss、aud、exp都要校验。jsonwebtoken的verify默认会校验exp,但iss和aud需要手动传参才会校验。
第五,敏感操作二次验证。修改密码、转账等操作,不能只凭token,要求用户重新输入密码或进行二次验证。
6. 我在实际项目中的几点体会
这套方案我在三个项目里用过,从日活几千到几十万都有。最大的体会是:JWT的复杂度不在于签发和校验,而在于生命周期管理。签发就几行代码,但刷新、注销、强制下线、多端登录这些场景,每一个都需要仔细设计。
Redis那层存储看起来破坏了无状态,但实际收益远大于损失。没有它,你没法做真正的退出登录,没法强制下线,没法限制单设备登录。而且Redis的性能足够高,多一次查询对整体响应时间的影响微乎其微。
另外,前端的配合至关重要。后端设计得再好,前端如果不在401时正确触发刷新,或者刷新时没有加锁,用户体验依然会很差。建议前后端一起评审token方案,把刷新时机、重试逻辑、错误处理都对齐。
最后分享一个小技巧:在开发环境把访问token的过期时间设成30秒,刷新token设成2分钟,这样能快速验证刷新逻辑是否正确。上线前再改回正常值。这个办法帮我提前发现了好几个并发刷新的bug。