什么是 JWT?
JWT(JSON Web Token,RFC 7519)是一种开放标准,把一组声明(claims)打包成 JSON 对象,并用签名保证它在传输过程中没有被改过。注意这句话的边界:JWT 保证的是完整性,不是机密性。它让服务端不用查库也能确认「这个令牌是我签发的、内容没被动过」。
它最常见的用途是身份认证:用户登录后拿一枚 Access Token,之后每次请求带上它,服务端验签即可识别用户,无需会话存储。
它和传统 Session 的差异
| 维度 | Session + Cookie | JWT |
|---|---|---|
| 服务端状态 | 需要存 session(内存/Redis) | 无状态,令牌自包含 |
| 水平扩容 | 需要共享存储或粘性会话 | 直接加机器 |
| 跨域 / 跨服务 | Cookie 域限制麻烦 | 放在 Authorization 头,天然跨域 |
| 撤销能力 | 服务端删掉即失效 | 默认不可撤销,需额外设计 |
| 单次请求开销 | 一次存储查询 | 一次签名验算(通常更快) |
| 载荷大小 | 只有一个 session id | 承载全部声明,请求头更大 |
结论不是「谁更好」,而是:需要即时吊销、强管控的内部系统,Session 更省心;多服务、跨端、要横向扩容的 API,JWT 更合适。
手工拆一枚 JWT
一个 JWT 由三段 Base64Url 字符串用点号连接:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
把它丢进 JWT 解析器 会立刻还原成三段 JSON;想理解原理,可以用 Base64 转换器 手动解前两段。
第一段:Header
{ "alg": "HS256", "typ": "JWT" }
| 字段 | 含义 | 常见值 |
|---|---|---|
alg |
签名算法 | HS256 / RS256 / ES256 / EdDSA / none |
typ |
令牌类型 | JWT(嵌套场景可为 at+jwt) |
kid |
密钥 ID | 用于 JWKS 中定位公钥,多密钥轮换时必填 |
jku |
JWKS 地址 | 谨慎使用,见「攻击」一节 |
cty |
内容类型 | 嵌套 JWT(JWE 内嵌 JWS)时用到 |
第二段:Payload
Payload 里是一条条声明,分三类:
注册声明(RFC 7519 预定义,全部可选但强烈建议用)
| Claim | 含义 | 说明 |
|---|---|---|
iss |
Issuer,签发者 | 建议用完整 URL,如 https://auth.example.com |
sub |
Subject,主体 | 通常为用户 ID,全局唯一且永不复用 |
aud |
Audience,受众 | 可以是字符串或数组;校验方必须确认自己在其中 |
exp |
Expiration,过期时间 | Unix 秒级时间戳 |
nbf |
Not Before,生效时间 | 早于此刻的令牌应被拒绝 |
iat |
Issued At,签发时间 | 用于计算令牌年龄 |
jti |
JWT ID,唯一标识 | 防重放、做黑名单的关键字段 |
公共声明:需要避免命名冲突,应在 IANA 注册或用 URI 命名空间,例如 https://oltool.net/claims/role。
私有声明:收发双方自行约定,如 role、tenant_id。
⚠️ 时间戳单位是秒不是毫秒。用
Date.now()直接填进去会得到一个 50 年后的过期时间——这是线上最常见的低级错误之一。
第三段:Signature
签名覆盖前两段,计算公式(HS256):
HMACSHA256(
base64UrlEncode(header) + "." + base64UrlEncode(payload),
secret
)
RS256 则是 RSASSA-PKCS1-v1_5(SHA256, signingInput),ES256 是 ECDSA(P-256, SHA-256)。你会发现一个反直觉的点:签名算法不参与加密,它只是对「前两段的字节流」做一次摘要运算,再把结果 Base64Url 追加到后面。验签就是用同样的输入重算一遍,比对结果。
它不加密:Base64Url ≠ 保密
这是每个刚接触 JWT 的人都会踩的坑。Base64Url 只是把二进制安全地塞进 URL 的编码方式,任何解码器都能还原。想验证很简单:复制 Payload 段,用 Base64 转换器 解一下。
可以放:用户 ID、角色、租户、过期时间、权限范围(scope)、会话标识。
绝不能放:密码、密钥、身份证号、银行卡、完整手机号、内部系统拓扑。
确实需要保密时:用 JWE(RFC 7516)。JWE 是加密令牌,结构为五段,与 JWS 是两套标准。但绝大多数场景的正解不是上 JWE,而是别把敏感数据塞进令牌——令牌的使命是证明身份,不是当数据库用。
签名算法怎么选
| 算法 | 类型 | 密钥长度建议 | 签名长度 | 适用场景 |
|---|---|---|---|---|
HS256 |
对称 HMAC | ≥ 256 bit 随机 | 43 B | 单服务签发并校验 |
HS384 / HS512 |
对称 HMAC | ≥ 384 / 512 bit | 64 / 86 B | 合规要求更强摘要 |
RS256 |
RSA PKCS#1 | ≥ 2048 bit(建议 3072) | 342 B | 兼容性优先的分布式系统 |
PS256 |
RSA PSS | ≥ 2048 bit | 342 B | 需要概率签名、抗 padding 攻击 |
ES256 |
ECDSA P-256 | 256 bit | 86 B | 移动端 / 带宽敏感(更短更快) |
EdDSA |
Ed25519 | 256 bit | 86 B | 新系统首选,实现简单、无随机数陷阱 |
决策顺序:
- 签发方 == 校验方,且只有一个服务 →
HS256。 - 有多个校验方或第三方 →
RS256(兼容)或ES256(更短)。 - 全新系统、两端都可控 →
EdDSA,避开 ECDSA 的随机数复用风险。 - 涉及国密 / 合规要求 → 按规范选择
SM2。
想生成密钥对,可以用 RSA 密钥生成器;想理解 HMAC 与哈希的差异,HMAC 生成器 与 哈希工具 可以并排试用。
七步验证清单
验签不是「库说通过就算通过」。下面是生产环境必须走完的顺序:
- 结构检查:必须是三段,且每段都是合法 Base64Url。
- 算法白名单:从配置读取允许的
alg列表,不接受令牌自带的 alg 决定用哪个密钥。 - 定位密钥:按
kid取;没有kid时按约定的单一密钥取。 - 验签:用上一步确定的算法和密钥验算,先于任何声明解析。
- 时效:校验
exp(含 leeway)、nbf、iat是否在未来。 - 来源:校验
iss与aud是否匹配本服务。 - 业务:查
jti是否在黑名单、用户状态是否正常、权限是否包含所需 scope。
代码里最容易漏的是第 2 步。绝大多数 JWT 漏洞不是密码学被攻破,而是服务端「信任了令牌自己声明的算法」。
// ✅ 正确:算法写死在验证侧
jwt.verify(token, publicKey, { algorithms: ['RS256'], issuer, audience });
// ❌ 危险:让库按 token 的 alg 自行选择
jwt.verify(token, key); // 攻击者可换成 HS256 并用公钥当 HMAC 密钥
七种经典攻击与防御
| 攻击 | 原理 | 防御 |
|---|---|---|
alg: none |
去掉签名,服务端若不校验算法就放行 | 强制算法白名单,显式拒绝 none |
| 算法混淆 | RS256 服务被喂 HS256 令牌,攻击者用公开的公钥当 HMAC 密钥签名 | 验证侧固定算法 + 固定密钥类型 |
| 弱密钥爆破 | HS256 用了 secret / 123456 这类短密钥,可离线枚举 |
密钥 ≥ 256 bit 随机,定期轮换 |
JWKS / jku 欺骗 |
令牌里塞入攻击者控制的 jku,服务端去拉假公钥 |
忽略 jku/x5u,公钥只从本地配置或固定 JWKS 端点取 |
kid 注入 |
kid 被当作文件路径或 SQL 片段拼进查询 |
kid 只做字典查表,绝不拼进路径或 SQL |
| XSS 窃取 | 令牌存 localStorage,被注入脚本读走 | 见 FAQ 存储建议 + 严格 CSP |
| 重放 | 截获令牌后重复使用 | 短期 exp + jti 一次性校验 + HTTPS |
顺带一句:令牌泄露后的爆炸半径取决于有效期。把 Access Token 从 7 天改成 15 分钟,是投入产出比最高的一次安全改进。
生命周期与撤销
双令牌模型
| 令牌 | 有效期 | 存放 | 作用 |
|---|---|---|---|
| Access Token | 5–15 分钟 | 内存 / Authorization 头 | 访问业务 API |
| Refresh Token | 7–30 天 | HttpOnly Cookie,绑定 /refresh 路径 |
换发新 Access Token |
Refresh Token 必须轮换:每次换发都生成新的并作废旧的那枚。若检测到已作废的 Refresh Token 再次被使用,说明令牌已泄露——立即吊销整条令牌链并强制重新登录。
三种撤销策略对比
| 策略 | 实现 | 生效延迟 | 代价 |
|---|---|---|---|
| 缩短有效期 | 改配置 | ≤ Access Token 寿命 | 刷新请求变多 |
黑名单(jti) |
Redis 存未过期的已撤销 id | 即时 | 重新引入状态;只存未过期的可自动过期 |
版本号(token_version) |
用户表存版本,Payload 带上,校验时比对 | 即时 | 每次校验一次查询(可缓存) |
需要「立即下线某人」的合规场景用黑名单;普通产品用「短有效期 + 版本号」即可。
相关状态码
令牌相关错误建议这样返回,配合 HTTP 状态码速查:
| 情况 | 状态码 | 响应头 |
|---|---|---|
| 令牌过期 | 401 |
WWW-Authenticate: Bearer error="invalid_token", error_description="The access token expired" |
| 签名无效 | 401 |
同上,日志里记录 jti 便于追踪 |
| 权限不足 | 403 |
不要返回 404,二者语义不同 |
| 刷新令牌失效 | 401 |
清除 Cookie,强制重新登录 |
各语言推荐库
| 语言 | 推荐库 | 备注 |
|---|---|---|
| Node.js | jose(首选)、jsonwebtoken |
jsonwebtoken 必须显式传 algorithms |
| Python | PyJWT、python-jose |
PyJWT 需自行校验 iss/aud |
| Java | nimbus-jose-jwt、jjwt |
Spring Security 内置 NimbusJwtDecoder |
| Go | golang-jwt/jwt/v5、lestrrat-go/jwx |
v5 起要求显式声明算法 |
| PHP | firebase/php-jwt、lcobucci/jwt |
后者 API 更现代 |
| Rust | jsonwebtoken |
注意时钟 leeway 配置 |
什么时候不该用 JWT
- 需要秒级吊销的管理后台——Session 更简单也更安全。
- 把 JWT 当 Session 全量数据桶——Payload 每次请求都要传,塞太多会拖慢接口、撑爆请求头(部分网关限制 8 KB)。
- 用它替代加密传敏感数据——那是 JWE 或 TLS 的活。
- 单一单体应用的简单登录——引入的复杂度未必换来收益。
上线前自检
- [ ]
alg由服务端白名单决定,不接受令牌自带值 - [ ] 密钥 ≥ 256 bit 随机,且不进代码仓库
- [ ]
exp/nbf/iat使用秒级时间戳 - [ ] 校验了
iss与aud - [ ]
leeway设置在 30–60 秒 - [ ] Payload 中无任何敏感数据
- [ ] Access Token ≤ 15 分钟,Refresh Token 有轮换
- [ ] 忽略
jku/x5u,公钥来源固定 - [ ] 全站 HTTPS,令牌不出现在 URL 与日志里
- [ ] 有
jti,具备撤销能力
现在可以打开 JWT 解析器,把任意令牌粘进去看三段结构——整个过程在你的浏览器里完成,令牌不会上传到任何服务器。