外观
JWT 无状态鉴权踩坑实录:多实例下的续期与黑名单
问题背景:JWT 无状态为什么还需要状态
JWT 常被宣传为“服务端不保存会话”,非常适合多实例部署:请求携带 Token,任意实例只要持有同一套签名密钥,就能独立完成验签和授权。但在真实后端系统里,注销、续期、权限变更、Token 泄露处置都要求系统在过期前改变鉴权结果,这本质上又引入了状态。
典型问题有三个:
- Access Token 有效期过长,泄露后可被长期重放;有效期过短,用户又会频繁掉线。
- 用户点击退出后,未过期的 JWT 默认仍然有效,普通黑名单只放单机内存会在多实例间失效。
- Refresh Token 长期有效,如果没有轮换、复用检测和设备维度吊销,账号被盗后很难止血。
因此,实战方案不是追求“绝对无状态”,而是把无状态验签和最小必要状态结合:Access Token 短期有效,Refresh Token 长期保存并可轮换;注销和泄露应急通过 Redis 等共享存储形成快速失效通道。
方案对比:三种 JWT 鉴权模式怎么选
| 方案 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| 单 JWT,长期有效 | 实现最简单 | 泄露风险高,无法可靠注销 | 内网低风险工具 |
| 双 Token,Access 短期 + Refresh 长期 | 用户体验好,泄露窗口小 | 需要刷新接口和轮换逻辑 | 互联网 API、多实例服务 |
| JWT + 集中会话存储 | 注销和踢人最直接 | 每次鉴权依赖存储,接近有状态会话 | 高安全、强管控系统 |
推荐大多数业务采用第二种:Access Token 有效期设为 5 到 15 分钟,只保留用户 ID、角色、版本号等必要声明;Refresh Token 设为 7 到 30 天,以不透明随机字符串保存到 Redis,并在每次刷新时轮换。
实操步骤:Go 实现双 Token、续期与注销
下面示例使用 golang-jwt、Redis 和标准库,演示核心配置与关键实现。生产项目可把中间件接入网关或统一 HTTP 框架。
1. 定义 Token 配置与声明
Access Token 使用非对称签名更利于多服务验签;示例为便于运行采用 HMAC,生产环境应通过密钥管理系统加载密钥,避免硬编码。
go
package main
import (
"context"
"crypto/rand"
"encoding/hex"
"errors"
"fmt"
"net/http"
"time"
"github.com/golang-jwt/jwt/v5"\n "github.com/redis/go-redis/v9"
)
type AuthConfig struct {
JWTSecret []byte
AccessTTL time.Duration
RefreshTTL time.Duration
}
type Claims struct {
UserID string `json:"user_id"`
Role string `json:"role"`
TokenID string `json:"jti"`
jwt.RegisteredClaims
}
func NewAccessClaims(userID, role string, ttl time.Duration) *Claims {
now := time.Now()
jti := randomToken(16)
return &Claims{
UserID: userID,
Role: role,
TokenID: jti,
RegisteredClaims: jwt.RegisteredClaims{
Subject: userID,
ID: jti,
IssuedAt: jwt.NewNumericDate(now),
ExpiresAt: jwt.NewNumericDate(now.Add(ttl)),
Issuer: "developer-diary",
},
}
}
func (c *AuthConfig) SignAccess(userID, role string) (string, time.Time, error) {
claims := NewAccessClaims(userID, role, c.AccessTTL)
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
s, err := token.SignedString(c.JWTSecret)
return s, claims.ExpiresAt.Time, err
}
func randomToken(n int) string {
b := make([]byte, n)
_, _ = rand.Read(b)
return hex.EncodeToString(b)
}2. 登录时写入 Refresh Token
Refresh Token 不建议直接放业务权限,也不要把它再编码成一个可解析的 JWT。使用随机字符串即可,服务端保存哈希值、用户、设备、过期时间和轮换版本。
go
type RefreshStore struct {
rdb *redis.Client
cfg *AuthConfig
}
func (s *RefreshStore) Create(ctx context.Context, userID, device string) (string, error) {
refresh := randomToken(32)
key := "refresh:" + refresh
value := map[string]interface{}{
"user_id": userID,
"device": device,
"created_at": time.Now().Unix(),
"reused": 0,
}
// 只在首次登录时创建,过期由 Redis TTL 控制
if err := s.rdb.HSet(ctx, key, value).Err(); err != nil {
return "", err
}
return refresh, s.rdb.Expire(ctx, key, s.cfg.RefreshTTL).Err()
}3. 刷新接口:一次性轮换而不是无限续期
刷新时必须读取并删除旧 Refresh Token,再签发新的 Access Token 和 Refresh Token。为了降低并发刷新误杀,可给旧 Token 设置几十秒宽限,或在客户端加单飞锁;但不能让旧 Token 长期有效。
go
var ErrRefreshReused = errors.New("refresh token reused")
func (s *RefreshStore) Rotate(ctx context.Context, oldRefresh, device string) (string, string, time.Time, error) {
oldKey := "refresh:" + oldRefresh
// Lua 保证“读取、校验、删除”原子执行,避免多实例并发重复刷新
script := redis.NewScript(`
local data = redis.call("HGETALL", KEYS[1])
if #data == 0 then
return {1}
end
redis.call("DEL", KEYS[1])
return {0, data[2], data[4]}
`)
res, err := script.Run(ctx, s.rdb, []string{oldKey}).Result()
if err != nil {
return "", "", time.Time{}, err
}
arr := res.([]interface{})
if arr[0].(int64) == 1 {
// 不存在可能是过期、已轮换,也可能是攻击者使用了被盗 Token
return "", "", time.Time{}, ErrRefreshReused
}
userID := arr[1].(string)
newRefresh, err := s.Create(ctx, userID, device)
if err != nil {
return "", "", time.Time{}, err
}
access, exp, err := s.cfg.SignAccess(userID, "user")
if err != nil {
return "", "", time.Time{}, err
}
return access, newRefresh, exp, nil
}如果发现一个已经轮换过的 Refresh Token 再次出现,应视为“复用攻击”:立即吊销该用户全部 Refresh Token,记录用户 ID、设备、IP、UA,并强制重新登录。
4. 鉴权中间件:验签后检查黑名单
注销 Access Token 时,黑名单 TTL 应等于该 Token 的剩余有效期,而不是永久保存。这样既能立即失效,也不会让 Redis 无限膨胀。
go
func BearerMiddleware(cfg *AuthConfig, rdb *redis.Client, next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
auth := r.Header.Get("Authorization")
if len(auth) < 8 || auth[:7] != "Bearer " {
http.Error(w, "missing token", http.StatusUnauthorized)
return
}
tokenString := auth[7:]
claims := &Claims{}
token, err := jwt.ParseWithClaims(tokenString, claims, func(t *jwt.Token) (interface{}, error) {
if t.Method.Alg() != jwt.SigningMethodHS256.Alg() {
return nil, fmt.Errorf("unexpected alg: %s", t.Method.Alg())
}
return cfg.JWTSecret, nil
})
if err != nil || !token.Valid {
http.Error(w, "invalid token", http.StatusUnauthorized)
return
}
blackKey := "jwt:block:" + claims.TokenID
n, err := rdb.Exists(r.Context(), blackKey).Result()
if err != nil {
http.Error(w, "auth dependency unavailable", http.StatusServiceUnavailable)
return
}
if n > 0 {
http.Error(w, "token revoked", http.StatusUnauthorized)
return
}
ctx := context.WithValue(r.Context(), "user_id", claims.UserID)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
func RevokeAccess(ctx context.Context, rdb *redis.Client, tokenString string, cfg *AuthConfig) error {
claims := &Claims{}
_, _, err := jwt.NewParser().ParseUnverified(tokenString, claims)
if err != nil {
return err
}
ttl := time.Until(claims.ExpiresAt.Time)
if ttl <= 0 {
return nil
}
return rdb.Set(ctx, "jwt:block:"+claims.TokenID, "1", ttl).Err()
}5. 泄露处置:从单设备注销到全局止血
安全事件一般分三级处理:
- 用户主动退出:拉黑当前 Access Token,删除当前 Refresh Token。
- 单设备异常:按设备 ID 删除对应 Refresh 记录,要求该设备重新认证。
- 账号疑似被盗:吊销该用户全部 Refresh Token,提升用户安全版本号,已有 Access Token 在下次请求时拒绝;必要时触发密码重置和二次验证。
如果不想在 Access Token 中查 Redis 大集合,可以加入 ver 声明。用户改密或全局踢出时递增 Redis 中的用户版本号,中间件比较 ver 是否一致。黑名单仍用于单个 Token 注销,版本号用于批量失效。
避坑总结:多实例 JWT 的高频错误
1. 只在本机内存做黑名单
错误现象:调用实例 A 退出后,请求被负载均衡转发到实例 B,接口仍然可以访问。原因是 map 或本地缓存不会跨实例同步。修正方法是使用 Redis Cluster、Valkey 或内部鉴权服务统一保存吊销状态,并把检查放在所有实例共用的中间件中。
2. Refresh Token 不轮换,被盗后长期可用
错误现象:抓包拿到 Refresh Token 后,攻击者可以不断换新 Access Token,即使用户重新登录也不受影响。修正方法是刷新即失效旧 Token,服务端只保存 Token 哈希或随机串,检测到旧 Token 重复使用时吊销整个授权族,并记录安全审计日志。
3. JWT 不设置过期时间,或把权限变更完全交给前端
错误现象:管理员降权、员工离职后,旧 Token 在数天内仍能访问高权限接口;甚至有人只在前端隐藏菜单。修正方法是 Access Token 必须短 TTL,权限以后端接口校验为准;批量权限变更使用用户版本号、角色版本号或短轮询缓存,而不是等待 Token 自然过期。
4. 黑名单永久写入,Redis 内存持续上涨
错误现象:上线几个月后 Redis 内存报警,里面堆满早已过期的 JWT ID。修正方法是注销时只按 exp - now 设置 TTL;同时不要把完整 Token 原样存入 Redis,保存 jti、用户 ID、吊销原因即可。
5. 验签库允许 alg=none 或随意接受公钥当 HMAC 密钥
错误现象:攻击者伪造 Header 和声明即可通过校验。修正方法是显式固定签名算法,使用密钥 ID(kid)做密钥轮换;非对称方案中严格区分私钥签名服务和公钥验签服务,不要从 Token Header 动态选择可信密钥。
结语
JWT 的价值不是“完全不需要服务端状态”,而是把高频验签做成可水平扩展的本地计算,同时把注销、刷新和泄露处置收敛到少量共享状态。多实例部署下,短 TTL Access Token、可轮换 Refresh Token、Redis 黑名单和用户版本号组合起来,才能同时兼顾性能、体验和安全。
免责声明
本工具与文章内容仅用于学习测试场景,优化建议仅供参考,请勿直接用于生产环境,由此产生的风险自行承担。