JWT出现在几乎所有现代Web应用中——认证头、刷新流程、API访问控制。它们也是野外最常被误用的标准之一。如果你使用它们进行开发,了解令牌内部是什么、解码和验证实际意味着什么,以及哪些捷径会悄悄破坏你的安全性,这一点是不可或缺的。
JWT的结构
一个JWT由三个base64url编码的字符串用点连接而成:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ1c2VyXzEyMyIsImVtYWlsIjoiYWxpY2VAZXhhbXBsZS5jb20iLCJpYXQiOjE3MTI3NjQ4MDAsImV4cCI6MTcxMjg1MTIwMH0.4Xr8mNkZQWpH2TvL9uY3sKdJwFqBzEcAoMnRiVePxlU
- 标头 — 算法和令牌类型(
alg,typ) - 有效载荷 — 声明(你的应用实际需要的数据)
- 签名 — 对前两个部分的HMAC或RSA签名
解码后,有效载荷看起来像这样:
{
"sub": "user_123",
"email": "alice@example.com",
"iat": 1712764800,
"exp": 1712851200
}
这里没有任何内容是加密的。任何持有令牌的人都可以读取这些值。签名只证明令牌在签发后没有被篡改——它并不能隐藏内容。
解码不等于验证
这个区别让比本应如此多的开发者感到困惑。
解码 将令牌按点分割,并对每个部分进行base64url解码。不需要密钥——任何工具或单行代码都可以做到。如果你想在线解码jwt而无需安装任何东西,请将令牌粘贴到 IO Tools JWT Decoder 即可立即获取头部、有效载荷和过期时间的分解信息。
验证 使用密钥(或用于非对称算法的公钥)检查签名是否有效。它还会确认令牌是否未过期,以及声明是否与你的应用程序预期相符。跳过验证并信任解码后的令牌是发生身份验证绕过的方式。
这是一个使用Node.js验证JWT并处理常见边缘情况的代码片段:
import jwt from 'jsonwebtoken';
const SECRET = process.env.JWT_SECRET;
function verifyToken(token) {
try {
const payload = jwt.verify(token, SECRET, {
algorithms: ['HS256'], // whitelist — never allow 'none'
audience: 'myapp', // validate aud claim
});
// jwt.verify throws if exp is in the past, but be explicit:
if (payload.exp < Math.floor(Date.now() / 1000)) {
throw new Error('Token expired');
}
return payload;
} catch (err) {
// Never silently swallow verification failures
throw new Error(`Invalid token: ${err.message}`);
}
}
值得验证的声明
JWT规范定义了你的服务器应该主动检查,而不仅仅是读取的标准声明:
| 声明 | 意义 | 是否验证? |
|---|---|---|
exp | 过期时间戳 | 总是——过期的令牌是一个真实的攻击面 |
iat | 签发时间戳 | 可选,适用于最大年龄检查 |
sub | 主题(通常是用户ID) | 是——确认它是否与预期的用户匹配 |
aud | 预期受众 | 是——防止服务A的令牌用于服务B |
大多数JWT库都会 exp 自动验证——但前提是你必须配置它们。阅读你库的文档。不要假设它默认开启。
让开发者犯错的三个错误
1. 算法 alg: none 攻击
JWT规范允许的算法值是 none,这意味着没有签名。一些库——特别是较旧的库——接受了这一点,并完全跳过了签名验证。攻击者剥离签名,在头部设置 "alg": "none" ,并伪造任意声明。服务器信任它。
修复方法:验证时明确白名单算法。绝不能接受 none。上面的代码片段用此演示了 algorithms: ['HS256'].
2. 未验证过期时间
解码令牌而没有检查有效载荷,并信任其内容 exp 意味着几个月前签发的令牌仍然被接受。如果用户的会话被撤销或攻击者窃取了旧令牌,你的应用程序将永远不会知道。
修复方法:将过期时间视为强制性,而非可选。要检查特定令牌何时失效, IO Tools JWT Expiry Checker 解码 exp 声明,并准确地告诉你还剩多少时间——这对于无需编写代码即可调试刷新流程非常有用。
3. 将JWT存储在localStorage中
localStorage可以被页面上的任何JavaScript读取。单个XSS漏洞就意味着攻击者可以静默地窃取你的用户认证令牌。以下是存储选项的比较:
| 存储位置 | XSS风险 | CSRF风险 | 可从JS访问 | 笔记 |
|---|---|---|---|---|
| localStorage | 高的 | 没有任何 | 是的 | 不应用于认证令牌 |
| sessionStorage | 高的 | 没有任何 | 是的 | 与localStorage有相同的风险 |
| httpOnly cookie | 没有任何 | 中等的 | 不 | 最适合认证;与SameSite + CSRF令牌配对 |
| 内存中(JS var) | 低的 | 没有任何 | 是(相同上下文) | 刷新时丢失;适用于短期令牌 |
httpOnly cookie根本无法被JavaScript读取,这完全消除了XSS窃取向量。权衡是CSRF暴露,你需要用 SameSite=Strict 或CSRF令牌来处理。
JWT与会话:诚实的权衡
JWT是无状态的——服务器无需查询数据库即可验证它们。这在分布式系统中很有用,因为你不想让每个服务都访问共享的会话存储。
但无状态是有真实代价的:你不能在JWT过期之前撤销它。如果用户登出或被攻破,令牌将一直有效直到 exp。存在变通方法(令牌黑名单、短过期时间+刷新令牌),但它们增加了复杂性,并且通常重新创建了会话存储已经实现的功能。
何时使用JWT: 当你拥有多个独立认证请求的服务,你想将角色/权限直接嵌入令牌中,或者你的令牌是短期且可接受撤销延迟时。
何时使用会话: 当你需要立即撤销(登出必须真正起作用),你正在构建一个服务器渲染的应用,或者简单性超过了无状态的可扩展性时。
几秒内检查令牌
现在需要查看JWT内部是什么?终端:
echo "YOUR.JWT.HERE" | cut -d. -f2 | base64 -d 2>/dev/null | python3 -m json.tool
或者跳过终端——将令牌粘贴到 IO Tools JWT Decoder 以获取头部和有效载荷的格式化分解。这两种方法都没有验证签名;它们只是解码。要快速读取过期时间而无需编写代码,使用 JWT Expiry Checker 可以给出确切的时间戳和剩余时间。
