必须用 HMAC 而非直接 SHA256 拼串,密钥需提前固定、签名原文格式须严格统一(含字段顺序、编码、换行),bodyHash 须基于原始字节计算,nonce 和 timestamp 必须校验防重放。

直接用 sha256 拼字符串再哈希,几乎必然失效——它不防篡改,只防“懒人改”,且极易因参数顺序、空格、编码差异导致服务端验签失败。真要防篡改,必须用 HMAC,并严格对齐密钥、拼接规则、时间窗口和 body 处理方式。
hmac.New 初始化时密钥就固定了,别在 Write 时传数据
Go 的 hmac.New 第二个参数是密钥([]byte),不是待签名内容。常见错误是把原始请求体或参数字符串当成第二个参数传进去,结果算出的签名和服务端完全对不上。
- 正确做法:先用
sha256.New构造 hasher,再套一层hmac.New,密钥必须提前准备好,且不能含 BOM、UTF-8-BOM、首尾空格 - 密钥别用
string(secret)再转[]byte,尤其含中文或特殊字符时会静默错位;直接读环境变量或配置文件的原始字节流 - 示例片段:
h := hmac.New(sha256.New, []byte(apiSecret)) h.Write([]byte(signStr)) signature := hex.EncodeToString(h.Sum(nil))
签名原文必须固定格式:大小写、换行、字段顺序一个都不能错
客户端和服务端对“什么参与签名”必须完全一致,否则哪怕只差一个换行符,hmac.Equal 就会返回 false。
- 推荐拼接模板:
method + "\n" + path + "\n" + timestamp + "\n" + nonce + "\n" + sortedQuery + "\n" + bodyHash -
sortedQuery要按 key 字典序升序排列,value 用url.QueryEscape编码,空值也保留(如a=&b=1) -
bodyHash必须基于原始r.Body字节计算(io.ReadAll一次读完),不能在json.Unmarshal后再拼字符串——JSON 解析可能丢空格、重排键序 - timestamp 必须是秒级 Unix 时间戳(
time.Now().Unix()),不是毫秒;服务端校验 ±300 秒偏移
nonce 和 timestamp 验证不到位,防篡改就变成摆设
签名本身只保证“内容没被改”,但拦不住重放。没有有效的 anti-replay 机制,攻击者截获一次合法请求就能无限重发。
立即学习“go语言免费学习笔记(深入)”;
- nonce 必须是密码学安全随机值(
crypto/rand.Read生成 16 字节后 hex 编码),不能是 UUID 或时间戳拼接 - 服务端必须缓存最近 5 分钟内所有见过的
nonce(推荐 Redis + TTL 300 秒),重复即拒 - timestamp 校验必须在签名比对前做,避免攻击者故意填超期时间绕过逻辑
- 别把 nonce 存数据库——高并发下写放大严重;用带过期的内存缓存更实际
Gin 中间件里别直接读两次 r.Body
r.Body 是单次读取流,中间件里如果先 io.ReadAll 算 bodyHash,后续 controller 就拿不到原始 body,c.ShouldBindJSON 会失败或读空。
- 解决方案:用
io.TeeReader或bytes.NewReader复制一份字节切片,一份给签名验证,一份留给后续逻辑 - 或者统一用
c.GetRawData()(Gin v1.9+)获取原始 body,它内部已做缓冲 - 千万别在中间件里调
c.Bind或c.ShouldBind,那会提前消费 body 流 - form-data 和 multipart 请求默认跳过 bodyHash 计算,因为无法无损还原原始字节;这类请求建议只签 query + header
最常被忽略的是 body 的“原始性”和 nonce 的“去重时效性”——前者让前端调试时反复怀疑密钥错了,后者让上线后某天突然发现大量重放请求通过了验签。这两个点不卡死,签名机制就只是心理安慰。


















