JWT 经常被当作登录认证的标准答案:服务端签发一个 Token,客户端保存它,之后每次请求都带上它。服务端不需要保存 Session,看起来简单又高效。

但 JWT 并不会自动让认证系统变得安全。它只是一个带签名的数据格式,真正的安全性取决于服务端如何生成、验证、保存和撤销 Token。实际项目中的 JWT 漏洞,大多不是加密算法被攻破,而是使用方式出了问题。

1788101150864.png
图 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 用来描述 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 库同时提供 decodeverify 两类方法。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。由认证中间件完成验证后,只向业务层传递经过校验的最小身份对象,例如 userIdtenantId 和必要的权限上下文。
1788101182218.png
图 2:错误的验证流程会把“可读取的数据”误当成“可信的身份”。

5. 忽略 exp,让 Token 永不过期

签名有效只能说明 Token 没有被篡改,不能说明它现在仍然应该有效。

如果服务端不检查 exp,一个被盗取的 Token 可能永久有效。即使设置了过期时间,也应该合理校验 nbfiatissaud 等声明,避免 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 的一个设计取舍:减少服务端状态,同时增加撤销难度。

常见解决方案包括:

  1. 缩短 Access Token 的有效期。
  2. 使用 Refresh Token 获取新的 Access Token。
  3. Refresh Token 每次使用后轮换,并检测旧 Token 的重复使用。
  4. 对高风险操作额外要求重新认证。
  5. 在必要场景中维护 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 又被使用,服务端可以认为它可能已经泄露,并撤销这一登录设备或整个会话链。

1788101223523.png
图 3:短时 Access Token、Refresh Token 轮换和服务端撤销共同降低长期风险。

9. kidjkux5u 的密钥加载风险

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 时,issaud 校验是否生效。
  • 使用其他用户的合法 Token 访问资源时,资源级授权是否生效。
  • 注销后继续使用旧 Refresh Token 时,服务端是否拒绝请求。
  • Refresh Token 重复使用时,是否触发会话撤销或安全告警。
  • 缺失、重复或异常类型的声明字段是否会导致安全失败,而不是程序异常。

测试的重点不是构造一个“神奇的攻击字符串”,而是确认每一个认证失败分支都真正停止了后续业务处理。对于认证中间件,还应该增加日志审计,记录失败原因分类、来源 IP、客户端信息和关联会话,但绝不能记录完整 Token。

一份实用的检查清单

在项目上线前,可以检查以下问题:

  • 是否固定了允许的签名算法?
  • 是否严格区分了 HMAC 密钥和 RSA/ECDSA 密钥?
  • 签名密钥是否足够随机,并且支持轮换?
  • 代码使用的是 verify,还是仅仅 decode
  • 是否校验 expissaudnbf
  • Access Token 是否设置了合理的过期时间?
  • Token 是否可能出现在 URL、日志或错误信息中?
  • Refresh Token 是否轮换,并能检测重复使用?
  • 用户退出登录或权限变化后,旧 Token 如何失效?
  • kidjkux5u 等字段是否来自可信配置?

结语

JWT 不是不安全,也不是用了就安全。它适合解决部分无状态认证问题,但不适合被当成完整的身份安全方案。

真正可靠的 JWT 认证,需要同时做好算法限制、密钥管理、声明校验、传输保护、Token 存储和撤销机制。开发时最值得记住的一句话是:

JWT 的签名只能保证内容没有被篡改,不能保证 Token 没有泄露,也不能保证它现在仍然应该被接受。

在选择 JWT 之前,应该先明确系统是否真的需要无状态认证。如果服务端本来就需要维护登录状态、撤销列表和权限版本,那么传统 Session 也许更加简单、直接,安全边界也更容易理解。