16 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)
"jti": "550e8400-e29b-41d4", // JWT ID — 唯一标识,用于防重放和黑名单
"role": "admin", // 自定义字段
"permissions": ["read", "write"] // 自定义字段
}
[!tip] Payload 到底存什么? 存的是身份标识 + 授权上下文——让服务端拿到令牌后能快速判断"你是谁、你能干什么",而不用每次都查库。典型内容:
类别 字段示例 说明 身份标识 sub,iss谁签发的、令牌属于谁 时间控制 iat,exp,nbf签发 / 过期 / 生效时间 授权信息 role,permissions角色与权限,减少查库次数 防重放 jti唯一标识,配合黑名单做一次性校验 业务元数据 tenantId,orgId多租户场景的轻量上下文 核心原则:存标识符和元数据,不是数据本身。密码、手机号、身份证号——通过
sub对应的用户 ID 去数据库查。
[!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?
理解 JWT 之前,有必要先搞清楚它要"替代"的对象——Session。
Session(会话)是一种有状态的认证机制。核心思路很朴素:客户端只存一把"钥匙"(sessionId),用户是谁、什么角色、登录了多久……所有状态全部存在服务端。
sequenceDiagram
participant C as Client
participant S as Server
participant Store as Session Store
C->>S: 1. POST /login (username + password)
S->>S: "2. 校验通过 生成 sessionId"
S->>Store: "3. 存储 sessionId 到用户数据的映射"
S-->>C: 4. Set-Cookie: sessionId=abc123
Note over C: "浏览器自动保存这个 Cookie"
C->>S: "5. 后续请求自动带 Cookie: sessionId=abc123"
S->>Store: "6. 查 Store: abc123 对应谁"
Store-->>S: "7. 返回用户信息"
S-->>C: "8. 返回业务数据"
[!question] Session 每次请求都要查 Store,性能会不会很差? 单次查询开销很小(尤其是 Redis 这类内存数据库,微秒级)。真正的瓶颈在于水平扩展——当服务器从 1 台扩到 10 台,每台都要能访问同一份 Session 数据,这就必须引入 Redis 等共享存储,架构复杂度随之上升。这也正是 JWT "无状态"优势凸显的地方。
三个关键特征:
| 特征 | 说明 |
|---|---|
| 客户端只存 ID | 浏览器通过 Cookie 自动携带 sessionId,它本身不含任何业务信息,只是一把钥匙 |
| 服务端存全部状态 | 用户身份、角色、权限等全部保存在服务端的 Session Store(内存 / Redis / 数据库) |
| 每次请求需查库 | 服务端收到 sessionId 后,必须去 Store 查询,才能识别"这个请求是谁发的" |
[!tip] 一句话类比 Session 像存包柜——你拿着号牌走人,东西存在柜子里;JWT 像护照——所有信息都写在证件本身。前者靠柜子(服务端存储)管状态,后者靠证件本身(令牌签名)证明身份。
Session vs JWT:选型决策
| 维度 | Session | JWT |
|---|---|---|
| 服务端存储 | 需要(内存/Redis) | 不需要(仅验签) |
| 实时注销 | ✅ 立即可效 | ❌ 需黑名单或短轮询 |
| 跨域支持 | 依赖 Cookie,需处理 CORS | 任意头,天然跨域 |
| 可扩展性 | 受限于共享存储 | 天然无状态,易于水平扩展 |
| 令牌体积 | 极小(仅 sessionId) | 较大(含 payload) |
| 客户端操控 | ❌ 不可见 | ✅ 可解码(但不可篡改) |
[!info] 如何选型?
- 单体应用 / 传统 MVC → Session(简单可靠,原生支持好)
- 微服务 / 前后端分离 / 移动端 → JWT(无状态,多服务共享密钥即可)
- 混合模式 → JWT 作为内部服务间通信凭证,外部入口仍可搭配 Session
JWT vs Cookie:常见误区
[!question] JWT 和 Cookie 是对立的吗? 不是!它们根本不在同一层——JWT 是令牌格式(数据结构),Cookie 是存储/传输机制(浏览器行为)。两者完全可以组合使用。
| 维度 | JWT | Cookie |
|---|---|---|
| 本质 | 令牌格式(数据载体) | 存储与传输机制(浏览器行为) |
| 存储位置 | localStorage / Memory / Cookie 均可 | 浏览器自动管理 |
| 自动发送 | ❌ 需手动设置 Authorization 头 |
✅ 浏览器每次请求自动携带 |
| 跨域 | ✅ 天然支持(手动设头即可) | ⚠️ 受 SameSite / Domain 策略限制 |
| XSS 防护 | 存 localStorage 时易被 JS 读取 | HTTP-only Cookie 无法被 JS 访问 |
| CSRF 防护 | ✅ 不自动发送,天然免疫 | ⚠️ 自动发送,需额外 CSRF Token |
| 大小限制 | 无硬限制(建议精简) | 单个约 4KB |
| 数据可见性 | Base64 可解码(但签名防篡改) | 明文存储(除非加密) |
flowchart LR
A["JWT 是什么?"] --> B["令牌格式 — 数据结构"]
C["Cookie 是什么?"] --> D["传输机制 — 浏览器行为"]
B --> E["存在哪里?"]
E --> F["localStorage"]
E --> G["Cookie (HTTP-only)"]
E --> H["Memory (JS 变量)"]
D --> I["自动随请求发送"]
I --> J["SameSite 限制"]
I --> K["Domain/Path 限制"]
style A fill:#e8f4f8
style C fill:#fde8e8
[!tip] 生产环境的常见组合策略
- SPA 前后端分离 → JWT 存 localStorage,手动注入
Authorization头- 需要兼顾安全 → JWT 存 HTTP-only Cookie,既防 XSS 又自动传输
- 最佳实践 → Access Token 用 JWT +
Authorization头(短期),Refresh Token 用 HTTP-only Cookie(长期)
关联笔记
- JWT vs Cookie — JWT 与 Cookie 认证方案全景对比
- hzh/GIN/3-middleware/jwt-auth-qa — JWT 中间件中 Context 数据安全分析
- hzh/GIN/3-middleware — Gin 中间件完整机制
- hzh/DEV/跨域问题调试 — CORS 配置与跨域场景