JWT 解码器

"alg=none" 攻击 — 为什么到 2026 年仍在坑害应用

JWT 规范(RFC 7519)在令牌头部定义了一个算法字段。有效值包括 HS256、RS256、ES256、EdDSA — 以及 "none"。"none" 算法表示令牌根本没有签名。最初的理由是"当 JWS 的完整性已经通过其他方式验证时很有用" — 也就是说,用于某种由其他层保证完整性的管道。而在实践中,这条脚注催生了现代身份认证中被引用最多的漏洞类别之一。 经典攻击是这样的:开发者集成了一个 JWT 库,验证函数写成 jwt.verify(token, secret) 之类的形式。当库收到头部为 { "alg": "none" } 的令牌时,它查看算法,发现没有签名需要检查,于是把载荷当作"已验证"返回。能够构造任意令牌的攻击者 — 比如篡改一个合法获得的令牌 — 只需把 alg 设为 none 并删除签名,就能以任何人的身份登录。CVE 列表每年都有新条目,因为这种攻击在许多语言和许多库中依然存活:CVE-2015-9235(jsonwebtoken)、CVE-2018-1000531(inversoft)、CVE-2019-7644(auth0/java-jwt)、CVE-2022-23529(又是 jsonwebtoken),一直延续到 2024 年。 第二种变体是"算法混淆"攻击。RS256(非对称)使用公钥验证签名。HS256(对称)使用共享密钥验证。如果服务器存储着自己的 RS256 公钥,而验证函数把 "key" 当作通用参数接收,攻击者就可以把头部切换为 alg: HS256,并把该公钥当作 HMAC 密钥来给令牌签名。服务器随后用同一个密钥进行"验证",验证成功,伪造令牌被接受。CVE-2016-10555、CVE-2019-20933 以及之后的许多问题正是这个模式。 防御手段既简单又容易被遗忘。永远不要信任令牌中的 alg 字段。验证方必须硬编码期望的算法 — 或至少使用严格的允许列表 — 并拒绝其他一切,包括(尤其是)"none"。正确的调用形如 jwt.verify(token, key, { algorithms: ['RS256'] }),而且你必须在代码中确认这个选项确实被传入了。2020 年前后开始,许多库默认采用安全的允许列表,但外面仍有大量遗留代码在构造验证器时没有传这个选项。 除了 alg,验证方还必须检查 exp(未过期)、nbf(已生效)、iss(签发者匹配)、aud(受众是你自己),以及任何你关心的 nonce 或 jti。JWT 只是签过名的 JSON;加密签名只能证明字节没有被改动,并不能证明这些字节对你的应用程序而言含义正确。
// VULNERABLE — accepts alg=none and algorithm confusion
const payload = jwt.verify(token, key);

// HARDENED — explicit allowlist + claim checks
const payload = jwt.verify(token, key, {
  algorithms: ['RS256'],     // never trust header alg
  issuer: 'https://api.example.com',
  audience: 'https://app.example.com',
  clockTolerance: 30,        // 30s skew, not minutes
});

// alg=none token an attacker would craft
header  = base64url('{"alg":"none","typ":"JWT"}')
payload = base64url('{"sub":"admin","exp":9999999999}')
signature = ''  // empty
token = `${header}.${payload}.`  // trailing dot, no sig

// Algorithm confusion: signing with the RS256 public key as HMAC key
const pub = fs.readFileSync('public.pem');
const forged = jwt.sign({ sub: 'admin' }, pub, { algorithm: 'HS256' });
// If the verifier doesn't pin algorithms, this passes.

正确实现刷新令牌 — 轮换、重用检测、滑动会话

访问令牌很短(5–15 分钟),属于 bearer 型,并且无需查询数据库即可验证。刷新令牌很长(数天到数周),用于换取新的访问令牌,而且与访问令牌不同,必须能够按需吊销。大多数基于 JWT 的系统悄然失败的地方,正是刷新流程的设计。 OAuth 2.1 草案和 IETF 的 "OAuth 2.0 Security Best Current Practice"(RFC 9700,2024)都强制要求刷新令牌轮换:每当客户端用刷新令牌 R 换取新的访问令牌时,服务器同时签发新的刷新令牌 R' 并使 R 失效。这种一次性使用的特性至关重要。没有它,被盗的刷新令牌会给攻击者无限期的访问权。有了轮换,窃取 R 的攻击者只能使用一次 — 而当合法用户的客户端尝试刷新的那一刻,服务器就会看到 R 的第二次使用,意识到出了问题,并可以吊销整个会话族。 这种"重用检测"正是轮换的杀手级特性。实现方式如下:为每个签发的刷新令牌同时存储一个刷新令牌族标识符(每个登录会话一个 UUID)。每次接受刷新时,把旧令牌标记为已使用。如果收到的刷新请求使用了已被标记为已使用的令牌,那意味着 (a) 合法客户端因网络故障在重放,或 (b) 有人偷走了其中一个令牌。无论哪种情况,安全的做法都是一样的:使该族中的所有刷新令牌失效,强制重新登录,并记录一条安全事件。Auth0、Stripe、Supabase 以及基本上所有主流身份认证提供商实现的正是这个模式。 滑动过期是第二个关键思路。不设置硬性期限("刷新令牌签发后 30 天过期"),而是同时设置最大空闲窗口("必须在上次使用后 14 天内使用")和绝对生命周期("无论如何,首次签发后 90 天过期")。这样你既能获得"只要我还在用这个应用就保持登录"的用户体验,又不会陷入刷新令牌存活数年的安全恐怖。 关于存储的一点实践说明:刷新令牌不应该是 JWT。JWT 是无状态且自包含的,这与需要即时吊销的东西的形态恰恰相反。刷新令牌应作为不透明的随机字符串(Base64URL 编码的 32 字节以上随机数)存储在服务器端数据库中并以其为键,同时保存族 ID、用户 ID、设备指纹、签发时间、最后使用时间和"已使用"标志。访问令牌可以继续用 JWT,因为它们足够短命,吊销的重要性较低;即时吊销最多也只需等待一个访问令牌的生命周期。如果你确实需要对访问令牌也做即时吊销,那就彻底抛弃 JWT,改用不透明会话 ID 并在每个请求上做服务器查询 — Stripe、GitHub 和大多数消费级应用实际上就是这么做的。
// Refresh endpoint with rotation + reuse detection
async function refresh(refreshToken) {
  const row = await db.refreshTokens.findOne({ token: refreshToken });
  if (!row) throw new Unauthorized();

  if (row.used) {
    // REPLAY: revoke the entire family
    await db.refreshTokens.updateMany(
      { familyId: row.familyId },
      { revoked: true }
    );
    auditLog('refresh-token-replay', { userId: row.userId });
    throw new Unauthorized('session compromised');
  }

  // Mark current as used + idle window check
  if (Date.now() - row.lastUsedAt > 14 * DAY) throw new Unauthorized();
  if (Date.now() - row.firstIssuedAt > 90 * DAY) throw new Unauthorized();

  await db.refreshTokens.update({ id: row.id }, { used: true });

  // Issue rotated pair
  const newAccess = signJWT({ sub: row.userId }, { expiresIn: '15m' });
  const newRefresh = base64url(crypto.randomBytes(32));
  await db.refreshTokens.insert({
    token: newRefresh,
    familyId: row.familyId,        // same family, new member
    userId: row.userId,
    used: false,
    firstIssuedAt: row.firstIssuedAt, // PRESERVE absolute lifetime
    lastUsedAt: Date.now(),
  });
  return { accessToken: newAccess, refreshToken: newRefresh };
}

JWE 与 JWS — 什么时候应该加密(而不只是签名)

你在实际环境中看到的大多数 "JWT" 都是 JWS — JSON Web Signature。载荷只是经过 Base64URL 编码而没有加密,所以任何持有令牌的人都可以对中间段做 base64 解码来读取每一个声明。如果声明是非敏感的("用户 12345 已登录,X 时过期"),这没问题;但如果声明中包含电子邮件地址、角色名称、你的业务在意的内部用户 ID、客户套餐等级,或任何你不希望竞争对手或监管审计员读到的信息,那就是错误的选择。 JWE — JSON Web Encryption — 包装了与 JWS 相同的思路,但同时也加密载荷。JWE 令牌有五个段而不是三个(头部、加密后的密钥、IV、密文、认证标签)。读取载荷需要解密密钥。如果你需要把包含敏感数据的令牌经由第三方传递(Webhook 接收方、重定向 URL、你并不完全信任的移动客户端),JWE 就是正确的工具。 判断规则很直接。问一句:这个令牌的持有者需要读取声明吗?如果需要,用 JWS — 持有者是你自己的后端或你自己的客户端,签名就是完整性保证。如果不需要 — 令牌要经过不应知晓其内容的代码,最终只有你的服务解密 — 用 JWE。一种常见的组合模式是"嵌套 JWT":先用 JWS 签名,再把它作为 JWE 的明文包裹起来,这样接收方同时获得加密(只有他们能读)和签名(他们可以验证签发者)。 2026 年的算法选择:JWS 方面,新系统优先选 EdDSA(Ed25519)— 最快、签名最小、没有曲线混淆的坑;ES256(ECDSA P-256)是兼容性广泛的备选。如果你被要求使用 RSA 的 PKI 基础设施绑住,RS256 也可以,但密钥巨大且签名缓慢。JWE 方面,对称场景使用带密钥包装的直接 AES-GCM(alg=A256KW + enc=A256GCM),非对称场景使用 ECDH-ES 配 AES-GCM。除非必需,否则避免 RSA-OAEP — 它很慢,密钥大小也令人痛苦。 最后一个提醒:加密不能替代"从一开始就不把敏感数据放进令牌"。令牌会被记入日志、被缓存、被复制粘贴、被截图。"声明应当是验证方所需的最小集合"这一原则对 JWE 和 JWS 同样适用。如果某个声明泄露后果太危险,最安全的存放位置是你的数据库,JWT 只携带一个不透明指针。
// JWS — payload is readable, just signed
header.payload.signature
^^^^^^^.^^^^^^^.^^^^^^^^^
3 segments, base64url decode the middle to read claims.

// JWE — payload is encrypted (5 segments)
header.encryptedKey.iv.ciphertext.tag

// Node 'jose' library
import { SignJWT, jwtVerify, EncryptJWT, jwtDecrypt } from 'jose';

// JWS sign with EdDSA (recommended new default)
const jws = await new SignJWT({ sub: '123' })
  .setProtectedHeader({ alg: 'EdDSA' })
  .setIssuedAt()
  .setExpirationTime('15m')
  .sign(privateKey);

// JWE encrypt with AES-GCM
const jwe = await new EncryptJWT({ ssn: '123-45-6789' })
  .setProtectedHeader({ alg: 'A256KW', enc: 'A256GCM' })
  .setExpirationTime('5m')
  .encrypt(symmetricKey);

// Nested: sign then encrypt
const inner = await new SignJWT({ sub: '123', email: '[email protected]' })
  .setProtectedHeader({ alg: 'EdDSA' }).sign(signingKey);
const outer = await new EncryptJWT({ jwt: inner })
  .setProtectedHeader({ alg: 'A256KW', enc: 'A256GCM' }).encrypt(encKey);
最后更新:

关于此工具

JWT(JSON Web Token)解码器把令牌拆分为头部、载荷、签名三部分,并把前两部分从 Base64URL JSON 解码,直接显示 sub、iat、exp 等标准声明和自定义字段。JWT 是 OAuth/OIDC 标准载体令牌,广泛出现在 Authorization 头、Cookie 和 API 响应中。

使用方法

  1. 将 JWT(由点分隔的 3 个 Base64URL 段)粘贴到输入框。
  2. 查看头部(算法、key id)和载荷(声明)面板。
  3. 如果 exp 已是过去时间,请留意过期警告。
  4. 检查自定义声明,确认作用域、租户 ID 或用户信息。
  5. 使用签名段在你的 JWKS 中查找 kid 以进行验证(本工具不做验证 — 请用你的库进行签名验证)。

常见用例

  • 调试 OAuth 流程时检查访问令牌的声明。
  • 确认 exp 和 iat 以排查"令牌已过期"错误。
  • 读取 scope 或 roles 以调试授权失败。
  • 验证在认证服务器上添加的自定义声明是否真正到达客户端。
  • 检查 OIDC id_token,查看暴露了哪些用户属性。
  • 在缺陷报告中分享脱敏后的 JWT 解码结果,以便与规范对照。

常见问题

Q. 解码和验证是一回事吗?

A. 不是。解码只是 Base64URL 到 JSON 的转换,任何人都能做。验证需要签名密钥(HS256 密钥或 RS256/ES256 公钥),用于确认令牌未被篡改。

Q. 我的 JWT 能被直接读出来,这是安全问题吗?

A. JWT 默认不加密。切勿把机密、明文密码或敏感个人信息放进 JWT 载荷。如果必须隐藏内容,请使用 JWE(加密 JWT)。

Q. alg=none 警告是什么意思?

A. 带 alg=none 的 JWT 没有签名。除非明确允许,服务器必须拒绝这类令牌;否则攻击者可以伪造任意载荷。

Q. 我的浏览器会把令牌发送到哪里?

A. 哪里都不会。此解码器完全在你浏览器内的 JavaScript 中运行,令牌不会离开页面。