12 KiB
tags, create time
| tags | create time | |||||
|---|---|---|---|---|---|---|
|
2026-05-02 16:30 |
JWT — JSON Web Token
概述
JWT(JSON Web Token, RFC 7519)是一种开放标准,用于在各方之间安全地传输声明(claims)。在 Web 开发中,它最常被用作无状态身份认证的载体——服务器签发一个签名的令牌,客户端在后续请求中携带该令牌来证明自己的身份。
[!tip] 为什么选 JWT 而不是 Session? Session 需要服务端存储用户状态,在分布式或微服务架构中必须借助 Redis 等共享存储;JWT 将所有信息打包在令牌本身,服务端只需验签即可,天然适合横向扩展的无状态场景。
[!question] 思考 如果 JWT 把所有信息(包括敏感数据)都打包在令牌里,那和明文 Cookie 有什么区别? —— 关键差异在于 签名机制。JWT 附带数字签名,服务端可以验证令牌是否被篡改。但注意:默认情况下 Payload 只是 Base64 编码,并非加密!
核心结构
JWT 由三段 Base64Url 编码的字符串用 . 连接而成:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9. header (头部)
eyJpc3MiOiJleGFtcGxlLmNvbSIsInVzZXJJZCI6NDJ9. payload (载荷)
SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c signature (签名)
Header — 元信息
声明签名算法和令牌类型:
{
"alg": "HS256",
"typ": "JWT"
}
常用算法对比:
| 算法 | 底层机制 | 密钥类型 | 典型场景 |
|---|---|---|---|
HS256 / HS512 |
HMAC + SHA-256/512 | 对称(同一密钥签名+验签) | 小型项目、快速原型 |
RS256 |
RSA + SHA-256 | 非对称(私钥签名,公钥验签) | 生产环境、多服务验签 |
ES256 |
ECDSA + P-256 | 非对称(密钥更短,性能更好) | 移动端、IoT 资源受限场景 |
Payload — 声明数据
承载要传递的信息,分为三类标准字段和自定义字段:
{
"iss": "auth.example.com", // Issuer — 签发者
"iat": 1714627200, // Issued At — 签发时间(Unix 时间戳)
"exp": 1714713600, // Expiration — 过期时间
"sub": "user_42", // Subject — 主题(通常是用户 ID)
"role": "admin", // 自定义字段
"permissions": ["read", "write"] // 自定义字段
}
[!warning] 安全红线 Payload 绝对不要存放密码、身份证号等敏感信息! Base64 编码不等于加密,任何人拿到令牌都可以解码看到内容。如需保密,应使用 JWE(JWT 加密格式)或在服务端查库获取。
Signature — 签名验证
签名确保令牌在传输过程中未被篡改:
HMACSHA256(
base64UrlEncode(header) + "." + base64UrlEncode(payload),
secret
)
对于非对称算法(RS256),则使用私钥签名、公钥验签。这也是微服务架构中的推荐方案——只有鉴权服务持有私钥,其他服务用公钥验签即可。
flowchart LR
subgraph Sign["签名阶段"]
A[Header + Payload] --> B["HMAC(Raw string, Secret)"]
B --> C[Signature]
A -.-> D["最终令牌 = Header.Payload.Signature"]
end
subgraph Verify["验签阶段"]
E["收到的令牌"] --> F["提取前两段拼接"]
F --> G["HMAC(Raw string, Secret)"]
G --> H["本地签名"]
I["原始签名段"] --> J{"一致?"}
H --> J
J -->|"是"| K["令牌合法"]
J -->|"否"| L["被篡改"]
end
Sign --> |"下发完整令牌"| Client["Client"]
Client --> |"每次请求携带"| Verify
认证流程
典型的 JWT 认证过程涉及四个角色间的交互:
sequenceDiagram
participant C as Client
participant S as Server
participant DB as Database
C->>S: 1. POST /login (username + password)
S->>DB: 2. 校验凭据
DB-->>S: 3. 用户信息
alt 凭据正确
S->>S: 4. 生成 JWT (带 exp, iss, sub)
S-->>C: 5. 返回 { accessToken, expiresIn }
C->>S: 6. 后续请求带 Authorization: Bearer <token>
S->>S: 7. 验签 + 检查 exp
alt 令牌有效
S-->>C: 8. 业务数据
else 令牌无效
S-->>C: 9. 401 Unauthorized
end
else 凭据错误
S-->>C: 10. 401 Unauthorized
end
[!note] Access Token vs Refresh Token 实践中通常采用双令牌策略:
- Access Token:生命周期短(如 15 分钟),放在请求头中,用于访问 API
- Refresh Token:生命周期长(如 7 天),存储在 HTTP-only Cookie 中,用于换取新的 Access Token
这样即使 Access Token 泄露,影响范围也有限;而 Refresh Token 存放在 Cookie 中不受 XSS 直接窃取。
代码示例
Go:签发与验证 JWT
使用 golang-jwt/jwt/v5:
import "github.com/golang-jwt/jwt/v5"
// 签发令牌
func GenerateToken(userID uint, role string) (string, error) {
claims := jwt.MapClaims{
"sub": userID,
"role": role,
"iat": time.Now().Unix(),
"exp": time.Now().Add(15 * time.Minute).Unix(), // 15 分钟过期
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
return token.SignedString([]byte("your-secret-key"))
}
// 验证令牌
func ValidateToken(tokenString string) (*jwt.Token, error) {
return jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) {
// 确保算法符合预期,防止算法替换攻击
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
}
return []byte("your-secret-key"), nil
})
}
Gin 中间件:JWT 认证
结合之前 [[hzh/GIN/3-middleware/jwt-auth-qa]] 讨论的模式:
func JWTAuthRequired() gin.HandlerFunc {
return func(c *gin.Context) {
authHeader := c.GetHeader("Authorization")
if authHeader == "" {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "missing token"})
return
}
// 提取 Bearer prefix
parts := strings.Split(authHeader, " ")
if len(parts) != 2 || parts[0] != "Bearer" {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "invalid format"})
return
}
token, err := ValidateToken(parts[1])
if err != nil {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "invalid token"})
return
}
// 存入 Context,供下游 handler 使用
c.Set("userID", token.Claims.(jwt.MapClaims)["sub"])
c.Next()
}
}
React:前端使用 JWT
import axios from 'axios';
const api = axios.create({
baseURL: '/api',
});
// 自动在请求头注入 Token
api.interceptors.request.use((config) => {
const token = localStorage.getItem('accessToken');
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
});
// 统一处理 401,触发刷新或登出
api.interceptors.response.use(
(res) => res,
async (err) => {
if (err.response?.status === 401) {
// 尝试刷新 Token
const newToken = await refreshAccessToken();
if (newToken) {
localStorage.setItem('accessToken', newToken);
err.config.headers.Authorization = `Bearer ${newToken}`;
return api.request(err.config);
}
window.location.href = '/login'; // 重定向到登录页
}
return Promise.reject(err);
},
);
[!example] Token 存储方式对比
存储位置 XSS 防护 CSRF 防护 后端可读 适用场景 localStorage ❌ 易被 XSS 读取 ✅ 不自动发送 ❌ 需手动设置 SPA + 有 XSRF 防护 Memory ✅ 不被 XSS 直接读 ❌ — ❌ 需手动设置 高安全要求(刷新即丢失) HTTP-only Cookie ✅ 不被 JS 访问 ⚠️ 需额外防护 ✅ 自动发送 服务端渲染 / 传统 Web
常见安全问题与防御
1. 算法降级攻击(Alg: None)
攻击者将 alg 改为 none,试图绕过验签:
{"alg": "none", "typ": "JWT"}
防御:始终在白名单中指定允许的算法:
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, fmt.Errorf("unexpected signing method")
}
2. 密钥泄露与弱密钥
使用硬编码或弱密钥会让攻击者轻易伪造令牌。
防御:
- 密钥长度 ≥ 256 bit(HS256 要求至少 32 字节)
- 通过环境变量或密钥管理服务(Vault、AWS KMS)注入
- 定期轮换密钥
3. 过期时间设计不合理
过长的 exp 增加令牌泄露后的风险窗口;过短的 exp 又导致频繁刷新。
建议:
- Access Token:5 ~ 30 分钟
- Refresh Token:7 ~ 30 天
- 配合黑名单机制实现主动注销(见下文)
4. 重放攻击
攻击者截取合法的 JWT 令牌后反复使用。
防御:
- 短生命周期 Access Token
- 添加
jti(JWT ID)唯一标识,配合黑名单做一次性校验 - 在高价值操作中引入二次确认(如修改密码、支付)
进阶:Token 黑名单与撤销
JWT 的一个固有缺陷:一旦签发,无法主动作废(除非查询数据库)。在生产环境中,这意味著用户登出或密码修改后,已泄露的令牌仍可在过期前使用。
flowchart TD
subgraph Issue["签发"]
A["登录成功"] --> B["生成 JWT jti + exp + signed"]
B --> C["返回给客户端"]
end
subgraph Use["正常使用"]
D["请求携带 JWT"] --> E["验签 + 查黑名单"]
E -->|"未在册"| F["放行"]
E -->|"已在册"| G["拒绝"]
end
subgraph Revoke["撤销操作"]
H["用户登出或改密"] --> I["将jti写入黑名单Redis"]
I --> J["Set TTL = exp - iat"]
end
C --> D
H --> I
Go 实现黑名单校验的关键逻辑:
func Blacklisted(ctx context.Context, jti string) bool {
key := "jwt:blacklist:" + jti
exists, _ := redis.Get(ctx, key).Result()
return exists == "true"
}
func InvalidateToken(ctx context.Context, jti string, ttl time.Duration) {
redis.Set(ctx, "jwt:blacklist:"+jti, "true", ttl)
}
[!tip] 替代方案:短期 Token + 版本控制 为简化实现,可以给每个用户分配一个
tokenVersion(存在数据库中)。签发令牌时将版本号写入sub或自定义字段。用户登出时只递增版本号,所有旧版令牌在验签通过后对比数据库版本号即可识别为失效。无需维护黑名单集合。
Session vs JWT:选型决策
| 维度 | Session | JWT |
|---|---|---|
| 服务端存储 | 需要(内存/Redis) | 不需要(仅验签) |
| 实时注销 | ✅ 立即可效 | ❌ 需黑名单或短轮询 |
| 跨域支持 | 依赖 Cookie,需处理 CORS | 任意头,天然跨域 |
| 可扩展性 | 受限于共享存储 | 天然无状态,易于水平扩展 |
| 令牌体积 | 极小(仅 sessionId) | 较大(含 payload) |
| 客户端操控 | ❌ 不可见 | ✅ 可解码(但不可篡改) |
[!info] 如何选型?
- 单体应用 / 传统 MVC → Session(简单可靠,原生支持好)
- 微服务 / 前后端分离 / 移动端 → JWT(无状态,多服务共享密钥即可)
- 混合模式 → JWT 作为内部服务间通信凭证,外部入口仍可搭配 Session
关联笔记
- hzh/GIN/3-middleware/jwt-auth-qa — JWT 中间件中 Context 数据安全分析
- hzh/GIN/3-middleware — Gin 中间件完整机制
- hzh/DEV/跨域问题调试 — CORS 配置与跨域场景