写在前面这篇文章不是我第一次写JWT但确实是我踩了足够多的坑之后觉得必须重新讲一遍的话题。前后端分离的项目越来越多API直接暴露在公网上的情况也越来越多而用户认证与授权这件事几乎每个团队都会选JWT来解。但说实话真正能把JWT用得既安全又顺手的人并不多。我见过不少项目token签发倒是写得很溜但要问一句如果token被人拿到了你的服务端能发现吗对方就愣住了。也见过把用户ID、手机号、甚至余额直接塞进payload里觉得加密了就没事结果被网上随便一个jwt在线解析工具把内容看了个底朝天。所以这篇博文不打算只给你一段能跑的代码而是想把为什么这么设计讲明白把那些我实战中遇到的坑一个一个摊开。如果你正在做用户认证、给API接入JWT授权或者已经接了但总感觉不踏实这篇文章都适合你。我会从签发端、客户端、校验端、授权模型、再到线上踩坑完整过一遍我自己的工程实践。1. 认证和授权是两件事先分清你是谁和你能干什么1.1 无状态API的诱惑与代价传统Web应用里登录状态靠Session保存在服务端内存或Redis里浏览器通过Cookie里的sessionId找到对应的会话。这种方式在单体时代非常好用但到了前后端分离、移动端和PC端都要访问同一套API时问题就来了客户端不是一个浏览器它可能没有Cookie机制也可能跨域访问不同的子域名服务端被负载均衡拆成多台之后session同步也成了麻烦。JWT的流行本质上是因为它把会话信息从服务端搬到了客户端手里。服务端不保存会话每次请求把token带回来服务端验签之后就知道这是谁。这就是无状态认证。代价也很明确token一旦签发在过期之前原则上都是有效的服务端没法主动踢人。这个特性后面会反复提到很多安全问题的根源都在这里。1.2 JWT是凭证不是加密数据保险箱这是我在实际交流中遇到最多的误解。JWT的全称是JSON Web Token它由三部分组成Header、Payload、Signature。Header和Payload只是Base64Url编码任何人拿到token都能解码看到里面的明文就像用jwt在线解析工具那样。所以你千万不要往payload里放明文密码、身份证号、手机号这类敏感字段。真正起到保护作用的是Signature签名。服务端用密钥对Header和Payload做签名客户端拿着token回来时服务端重新算一遍签名对得上就说明内容没有被篡改。我把JWT理解成一张带防伪贴的通行证。防伪贴可以验证通行证是不是官方发的、有没有被改过但通行证上的姓名和部门保安一眼就能读出来。所以你需要做的是让这张证上写的必要信息足够少而不是指望别人读不懂。1.3 认证、授权和权限模型先于编码定清楚认证Authentication回答你是谁授权Authorization回答你能干什么。很多项目在写代码的时候把这两件事混在一起接口里既做登录态校验又顺手判断角色一旦角色变了或者权限规则复杂了代码就成了一团浆糊。我的建议是设计接口时先理清三个问题。第一个问题这个接口是不是登录后才能访问这是认证层的事。第二个问题登录用户是否拥有某个角色或者某类操作权限这是授权层的事体现在URL或方法的权限标记上。第三个问题用户能操作的数据范围是什么这是数据权限通常不能只靠token里的角色解决而要在业务代码里根据用户ID、部门ID等条件二次过滤。这三个问题一定要在写第一行代码之前想清楚。因为JWT的claims设计、后端中间件设计、接口权限标记全都会被这三个问题的答案影响。先想清楚后面改动才小。2. 签发JWT时的工程取舍算法、密钥和claims每一步都影响安全2.1 HS256还是RS256别偷懒选一个看起来简单的JWT常用的签名算法有两类对称算法HMAC如HS256和非对称算法RSA/ECDSA如RS256/ES256。两者最大的区别是签名密钥是不是同一个。HS256使用同一个密钥做签名和验签缺点是发布token的服务和校验token的服务必须是同一个信任域密钥不能泄露给外部客户端。RS256使用私钥签名、公钥验签私钥只保存在认证服务器上资源服务器或API网关可以用公钥校验这样哪怕资源服务器被拖库攻击者也拿不到签发token的私钥。我在自己项目里的选择是只要是多个服务之间相互校验token的情况一律用RS256或ES256如果只是单体应用内部校验HS256也勉强能接受但密钥必须至少256位随机字节绝不能写在源码里。ES256比RS256的密钥更短、运算更快但一些老版本库支持不好如果团队依赖比较保守RS256是更稳妥的选择。下面是一个使用Node.js的jsonwebtoken库签发RS256格式token的示例const jwt require(jsonwebtoken); const fs require(fs); // 私钥从环境变量或密钥管理服务读取绝不提交到代码仓库 const privateKey fs.readFileSync(process.env.JWT_PRIVATE_KEY_PATH, utf8); function signAccessToken(user) { const payload { sub: user.id, name: user.username, role: user.role, // 自定义字段避免使用敏感信息 }; return jwt.sign(payload, privateKey, { algorithm: RS256, expiresIn: 15m, issuer: auth-service, audience: my-api, jwtid: require(crypto).randomUUID() }); }2.2 Payload应该放什么、不该放什么用jti做什么JWT的标准claims里有几个必须在签发时想清楚subSubject用户标识必须是唯一且不可变的用户ID不要用用户名因为用户名可能改、exp过期时间、iat签发时间、iss签发者、aud接收方、jtiJWT ID唯一标识。我认为jti是最容易被忽视但又最重要的字段。它本质上是一个随机UUID用来唯一标识这个token。有了jti你才能在后面做token撤销、token续签审计、用户下线记录。比如你想在用户修改密码后把该用户的所有历史token全部作废服务端只需要记录这些token的jti黑名单或者把用户最新密码版本号加进payload校验时对比一下。Payload里除了标准claims尽量少放自定义字段。最理想的做法是只放sub和少量角色信息其余的用户资料在需要时通过API查询——这也是很多人容易忽略的点JWT里的claims一旦签发了客户端和服务端看到的内容是固定的用户资料改了旧token里的内容仍然是旧的会造成数据一致性坑。我在一个项目里就遇到过用户改了昵称前端一直显示旧昵称排查到最后才发现昵称被写进了JWT payload必须等客户端的token过期重新签发才刷新。2.3 Access Token、Refresh Token和过期时间的配合很多人把JWT的过期时间设成7天甚至30天理由是用户不想频繁登录。这个做法很危险Access Token有效时间越长泄露后的风险窗口就越大。现在比较通行的方案是双token机制Access Token短时效比如15分钟到1小时Refresh Token长时效比如7天到30天但Refresh Token只用来换取新的Access Token不走业务接口。流程是这样用户登录后服务端返回一个Access Token和一个Refresh Token。前端用Access Token请求业务APIAccess Token过期后接口返回401前端用Refresh Token调用/auth/refresh接口拿到新的Access Token。Refresh Token通常也需要存在服务端或者以哈希形式记录不过严格来说Refresh Token也可以是无状态的但必须绑定用户的设备信息、IP等上下文并配合jti黑名单做撤销。设置过期时间的建议Access Token从15分钟起步如果业务可以接受更频繁刷新就设短一点Refresh Token的时长取决于产品的安全要求管理后台建议半天到一天用户端App可以做7天到30天。如果涉及支付、修改手机号、删除数据等高风险操作不要只看token最好再加一次二次验证或输入密码。3. 客户端如何安全存放和携带Token存储位置、Header规范与CSRF博弈3.1 localStorage、sessionStorage、内存还是HttpOnly Cookie这几乎是每个前端团队都要吵一轮的问题。先说结论没有绝对完美的方案只有当前场景下的取舍。如果token放在localStorage或sessionStorage里优点是前端代码取用方便配合axios拦截器就能拿到缺点是任何XSS脚本都能直接localStorage.getItem(token)把它偷走。如果token放在HttpOnly Cookie里Javascript读取不到XSS偷不走但会引入CSRF风险因为浏览器会自动带上Cookie攻击者可以诱导用户向你的API发请求。我个人的选择倾向是纯SPA且没有服务端渲染的项目尽量把Refresh Token放在HttpOnly Cookie里并且设置SameSiteLax和Secure属性Access Token可以放在内存变量里比如一个模块级变量每次刷新页面后内存清空用Refresh Token重新换。这样做的好处是Access Token不落盘XSS能拿到的机会小很多坏处是刷新页面后要等待refresh接口返回新的Access Token用户会感受到一次短暂的加载延迟。如果你团队前端能力有限必须用localStorage那也请务必把重点放在防止XSS上对用户输入做输出编码、上线CSPContent-Security-Policy、不引入不可信的第三方脚本。XSS一旦发生localStorage里的token就是案板上的肉。3.2 Authorization: Bearer的写法与axios拦截器JWT的标准携带方式是在HTTP请求头里放Authorization: Bearer token。注意Bearer这个前缀不能省很多API网关和中间件都依赖这个前缀来识别token格式。使用axios时常见的做法是在请求拦截器里统一加上tokenimport axios from axios; const apiClient axios.create({ baseURL: /api }); // 请求拦截器从内存或store里取accessToken apiClient.interceptors.request.use(config { const token getAccessToken(); if (token) { config.headers.Authorization Bearer ${token}; } return config; }); // 响应拦截器遇到401时尝试用refreshToken续签并重放原请求 apiClient.interceptors.response.use( response response, async error { const originalRequest error.config; if (error.response?.status 401 !originalRequest._retry) { originalRequest._retry true; const newToken await refreshAccessToken(); originalRequest.headers.Authorization Bearer ${newToken}; return apiClient(originalRequest); } return Promise.reject(error); } );这里有一个值得说的细节401并不一定都是token过期也可能是token本身非法、被撤销、权限不足。所以响应拦截器里最好根据后端返回的错误码区分处理如果后端返回的是TOKEN_EXPIRED才去续签如果返回的是TOKEN_INVALID或PERMISSION_DENIED直接跳转登录页或提示无权限避免无限刷新循环。3.3 CSRF和XSS对JWT的威胁对比以及防护思路我把这两种攻击放在一张表里对比这样最直观攻击类型针对的存储方式攻击原理核心防护手段XSSlocalStorage、sessionStorage、内存变量在页面注入脚本直接读取token输出编码、CSP、不信任第三方脚本CSRFCookie浏览器自动携带Cookie诱导用户发起请求SameSite、CSRF Token、校验Origin/Referer如果你的token放在Authorization Header里CSRF天然免疫因为跨站请求无法自定义Header如果放在Cookie里就要认真处理CSRF。SameSiteLax能挡掉大部分跨站POST请求但如果业务要求兼容旧浏览器或者有第三方接入场景最好再加一个自定义Header校验或CSRF Token。我见过的很多项目在客户端如何保护token这层几乎没有投入前端框架都自带路由守卫了但没人想过用户退出登录时内存里的token真的清干净了吗。这些细节恰恰是线上安全问题的高发区。4. 服务端如何真正落实授权中间件、角色与权限检查4.1 校验JWT的三个必要步骤验签、验期、验aud和iss很多教程只教你验签完事但生产环境里只验签远远不够。我在服务端写了一个通用认证中间件核心逻辑分三步。第一步是验签用公钥或对称密钥验证Signature确保token没被篡改也确实是自家签发的。如果验签失败直接401。第二步是验期检查exp和nbf。exp是过期时间nbf是生效时间之前一般签发时不用主动设默认就是当前时刻但你要知道有这么一个字段防止别人伪造一个时间戳在未来的token提前生效。第三步是验iss和aud。iss必须等于你认证服务的标识aud必须等于当前API的标识。这一步很多人不写但一旦你的系统里有多个服务共享同一个认证中心一个为A服务签发的token被拿到B服务去用如果不校验audB服务会照样放行。这是很隐蔽的越权漏洞。Node.js中校验的示例const jwt require(jsonwebtoken); function authMiddleware(req, res, next) { const authHeader req.headers.authorization; if (!authHeader || !authHeader.startsWith(Bearer )) { return res.status(401).json({ code: UNAUTHORIZED, message: Missing token }); } const token authHeader.slice(7); try { const decoded jwt.verify(token, publicKey, { algorithms: [RS256], issuer: auth-service, audience: my-api }); req.user decoded; next(); } catch (err) { if (err.name TokenExpiredError) { return res.status(401).json({ code: TOKEN_EXPIRED, message: Access token expired }); } return res.status(401).json({ code: TOKEN_INVALID, message: Invalid token }); } }注意algorithms: [RS256]这个配置。不限制算法的话某些库会默认接受alg: none的token或者接受对称算法验签这里就是著名的算法混淆攻击入口后面会展开讲。4.2 RBAC怎么落地到接口上角色、权限与注解/装饰器先有权限模型再有代码实现。业界最通用的是RBACRole-Based Access Control用户、角色、权限三者的关系是用户拥有若干角色角色拥有若干权限。JWT里最省事的做法是只放角色名列表接口上标注所需的角色中间件判断当前用户角色是否命中。但在稍微复杂的业务里角色粒度往往不够。比如一个内容管理系统有的用户只能查看文章有的用户能编辑草稿有的用户能发布上线如果你给每个操作都建一个角色角色会爆炸。更合理的做法是定义权限点permission比如article:read、article:write、article:publish角色只是一组权限点的集合。服务端实现时可以在Controller方法上声明所需权限再用一个全局权限拦截器统一判断。以Node.js为例可以写一个简单装饰器或者路由元数据function requirePermission(permission) { return function (req, res, next) { const userPermissions req.user.permissions || []; if (!userPermissions.includes(permission)) { return res.status(403).json({ code: FORBIDDEN, message: Permission denied }); } next(); }; } app.post(/api/articles, authMiddleware, requirePermission(article:create), (req, res) { // 创建文章 });如果你用的是Java Spring Boot对应的就是Spring Security的PreAuthorize(hasAuthority(article:create))Kotlin Ktor有Intercept路由Python FastAPI有Depends。语言和框架不重要重要的是把认证和授权拆成两层中间件让接口只关心业务逻辑和权限声明。4.3 数据权限token里的角色解决不了的问题角色和权限点解决的是能不能调用这个接口但很多时候还需要回答能操作哪些数据。比如一个销售能看到自己名下的客户但看不到别人的客户一个部门经理能看到整个部门的客户但看不到其他部门的。这种场景不能只靠token来判断必须在业务层用当前登录用户的ID、所属部门ID去过滤数据。我常用的做法是在认证中间件把req.user.sub用户ID、req.user.orgId这类上下文塞进去业务代码在查询时把数据归属条件拼进去。比如一个订单详情接口接口需要保证用户只能看自己的订单SQL里就必须带上WHERE order.owner_id ?。有人把这整个思路归为数据权限但它本质上是业务规则的一部分JWT只负责把身份准确传递到服务端后续的判断必须由业务代码完整执行。5. 生产环境里JWT最容易翻车的几个坑我的排查复盘5.1 时钟偏移导致token提前过期或还未生效有一次我线上突然出现一批401持续了几分钟又自动恢复。排查日志发现报错是jwt not active说明token的nbf还没到生效时间。但签发端的服务器明明刚签发完token怎么会还未生效原因出在多台服务器的系统时间不一致。签发token的服务器时间比校验token的服务器快了十几秒导致token签发出来后在另一台服务器看来它的iat和nbf都指向未来。反过来如果签发服务器时间慢了token会在到期时间之前就被校验端判为过期。解决方案有三个层面第一层所有服务器统一使用NTP时间同步这是必做项第二层JWT库一般允许设置clockTolerance比如Node.js的jsonwebtoken可以传clockTolerance: 30允许30秒的时钟偏差第三层在签发时适当把nbf设为当前时间不要主动设未来的时间避免不必要的未生效误判。5.2 算法混淆攻击与jwt在线解析工具的陷阱这是JWT里最经典的攻击方式之一攻击者把token的Header里alg改成none或HS256然后用各种手段绕过验签。alg: none意味着攻击者声明这个token不需要签名如果服务端没有限制算法列表且库版本较老攻击者完全可以自己伪造一个管理员身份的token。解决办法就是我在前面说的验签时显式指定algorithms: [RS256]不要用默认配置。HS256混淆攻击更隐蔽。如果签发端用的是RS256私钥签名、公钥验签而校验端没有限死算法攻击者可以把算法改成HS256然后拿公钥当对称密钥来签名。因为公钥本身是公开的攻击者等于拿到了签名密钥。很多老旧的jwt库如果同时支持非对称和对称算法又没有算法白名单就会中招。这里提醒一句网上很多jwt在线解析工具只负责解码别把token原样贴在第三方网站上毕竟token本身就是身份凭证哪怕只是解析也有被记录的风险。真要调试优先用本地工具或自己写脚本。5.3 登出失效、token续签与黑名单机制用户点了退出登录但服务器拿他没办法——这是无状态JWT天生的痛点。要真正实现登出即失效必须引入状态也就是黑名单机制。我的做法是在Redis里维护一个已撤销jti列表key用jtivalue存这个jti对应的过期时间TTL和token剩余有效期保持一致。用户登出时把jti加进去每次校验token时验完签名和有效期之后再查一下jti是否在黑名单里如果命中401。同理用户修改密码、忘记密码、账号被管理员禁用时也需要把该用户的所有历史token全部加入黑名单。如果按jti一个个加太麻烦可以维护一个用户token版本号在Redis里存user_token_version:{userId} 1JWT payload里带ver: 1校验时对比Redis里的版本号不相等就拒绝。这个方案比黑名单更粗粒度但在让用户重新登录这个场景下非常高效。5.4 密钥是怎么泄露的我见过的最常见路径密钥泄露往往不是从外部攻进来的而是从内部出去的。我见过最普遍的问题把私钥文件、secret字符串直接提交到了Git仓库哪怕后来删了提交历史里还留着。也见过把密钥写在环境变量文件里然后整个.env文件被同步到了在线文档或聊天群。关于密钥管理我从自己的经验出发列几条硬要求私钥和对称密钥绝不进代码仓库开发环境用本地环境变量生产环境用密钥管理服务如Vault、KMS或云厂商的Secret Manager。密钥定期轮换轮换时使用kidKey ID标注当前token使用哪把密钥验签这样新旧密钥可以并行过渡不会造成大面积401。JWT库的日志里不要打印token原文和密钥指纹避免日志系统泄露。如果是RS256公钥确实可以公开但私钥的访问权限要严格控制到具体服务和具体人。最后分享几个我现在还在用的小技巧关于JWT方案我想用自己的实际操作经验收个尾。第一个响应头里加一层Cache-Control: no-store。有些场景下API响应会被浏览器或中间代理缓存如果响应里包含用户相关数据又被缓存到公共环境那等同于是间接泄露用户信息。JWT保护的API不该被下游随便缓存我习惯在认证中间件里统一加上这个响应头。第二个登录接口务必做限流和审计。JWT本身不限制暴力尝试如果登录接口不做频控攻击者可以无限尝试撞库。现在我的登录接口都会配合设备指纹、验证码和IP维度限流同时把每次登录的jti、签发时间、IP记录到审计日志方便事后排查。第三个针对管理后台我会把Access Token的有效期压到5分钟并把Refresh Token改为每次刷新后轮换。轮换的意思是每次刷新时旧Refresh Token立即失效换一个新的Refresh Token这样即使Refresh Token被泄露攻击者也只能用一次还没来得及做坏事就失效了。第四个是测试技巧本地调试token相关逻辑时我会写一个小的解密脚本用命令行把Header和Payload拆开看避免把线上token贴到公网解析工具里。这个习惯帮我避免了好几次为了图方便差点泄露生产token的风险。JWT不是银弹它最大的价值是让认证状态在分布式环境里可以被验签和信任但代价是必须自己管理好过期、撤销和密钥。把上面的每个环节都落到实处你的API才算真正用JWT保护起来了。