写在前面:这篇文章不是我第一次写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里有几个必须在签发时想清楚:sub(Subject,用户标识,必须是唯一且不可变的用户ID,不要用用户名,因为用户名可能改)、exp(过期时间)、iat(签发时间)、iss(签发者)、aud(接收方)、jti(JWT 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请求业务API,Access 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里,并且设置SameSite=Lax和Secure属性;Access Token可以放在内存变量里(比如一个模块级变量),每次刷新页面后内存清空,用Refresh Token重新换。这样做的好处是Access Token不落盘,XSS能拿到的机会小很多;坏处是刷新页面后要等待refresh接口返回新的Access Token,用户会感受到一次短暂的加载延迟。
如果你团队前端能力有限,必须用localStorage,那也请务必把重点放在防止XSS上:对用户输入做输出编码、上线CSP(Content-Security-Policy)、不引入不可信的第三方脚本。XSS一旦发生,localStorage里的token就是案板上的肉。
3.2 Authorization: Bearer的写法与axios拦截器
JWT的标准携带方式是在HTTP请求头里放Authorization: Bearer <token>。注意Bearer这个前缀不能省,很多API网关和中间件都依赖这个前缀来识别token格式。
使用axios时,常见的做法是在请求拦截器里统一加上token:
import 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的威胁对比,以及防护思路
我把这两种攻击放在一张表里对比,这样最直观:
| 攻击类型 | 针对的存储方式 | 攻击原理 | 核心防护手段 |
|---|---|---|---|
| XSS | localStorage、sessionStorage、内存变量 | 在页面注入脚本,直接读取token | 输出编码、CSP、不信任第三方脚本 |
| CSRF | Cookie | 浏览器自动携带Cookie,诱导用户发起请求 | SameSite、CSRF Token、校验Origin/Referer |
如果你的token放在Authorization Header里,CSRF天然免疫,因为跨站请求无法自定义Header;如果放在Cookie里,就要认真处理CSRF。SameSite=Lax能挡掉大部分跨站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服务去用,如果不校验aud,B服务会照样放行。这是很隐蔽的越权漏洞。
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怎么落地到接口上:角色、权限与注解/装饰器
先有权限模型,再有代码实现。业界最通用的是RBAC(Role-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用jti,value存这个jti对应的过期时间,TTL和token剩余有效期保持一致。用户登出时,把jti加进去;每次校验token时,验完签名和有效期之后,再查一下jti是否在黑名单里,如果命中,401。
同理,用户修改密码、忘记密码、账号被管理员禁用时,也需要把该用户的所有历史token全部加入黑名单。如果按jti一个个加太麻烦,可以维护一个"用户token版本号",在Redis里存user_token_version:{userId} = 1,JWT payload里带ver: 1,校验时对比Redis里的版本号,不相等就拒绝。这个方案比黑名单更粗粒度,但在"让用户重新登录"这个场景下非常高效。
5.4 密钥是怎么泄露的:我见过的最常见路径
密钥泄露往往不是从外部攻进来的,而是从内部出去的。我见过最普遍的问题:把私钥文件、secret字符串直接提交到了Git仓库,哪怕后来删了,提交历史里还留着。也见过把密钥写在环境变量文件里,然后整个.env文件被同步到了在线文档或聊天群。
关于密钥管理,我从自己的经验出发列几条硬要求:
- 私钥和对称密钥绝不进代码仓库,开发环境用本地环境变量,生产环境用密钥管理服务(如Vault、KMS或云厂商的Secret Manager)。
- 密钥定期轮换,轮换时使用
kid(Key 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保护"起来了。