简介:什么是 JSON Web Token?
JSON Web Token(JWT)是一项开放标准(RFC 7519),定义了一种紧凑、自包含的方式,用于以 JSON 对象的形式在各方之间安全传输信息。由于 JWT 经过数字签名,其内容可以被验证和信任。JWT 可使用密钥(HMAC 算法)或公私钥对(RSA 或 ECDSA)进行签名。
JWT 的诞生是为了解决分布式系统中的一个常见挑战:服务器如何在不查询会话数据库的情况下,信任来自客户端的请求?解决方案是将用户身份和权限直接编码并签名到一个令牌中,由客户端在每次请求时携带。
如今,JWT 已成为现代身份认证和授权的核心技术:
- 单点登录(SSO): 一次登录,访问多个服务。
- API 身份认证: 移动应用和单页应用(SPA)通过 Bearer Token 进行 API 调用认证。
- OAuth 2.0 / OpenID Connect: JWT 被用作访问令牌(Access Token)和身份令牌(ID Token)。
- 微服务架构: 各服务无需中央会话存储即可独立验证调用方身份。
JWT 结构:header.payload.signature
JWT 由三个 Base64url 编码的部分组成,以点(.)分隔:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
第一部分 — Header(头部) 解码后为:
{
"alg": "HS256",
"typ": "JWT"
}
头部指定了签名算法(alg)和令牌类型(typ)。
第二部分 — Payload(载荷) 解码后为:
{
"sub": "1234567890",
"name": "John Doe",
"iat": 1516239022
}
载荷包含声明(Claims)——关于实体(通常是用户)的陈述和附加元数据。
第三部分 — Signature(签名)(以 HS256 为例)计算方式为:
HMACSHA256(
base64UrlEncode(header) + "." + base64UrlEncode(payload),
secret
)
签名确保令牌未被篡改。重要提示: 载荷只是 Base64url 编码,而非加密。任何持有 JWT 的人都可以解码并读取所有声明。
签名算法:HS256、RS256 与 ES256
HS256(HMAC-SHA256)— 对称算法
使用同一个共享密钥进行签名和验证。实现简单,但签发方和验证方都必须知道该密钥。
- 适用场景: 单体应用或共享密钥的紧耦合服务。
- 风险: 密钥一旦泄露,所有令牌均可被伪造。
RS256(RSA-SHA256)— 非对称算法
使用私钥签名,公钥验证。私钥保密;公钥可通过 JWKS 端点公开共享。
- 适用场景: 分布式系统、微服务以及多服务验证场景。
- 密钥长度: 建议使用至少 2048 位的 RSA 密钥。
ES256(ECDSA-SHA256)— 非对称算法
使用 P-256 曲线的椭圆曲线数字签名算法(ECDSA)。在同等安全强度下,签名比 RS256 更小。
- 适用场景: 对性能敏感或带宽受限的环境。
| 算法 | 类型 | 密钥 | 签名大小 | 适用场景 |
|---|---|---|---|---|
| HS256 | 对称 | 共享密钥 | 32 字节 | 单服务应用 |
| RS256 | 非对称 | RSA 密钥对 | 256+ 字节 | 分布式系统 |
| ES256 | 非对称 | EC 密钥对 | 64 字节 | 高性能 API |
JWT 声明:已注册、公开与私有
声明是编码在载荷中的关于实体(通常是用户)的陈述和元数据。
已注册声明(IANA 标准定义)
| 声明 | 全称 | 描述 |
|---|---|---|
iss |
签发方 | 签发令牌的主体(如 "auth.example.com") |
sub |
主题 | 令牌所指向的主体(如用户 ID) |
aud |
受众 | 令牌的目标接收方(服务标识符) |
exp |
过期时间 | 令牌失效的 Unix 时间戳 |
nbf |
生效时间 | 令牌在此 Unix 时间戳之前不得使用 |
iat |
签发时间 | 令牌被签发的 Unix 时间戳 |
jti |
JWT 唯一 ID | 唯一标识符,用于防止重放攻击 |
公开声明
在 IANA JWT Claims Registry 中注册定义。示例:email、name、picture、roles。
私有声明
由签发方和消费方自行约定的自定义声明。建议使用命名空间避免冲突:
{
"https://example.com/tenant_id": "acme-corp",
"https://example.com/roles": ["admin", "editor"]
}
JWT 身份认证流程
- 用户登录: 客户端将凭据(用户名 + 密码)发送至认证服务器。
- 令牌签发: 服务器验证凭据后,创建包含相应声明的 JWT,用密钥/私钥签名后返回客户端。
- 令牌存储: 客户端将 JWT 存储在内存、localStorage 或 HttpOnly Cookie 中。
- 携带令牌请求: 客户端在每次访问受保护资源时,在
Authorization头中携带 JWT:Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9... - 令牌验证: 服务器提取令牌,验证签名,检查
exp,并根据声明授权或拒绝访问。 - 令牌刷新: 访问令牌过期后,客户端使用长效刷新令牌获取新的访问令牌,无需重新登录。
客户端 认证服务器 资源服务器
| | |
|-- POST /login ---->| |
|<-- 200 + JWT ------| |
| | |
|-- GET /api (Authorization: Bearer JWT) ->|
| | 验证 JWT |
|<----------- 200 + 数据 ----------------->|
安全注意事项
载荷并未加密
Base64url 编码不是加密! 任何持有 JWT 的人都可以立即解码并读取所有声明。绝对不要在 JWT 载荷中存储:
- 密码或密码哈希
- 银行卡号或其他财务数据
- 非必要的敏感个人信息(PII)
- 私钥或内部密钥信息
务必验证签名
// Node.js — jsonwebtoken 库
const jwt = require("jsonwebtoken");
try {
const decoded = jwt.verify(token, process.env.JWT_SECRET);
console.log(decoded.sub); // 可信的用户 ID
} catch (err) {
res.status(401).json({ error: "无效或已过期的令牌" });
}
设置较短的过期时间
访问令牌的有效期应较短(15 分钟至 1 小时)。使用刷新令牌维持长效会话,以降低令牌被盗的影响。
始终使用 HTTPS
永远不要通过未加密的连接传输令牌。被拦截的令牌等同于被盗的密码。
常见安全漏洞
1. 算法混淆攻击(CVE-2015-9235)
攻击者将头部的 alg 字段从 RS256 改为 HS256,然后用服务器的公钥作为 HMAC 密钥对令牌签名。如果服务器实现不当,会将公钥当作 HMAC 密钥接受该伪造令牌。
防御措施: 在服务端明确指定期望的算法:
jwt.verify(token, publicKey, { algorithms: ["RS256"] });
2. "none" 算法攻击
如果服务器接受 alg: "none",攻击者可以伪造不带签名的令牌。
防御措施: 明确拒绝 alg: "none" 的令牌。现代主流库已修复此问题,但旧版本仍可能存在漏洞。
3. 弱密钥(HS256)
使用短小或可猜测的 HMAC 密钥,攻击者可以通过 hashcat 等工具对捕获的令牌进行离线暴力破解。
防御措施: 使用至少 256 位的加密随机密钥:
openssl rand -hex 32
4. 缺少声明验证
未验证 iss、aud 或 nbf 可能导致跨服务令牌复用或令牌提前使用。
5. 不安全的令牌存储
localStorage 可被页面上的任意 JavaScript 访问,使令牌易受 XSS 攻击。建议使用 HttpOnly Cookie 存储令牌。
JWT vs 会话令牌:无状态 vs 有状态
| 特性 | JWT(无状态) | 会话令牌(有状态) |
|---|---|---|
| 存储位置 | 客户端 | 服务端(数据库或缓存) |
| 可扩展性 | 极佳 — 无需服务端查询 | 需要共享会话存储 |
| 撤销能力 | 困难 — 有效期内无法失效 | 简单 — 从存储中删除即可 |
| 令牌大小 | 每次请求携带较大数据 | 令牌小,数据在服务端 |
| 微服务支持 | 理想 — 各服务独立验证 | 需要共享会话基础设施 |
| 载荷可见性 | 持有令牌的人均可读取 | 数据仅存储在服务端 |
选择 JWT 的场景: 构建分布式系统、无状态 REST API 或移动应用。 选择会话的场景: 需要即时撤销能力(如"所有设备退出登录")时。
令牌撤销的挑战
由于 JWT 是自包含的,在令牌过期前无法轻易撤销,常见的解决方案有:
- 短期访问令牌 + 刷新令牌: 访问令牌快速过期;在数据库中撤销刷新令牌。
- 令牌黑名单: 在 Redis 等快速存储中维护已撤销的
jti集合(引入部分有状态性)。 - 密钥轮换: 轮换签名密钥使所有令牌失效(会影响所有在线用户)。
OAuth 2.0 与 OpenID Connect
OAuth 2.0
OAuth 2.0 是一个授权框架,不强制要求特定的令牌格式。JWT 因其自包含、携带授权 scope 等特性,被广泛用作 OAuth 访问令牌:
{
"iss": "https://auth.example.com",
"sub": "user_123",
"aud": "https://api.example.com",
"scope": "read:profile write:posts",
"exp": 1893456000
}
OpenID Connect(OIDC)
OpenID Connect 是构建在 OAuth 2.0 之上的身份层,引入了 ID Token 概念。ID Token 始终是一个 JWT,包含经过认证的用户的身份声明:
{
"iss": "https://accounts.google.com",
"sub": "110169484474386276334",
"aud": "my-client-id.apps.googleusercontent.com",
"email": "[email protected]",
"name": "Jane Doe",
"exp": 1893456000,
"iat": 1893452400
}
OIDC 还定义了 JWKS 端点(/.well-known/jwks.json),授权服务器在此发布其公钥。任何服务都可以获取这些公钥来验证令牌,无需预先交换密钥。
最佳实践
- 公开系统使用非对称算法(RS256/ES256) — 私钥安全保存,公钥可供任何人验证。
- 为访问令牌设置较短的
exp— 15 分钟是常见的安全默认值。 - 始终轮换刷新令牌 — 每次使用后签发新令牌,并使旧令牌失效。
- 验证所有相关声明 — 至少检查
iss、aud、exp和nbf。 - 明确指定期望的算法 — 永远不要仅依赖令牌头部的
alg字段。 - 载荷中不存储敏感数据 — 载荷是编码,不是加密。
- 始终使用 HTTPS — 永远不要通过未加密连接传输令牌。
- 安全存储令牌 — 优先使用
HttpOnly、Secure、SameSite=StrictCookie,而非 localStorage。 - 实施令牌撤销策略 — 使用黑名单或短效令牌配合刷新令牌轮换机制。
- 保持 JWT 库更新 — 新版本会修复已知安全漏洞。
常见问题解答
Q:可以解密 JWT 载荷吗? A:JWT 载荷是 Base64url 编码,不是加密——无需任何密钥即可读取。如果需要保密性,请使用 JWE(JSON Web Encryption,RFC 7516),它会对载荷进行加密。
Q:decode 和 verify 有什么区别?
A:decode 只是 Base64url 解码各部分并返回 JSON,不做任何安全检查;verify 还会根据您的密钥验证签名,并检查 exp 等时间声明。生产环境中务必使用 verify。
Q:如何安全地处理令牌刷新? A:签发短效访问令牌(15–60 分钟)和长效刷新令牌(数天至数周)。在服务端存储刷新令牌以便撤销。访问令牌过期后,客户端向专用端点发送刷新令牌以获取新的访问令牌。
Q:JWT 既可以用于认证也可以用于授权吗?
A:可以。在同一令牌中包含身份声明(用户是谁)和授权声明(用户能做什么,如 roles、scope)。注意控制令牌大小。
Q:如果 JWT 密钥泄露了怎么办? A:攻击者可以为任意用户伪造合法令牌。应立即轮换密钥(这会使所有现有令牌失效),调查泄露原因,并强制所有用户重新登录。
Q:应该选择 HS256 还是 RS256? A:当多个服务需要验证令牌,或验证方不完全可信时,使用 RS256(或 ES256)。仅当同一服务同时负责签发和验证令牌,且密钥可以严格管控时,才使用 HS256。
Q:JWT 最大可以有多大? A:建议将 JWT 控制在 4 KB 以内,以避免 HTTP 头部大小限制和性能问题。不要在载荷中嵌入大型数据结构,改为通过 ID 引用。