防重放中间件核心是三步:前置校验X-Timestamp偏差(≤30秒)、拼接userID:timestamp:nonce构造指纹、用Redis SetNX原子判定首次到达;必须在JWT解析后、业务前执行,拒绝返回403。

直接上手写防重放中间件,核心就三件事:校验时间戳偏差、构造请求指纹、用 Redis 原子写入判定是否首次到达。别碰 JWT payload 里塞 nonce,也别在 handler 里零散写校验——这两条是线上翻车最高发的坑。
校验 X-Timestamp 和时钟偏差必须前置
服务端不能只比 time.Now().Unix() 和请求头里的 X-Timestamp 差值,得先确认这个时间戳本身“没被篡改”且“不算太旧”。它必须参与签名计算,否则攻击者直接改 header 就绕过。
-
X-Timestamp必须是 Unix 秒级整数(不是毫秒、不是字符串),客户端和服务端都用time.Now().Unix() - 允许的最大偏差建议设为 30 秒:接收时间减去请求时间,绝对值 > 30 → 直接返回
400 Bad Request - 这步必须在解析 JWT 之前做,否则攻击者用一个过期但签名合法的 Token + 伪造时间戳,可能绕过后续所有检查
用 userID:timestamp:nonce 拼指纹,别存整个 Token
指纹不是为了查“这个 Token 是否用过”,而是查“这个用户在这一秒内是否发过一模一样的请求”。userID 来自已解析的 JWT claims,timestamp 用秒级,nonce 由客户端生成并传在 X-Nonce header 中。
- 拼接格式推荐:
fmt.Sprintf("req:%s:%d:%s", userID, timestamp, nonce) -
nonce长度至少 16 字符,只含字母数字;前端可用crypto/rand.Read生成字节再 hex 编码 - 不接受 URL 参数或 form 字段传
nonce—— 日志、CDN、代理可能记录,泄露即失效
Redis SetNX 必须原子执行,不能先 GET 再 SET
竞态窗口就在那一瞬间:两个相同指纹的请求同时进来,都查 Redis 发现 key 不存在,然后都写入成功,防重放就形同虚设。
立即学习“go语言免费学习笔记(深入)”;
- 必须用
rdb.SetNX(ctx, fingerprint, "1", 60*time.Second)(Go-Redis v9)或SET key value EX 60 NX(原生命令) - 过期时间设 30–60 秒:太短易因网络抖动误拒;太长则重放窗口变大,60 秒已覆盖绝大多数公网延迟+服务耗时
- 如果 Redis 不可用,中间件应降级为只记录告警、不阻断请求(避免雪崩),而不是 panic 或 fallback 到本地 map
中间件顺序和错误码要严格对齐前后端约定
真正出问题的往往不是代码,而是某一方悄悄改了 X-Nonce 生成逻辑,或把时间戳单位从秒换成毫秒,又或者返回了 500 而不是明确的 403 Forbidden。
- 校验链路固定顺序:
X-Timestamp存在性 → 格式解析 → 偏差检查 → JWT 解析 → 提取userID→ 读X-Nonce→ 拼指纹 → Redis SetNX → 失败则http.Error(w, "request replay detected", http.StatusForbidden) - 所有拒绝必须返回明确语义的 HTTP 状态码:
400(header 缺失/格式错)、403(重放)、401(Token 无效),前端靠这个做重试决策 - 别在业务 handler 里补校验——一旦新增接口忘了加,漏洞就暴露了;统一收口到 Gin 或 chi 的中间件里
最易忽略的是时钟同步和容器环境下的时间跳变:K8s Pod 重启、Lambda 冷启动后 time.Now() 可能回拨几十毫秒,NTP 必须开,还要监控 chrony offset。防重放机制上线前,务必用故意延迟、重复发包、篡改 header 的方式实测一遍边界场景。


















