JWT 经常被当作登录认证的标准答案:服务端签发一个 Token,客户端保存它,之后每次请求都带上它。服务端不需要保存 Session,看起来简单又高效。
但 JWT 并不会自动让认证系统变得安全。它只是一个带签名的数据格式,真正的安全性取决于服务端如何生成、验证、保存和撤销 Token。实际项目中的 JWT 漏洞,大多不是加密算法被攻破,而是使用方式出了问题。

图 1:JWT 的 Header、Payload 和 Signature 共同组成一个令牌,但三者承担的安全职责不同。
JWT 解决了什么问题
传统 Session 认证通常需要服务端保存一份会话记录。用户登录后,服务端生成 Session ID,浏览器保存这个 ID;之后每次请求,服务端都根据 ID 查询用户身份和会话状态。
JWT 则把一部分身份信息放进 Token 中,并用签名保证内容没有被修改。服务端拿到 Token 后,可以在本地完成校验,不一定需要查询 Session 存储。这在分布式服务、跨服务调用和无状态 API 中比较方便。
但这种便利是有代价的:
- Token 一旦泄露,攻击者可以在有效期内直接重放。
- Token 签发后,服务端不一定能立即撤销它。
- Payload 中的信息虽然不能被篡改,但默认是可以被读取的。
- 认证安全从服务端存储迁移到了密钥管理、校验逻辑和客户端存储上。
所以,JWT 更准确的定位是“签名令牌格式”,而不是一套完整的登录安全方案。
JWT 的三部分
一个 JWT 通常由三段组成:
Header.Payload.Signature
Header 用来描述类型和签名算法,Payload 存放声明信息,Signature 用来证明前两部分没有被篡改。
Header
Header 用来描述 Token 类型和签名算法,例如:
{
"alg": "HS256",
"typ": "JWT"
}
Payload
Payload 保存声明信息,常见字段包括:
{
"sub": "user_42",
"iss": "https://api.example.com",
"aud": "web-client",
"iat": 1780000000,
"exp": 1780000900
}
sub 通常表示用户或主体,iss 表示签发者,aud 表示使用对象,iat 表示签发时间,exp 表示过期时间。
需要特别注意的是,Header 和 Payload 只是 Base64URL 编码,不是加密。任何拿到 JWT 的人都可以解码并读取其中的内容。因此,密码、身份证号、银行卡号等敏感信息都不应该放进 Payload。
Signature
Signature 用来证明 Header 和 Payload 在签发后没有被修改。以 HMAC 为例,签名大致可以理解为:
HMAC(Header.Payload, SecretKey)
签名能保证完整性,但不能解决 Token 泄露、重放和注销问题。拿到一个合法签名 Token 的攻击者,不需要修改它,也可以直接使用。
1. 信任 Token 中的算法声明
JWT 的 Header 中有一个 alg 字段,用来声明签名算法。危险的实现会直接相信客户端传来的算法,例如服务端本来要求使用 HMAC 签名,却允许客户端把算法改成 none,或者在不同算法之间自动切换。
这种问题的本质是:服务端让 Token 自己决定应该如何验证自己。
正确的做法是由服务端配置允许的算法,并拒绝其他算法:
const { payload } = await jwtVerify(token, secret, {
algorithms: ['HS256'],
issuer: 'https://api.example.com',
audience: 'web-client'
});
这里的算法白名单、签发者和受众都由服务端固定,而不是从 Token 中读取后再决定。
验证失败时应该直接返回未授权,不应该尝试使用 Payload 中的用户信息继续执行。还需要避免把详细的验签异常直接返回给客户端,否则可能泄露内部算法、密钥或配置线索。
2. 算法混淆:HS256 和 RS256 用错密钥
HS256 是对称签名,签名和验证使用同一个密钥;RS256 是非对称签名,服务端使用私钥签名,使用公钥验证。
如果验证逻辑同时支持两种算法,并且错误地把 RSA 公钥当成 HMAC 密钥使用,就可能产生算法混淆问题。攻击者可以构造一个使用 HS256 的 Token,让服务端把公开的 RSA 公钥当成密钥来验证。
防御时不要只判断“签名是否有效”,还要同时绑定:
- 允许使用的算法
- 对应的密钥类型
- 密钥的来源
- Token 的签发场景
如果项目只需要一种算法,就只开放这一种。不要为了“兼容各种 Token”而放宽验证条件。
这个问题在微服务中尤其容易出现:认证服务使用 RS256 签发,业务服务为了复用一套中间件又开放了 HS256。每个服务应该明确自己接受哪些签发方和算法,不能把“能解析”当成“应该接受”。
3. 使用弱密钥,Token 可以被离线猜解
HS256 的安全性高度依赖密钥强度。下面这种配置看似能用,实际上非常危险:
const secret = '123456';
JWT 的签名验证可以在本地进行,不需要不断请求服务端。因此,一旦 Token 被泄露,攻击者可以拿着它离线尝试常见密码、字典词和历史密钥。
生产环境的密钥应该使用密码学安全的随机值,并放在密钥管理系统或环境变量中。密钥还需要支持轮换,不能因为上线后一直没报错就永久不变。
如果使用非对称算法,私钥应该只保存在签发服务中,其他服务只拿到验证所需的公钥。这样可以降低某个业务服务被攻破后伪造全站 Token 的风险。
4. 只解码,不验证签名
很多 JWT 库同时提供 decode 和 verify 两类方法。decode 的作用只是读取 Token 内容,它不会证明内容可信。
下面的逻辑就是一个典型错误:
const payload = decode(token);
const user = await findUser(payload.userId);
只要攻击者修改 Payload 中的 userId,业务代码就可能把它当成真实身份。正确流程应该是先验证签名和声明,再使用验证结果:
const { payload } = await jwtVerify(token, secret, {
algorithms: ['HS256']
});
const user = await findUser(payload.sub);
验证失败时必须立即拒绝请求,不能继续使用解析出来的 Payload。
一个值得建立的代码规范是:业务层永远不直接接收原始 JWT,也不自行调用 decode。由认证中间件完成验证后,只向业务层传递经过校验的最小身份对象,例如 userId、tenantId 和必要的权限上下文。

图 2:错误的验证流程会把“可读取的数据”误当成“可信的身份”。
5. 忽略 exp,让 Token 永不过期
签名有效只能说明 Token 没有被篡改,不能说明它现在仍然应该有效。
如果服务端不检查 exp,一个被盗取的 Token 可能永久有效。即使设置了过期时间,也应该合理校验 nbf、iat、iss 和 aud 等声明,避免 Token 被拿到错误的系统或错误的时间窗口中使用。
比较稳妥的做法是:Access Token 设置较短的有效期,Refresh Token 设置更长的有效期,并对 Refresh Token 做轮换和复用检测。不要为了省事把 Access Token 设置成 30 天甚至更久。
6. Token 泄露,往往比签名破解更常见
JWT 一旦被窃取,攻击者通常不需要破解签名,直接在有效期内重放即可。常见泄露位置包括:
- 放在 URL 查询参数中
- 写入浏览器历史记录
- 被 Web 服务器、代理或分析平台记录
- 出现在前端错误日志中
- 保存在容易受 XSS 影响的
localStorage中 - 被复制到第三方脚本或不可信插件
如果采用 Cookie 保存认证信息,通常应至少配置:
Set-Cookie: access_token=...; HttpOnly; Secure; SameSite=Lax
HttpOnly 可以降低脚本直接读取 Cookie 的风险,Secure 要求只通过 HTTPS 传输,SameSite 可以降低部分跨站请求风险。但 Cookie 方案仍然需要考虑 CSRF,不能认为加上 HttpOnly 就解决了所有问题。
如果使用 localStorage,Token 不会自动随请求发送,因此可以减少部分 CSRF 风险,但页面中的任意 XSS 脚本都可能读取它。两种存储方式没有绝对答案,关键是根据应用的攻击面选择方案,并配合内容安全策略、输入输出编码、CSRF 防护和严格的第三方脚本管理。
还要区分“传输安全”和“存储安全”:HTTPS 只能保护传输链路,不能阻止 Token 被浏览器扩展读取,也不能阻止服务器端日志误记录,更不能让一个已经泄露的 Token 自动失效。
7. JWT 很难真正“注销”
传统 Session 可以通过删除服务端记录立即失效,而 JWT 常常是自包含的。Token 签发后,即使用户退出登录,服务端也可能无法立刻识别它已经被注销。
这也是 JWT 的一个设计取舍:减少服务端状态,同时增加撤销难度。
常见解决方案包括:
- 缩短 Access Token 的有效期。
- 使用 Refresh Token 获取新的 Access Token。
- Refresh Token 每次使用后轮换,并检测旧 Token 的重复使用。
- 对高风险操作额外要求重新认证。
- 在必要场景中维护 Token 黑名单或用户 Token 版本号。
不要把“删除浏览器里的 Token”误认为服务端注销。只要 Token 仍未过期,攻击者手里的副本通常仍然可以使用。
对于后台管理、支付、修改密码等高风险操作,最好不要只依赖一个长期有效的 JWT。可以要求重新输入密码、完成 MFA,或者检查最近一次认证时间。认证强度应该和操作风险匹配。
8. Refresh Token 也需要安全设计
很多系统把 Access Token 过期时间设置得很长,只是为了避免实现 Refresh Token。这会让一次泄露造成更长时间的影响。
更合理的方式是把两类 Token 分工:
- Access Token:有效期短,用于访问 API。
- Refresh Token:有效期较长,只用于换取新的 Access Token。
Refresh Token 不应该每次都永久有效。一次刷新成功后,服务端可以签发新的 Refresh Token,并让旧 Token 立即失效。如果旧 Refresh Token 又被使用,服务端可以认为它可能已经泄露,并撤销这一登录设备或整个会话链。

图 3:短时 Access Token、Refresh Token 轮换和服务端撤销共同降低长期风险。
9. kid、jku 和 x5u 的密钥加载风险
JWT Header 中的一些字段可以帮助服务端选择密钥,例如 kid。但如果服务端直接使用用户提供的值拼接文件路径、查询数据库,甚至请求一个用户提供的 URL,就可能引入路径穿越、密钥替换或 SSRF 风险。
例如,下面这种思路就需要非常谨慎:
const keyUrl = header.jku;
const publicKey = await fetch(keyUrl);
密钥地址应该来自服务端预置的可信配置,kid 也应该只允许匹配固定的密钥标识。不要让 Token 自己决定服务端应该去哪里下载验证密钥。
如果系统使用 JWKS,可以配置固定的 JWKS 地址、限制允许的域名、缓存公钥,并对密钥轮换过程做审计。密钥加载失败时应该拒绝请求,而不是退回到一个默认密钥或跳过验签。
10. 签名通过不代表权限正确
JWT 解决的是“这个 Token 是否由可信方签发,以及内容是否被修改”,但它不自动解决业务授权问题。
下面的逻辑仍然可能有权限漏洞:
if (payload.role === 'admin') {
return deleteResource(resourceId);
}
即使 role 来自一个合法签名 Token,也要确认:
- 当前角色是否仍然有效
- 用户是否属于当前组织或租户
- 用户是否有权访问这个具体资源
- 资源是否处于允许操作的状态
- 该操作是否需要二次认证
身份认证回答的是“你是谁”,权限控制回答的是“你能做什么”。不能因为使用了 JWT,就把所有授权判断都简化成读取几个 Payload 字段。
11. 一个相对稳妥的验证示例
下面使用 Node.js 的 jose 展示一个验证思路。示例重点不在于复制粘贴,而在于验证边界应该集中、明确,并且失败后立即结束请求。
import { jwtVerify } from 'jose';
const secret = new TextEncoder().encode(process.env.JWT_SECRET);
export async function authenticate(request) {
const authorization = request.headers.get('authorization') || '';
const [scheme, token] = authorization.split(' ');
if (scheme !== 'Bearer' || !token) {
throw new Error('missing credentials');
}
const { payload } = await jwtVerify(token, secret, {
algorithms: ['HS256'],
issuer: 'https://api.example.com',
audience: 'web-client',
maxTokenAge: '15 minutes'
});
if (typeof payload.sub !== 'string' || payload.sub.length === 0) {
throw new Error('invalid subject');
}
return { userId: payload.sub };
}
生产代码还需要统一处理异常、隐藏详细错误信息、记录必要的安全审计日志,并避免把原始 Token 写入日志。对于 RS256、ES256 等非对称算法,应该使用正确的公钥验证,并把算法和密钥类型固定在服务端配置中。
这段代码还不能代替完整的授权流程。拿到 userId 之后,业务接口仍然需要检查资源归属、租户边界和具体操作权限。
12. JWT 和 Session 应该怎么选
JWT 并不一定比 Session 更先进。选择方案时,可以先回答一个问题:系统是否真的需要让多个服务在不共享 Session 的情况下验证同一个身份?如果答案是否定的,传统 Session 往往更容易实现注销、封禁和权限即时变更。
更适合使用 JWT 的场景
- 多个服务需要验证同一个身份令牌。
- 服务之间希望减少中心化 Session 查询。
- API 需要跨域或跨系统传递短时身份信息。
- 团队能够认真维护密钥轮换和 Token 撤销机制。
Session 可能更合适的场景
- 主要是单体 Web 应用。
- 系统需要频繁注销、封禁和即时变更权限。
- 团队不希望在客户端处理长期身份令牌。
- 服务端已经有可靠的 Redis 或数据库会话存储。
Session 需要保护好 Session ID,JWT 需要保护好签名密钥和 Token 生命周期。两者都不是“配置一次就结束”的安全功能。
13. 如何测试 JWT 认证是否可靠
安全测试不应该只测试“正常 Token 能否登录”,还要测试各种异常状态是否会被拒绝。可以在测试环境中覆盖以下场景:
- 修改 Payload 后,服务端是否拒绝请求。
- 修改 Header 中的算法后,服务端是否拒绝请求。
- 删除签名或截断 Token 后,服务端是否拒绝请求。
- 使用已经过期的 Token 时,服务端是否拒绝请求。
- 使用其他系统签发的 Token 时,
iss和aud校验是否生效。 - 使用其他用户的合法 Token 访问资源时,资源级授权是否生效。
- 注销后继续使用旧 Refresh Token 时,服务端是否拒绝请求。
- Refresh Token 重复使用时,是否触发会话撤销或安全告警。
- 缺失、重复或异常类型的声明字段是否会导致安全失败,而不是程序异常。
测试的重点不是构造一个“神奇的攻击字符串”,而是确认每一个认证失败分支都真正停止了后续业务处理。对于认证中间件,还应该增加日志审计,记录失败原因分类、来源 IP、客户端信息和关联会话,但绝不能记录完整 Token。
一份实用的检查清单
在项目上线前,可以检查以下问题:
- 是否固定了允许的签名算法?
- 是否严格区分了 HMAC 密钥和 RSA/ECDSA 密钥?
- 签名密钥是否足够随机,并且支持轮换?
- 代码使用的是
verify,还是仅仅decode? - 是否校验
exp、iss、aud和nbf? - Access Token 是否设置了合理的过期时间?
- Token 是否可能出现在 URL、日志或错误信息中?
- Refresh Token 是否轮换,并能检测重复使用?
- 用户退出登录或权限变化后,旧 Token 如何失效?
kid、jku、x5u等字段是否来自可信配置?
结语
JWT 不是不安全,也不是用了就安全。它适合解决部分无状态认证问题,但不适合被当成完整的身份安全方案。
真正可靠的 JWT 认证,需要同时做好算法限制、密钥管理、声明校验、传输保护、Token 存储和撤销机制。开发时最值得记住的一句话是:
JWT 的签名只能保证内容没有被篡改,不能保证 Token 没有泄露,也不能保证它现在仍然应该被接受。
在选择 JWT 之前,应该先明确系统是否真的需要无状态认证。如果服务端本来就需要维护登录状态、撤销列表和权限版本,那么传统 Session 也许更加简单、直接,安全边界也更容易理解。
JWT 真的安全吗?常见安全漏洞与防御实践
本文采用 CC BY-NC-SA 4.0 许可协议,转载请注明出处。
评论交流
欢迎留下你的想法