# HMAC 請求簽章該簽哪些東西：method、path、query 與 body 的原始 bytes

> 只簽 body 的 HMAC，換掉 path 或 query 照樣驗過；重新序列化 JSON 再驗，合法請求反而失敗。簽章要涵蓋 method、path、排序後的 query 與 body 原始 bytes，附 Go 1.27 實測。

原文：https://sheng.page/posts/what-to-sign-in-hmac-requests/ · 發布：2026-10-14 · 作者：Sheng

API 用 HMAC 簽章時，大家都知道要有共享金鑰，也要加時間戳記和 nonce，比較少花時間確認簽章該涵蓋哪些內容。我碰過的平台裡，有的只簽 body，有的先把 body 解析成物件，再序列化一次才驗，兩種都能上線運作，問題卻不會立刻出現。

我的做法是把 method、path、query、body 全部放進 canonical string，body 用收到的原始 bytes 算雜湊，不重新序列化。下面用 Go 1.27.1 把兩種常見錯誤各跑一次，再說明 canonical string 怎麼組；冪等和防重放的分工在〈[同一筆付款送了兩次：從 Santander 的聖誕節講冪等與重送](/posts/idempotency-and-retries/)〉講過，這篇只談簽章範圍。

## 一、只簽 body：path 和 query 可以隨便換

只簽 body，通常是把 body 當成業務資料，path 則只負責路由，這個出發點合理，卻漏看了 path 和 query 本身的語意。`/credit` 和 `/debit` 代表相反的操作，`currency=TWD` 和 `currency=USD` 差了幾十倍。

先準備兩個函式，驗證用 `hmac.Equal`，標準函式庫文件寫的是「without leaking timing information」；如果用 `bytes.Equal` 或字串比較，比對時間可能隨相同前綴的長度變化。我在自己的原型裡也修過一次 api key 比對的 timing 問題，這類比較稍不注意就會寫成一般的 `==`。

```go
func sign(msg []byte) string {
	m := hmac.New(sha256.New, key)
	m.Write(msg)
	return hex.EncodeToString(m.Sum(nil))
}

func verify(msg []byte, sig string) bool {
	want, err := hex.DecodeString(sig)
	if err != nil {
		return false
	}
	m := hmac.New(sha256.New, key)
	m.Write(msg)
	return hmac.Equal(m.Sum(nil), want)
}
```

原始請求是 `POST /v1/wallet/credit?currency=TWD`，body 是 `{"account":"a1","amount":"100"}`。把 query 改成 USD、或把 path 改成 debit，body 一個 byte 都沒動：

```text
POST /v1/wallet/credit?currency=USD  body-only=true  canonical=false
POST /v1/wallet/debit?currency=TWD  body-only=true  canonical=false
```

只簽 body 的版本兩次都驗過了，之前串接過的一個平台就採用這種設計，簽章不涵蓋 path 和 query，後來加報表 API 時，報表種類只能放進已簽的 body 欄位，不能放在 path 上，所有報表共用一個端點。這樣可以運作，路由和存取控制卻無法辨別實際操作，log 裡也看不出來。

## 二、收到之後重新序列化：合法請求驗不過

另一種錯誤是驗章用的 bytes 和送出時不同，最常見的寫法是 framework 先把 body 解析成 struct，驗章時再用 `json.Marshal` 轉回去：

```go
raw := []byte(`{"amount":"100.5","account":"a1"}`)
sig := sign(raw)
var c Credit
if err := jsonv1.Unmarshal(raw, &c); err != nil {
	panic(err)
}
again, err := jsonv1.Marshal(c)
```

```text
raw body       {"amount":"100.5","account":"a1"}  verify=true
re-marshalled  {"account":"a1","amount":"100.5"}  verify=false
```

內容雖然一樣，欄位卻改按 struct 的宣告順序排列，雜湊就不同，空白或數字寫法（`1e2` 和 `100`）改了也一樣，Unicode 跳脫也會影響結果。

Go 1.27 的標準函式庫已經有 `encoding/json/v2`，序列化 map 時要留意版本差異，v1 會依 key 排序，v2 預設不排序。拿同一個有 8 個 key 的 map，各序列化 50 次，看看會出現幾種不同輸出：

```text
distinct outputs over 50 runs:
  v1 Marshal                 1
  v2 Marshal                 8
  v2 Marshal Deterministic   1
```

v2 那一行的數字每次跑都可能不同，這次是 8 種，加上 `Deterministic(true)` 之後 map 就會排序。不過文件明確說明，只有同一份 binary 在不同次執行時，才保證輸出一致，換了原始碼、toolchain 版本、GOOS/GOARCH 或 build flag 都不保證。簽章兩端幾乎一定使用不同的 build，甚至不同語言，所以我在八月的設計審查裡訂了規則，canonical string 不准依賴任何 encoder 的輸出順序，body 一律用收到的原始 bytes。

## 三、canonical string 怎麼組

我用的格式是 method、path、排序後的 query、時間戳記、nonce、body 原始 bytes 的 SHA-256，用換行串起來：

```go
func canonical(method, path, rawQuery, ts, nonce string, body []byte) ([]byte, error) {
	q, err := url.ParseQuery(rawQuery)
	if err != nil {
		return nil, err
	}
	h := sha256.Sum256(body)
	return []byte(strings.Join([]string{
		method, path, q.Encode(), ts, nonce, hex.EncodeToString(h[:]),
	}, "\n")), nil
}
```

```text
POST
/v1/wallet/credit
currency=TWD
1791547200
n-7f3a
35dd7c542e6f365b4ea248f71092ae4a8761af211e547487a43c8192d1fad9cf
```

query 用 `url.Values.Encode()` 重新編碼，它會依 key 排序，所以參數順序不同的兩個請求會得到同一個 canonical string：

```text
query reordered, same canonical: true
```

query 是扁平的 key／value，排序和百分比編碼規則可以明訂在規格裡，兩端各自實作也能對得上，因此重新編碼並不違反上一節「不要重新序列化」的原則。JSON body 有巢狀結構，數字格式和空白也得處理，要定義一套兩邊都能完全重現的正規化太難，直接用原始 bytes 最省事。同一個 key 出現多次時，`Encode` 會保留這些值原本的相對順序：

```text
tag=b&x=1&tag=a -> tag=b&tag=a&x=1
tag=a&x=1&tag=b -> tag=a&tag=b&x=1
```

兩個請求的參數一樣，canonical string 卻不同，所以規格要寫明重複 key 的值順序也有意義，或乾脆禁止重複 key。

AWS SigV4 走的是同一條路，canonical request 由 HTTP method、URI 編碼過的 path、依 key 排序的 query、簽入的 header、header 名稱清單、payload 的 SHA-256 組成，文件還特別提醒各平台內建的 URI encoder 行為不一定相同，建議自己寫。RFC 9421（HTTP Message Signatures，2024 年 2 月）則把 `@method`、`@path`、`@query` 定義成可簽的元件，body 交給 `Content-Digest`。兩邊的共同點是 method、path、query 都在簽章範圍內，body 簽的是原始內容的雜湊。

## 四、重放檢查與金鑰管理

原型的重放檢查用 api key、path 和簽章組成 key，一開始漏了 path，當時的簽章也沒有涵蓋 path，結果同一秒送到不同端點的兩個請求，只要內容相同，簽章就完全一樣，第二個因此被當成重放擋掉。冪等那篇提過 nonce 的範圍要含 path，這裡也看得出來，簽章漏掉 path 會留下竄改空間，也會讓重放檢查誤判。

簽章設計也得考慮金鑰的生命週期，原型裡輪替金鑰時會發新的 secret ref，舊金鑰保留 15 分鐘寬限期，這段時間內兩把都能驗過。建立營運商時，我還遇過先寫入 secret、再插入資料列的問題，api key 重複導致插入失敗，secret 卻已經寫入，把同一個 key 原本的 secret 蓋掉了，所以順序得反過來，資料列插入成功之後才寫 secret。

## 補充筆記

- 只簽 body 時，path 和 query 可以任意更換，簽章照樣通過；`/credit` 換成 `/debit` 就是相反的操作。
- 驗章用收到的原始 bytes，不要解析後再序列化，欄位順序一改，雜湊就不同，空白和數字寫法也會影響結果。
- Go 1.27 的 `encoding/json/v2` 預設不排序 map key；`Deterministic(true)` 只保證同一份 binary 內的輸出一致，不能當成跨系統的正規化。
- canonical string 包含 method、path、排序後的 query、時間戳記、nonce 和 body 雜湊；AWS SigV4 與 RFC 9421 的範圍和這個一致。
- 比對 MAC 用 `hmac.Equal`；nonce 去重 key 要含 path；輪替金鑰給寬限期，secret 在資料列寫入成功之後才寫。

## 延伸閱讀

- AWS 文件〈[Create a signed AWS API request](https://docs.aws.amazon.com/IAM/latest/UserGuide/create-signed-request.html)〉：SigV4 canonical request 的六個組成與 query 排序、編碼規則。
- RFC 9421〈[HTTP Message Signatures](https://www.rfc-editor.org/rfc/rfc9421.html)〉：IETF 標準，定義 `@method`、`@path`、`@query` 等可簽元件，body 透過 RFC 9530 的 `Content-Digest` 涵蓋。
- Go 文件〈[crypto/hmac](https://pkg.go.dev/crypto/hmac#Equal)〉與〈[encoding/json/v2](https://pkg.go.dev/encoding/json/v2#Deterministic)〉：`hmac.Equal` 的 timing 說明，以及 `Deterministic` 只在同一份 binary 內保證一致的原文。
- Wikipedia〈[HMAC](https://en.wikipedia.org/wiki/HMAC)〉／〈[金鑰雜湊訊息鑑別碼](https://zh.wikipedia.org/wiki/HMAC)〉：HMAC 的定義與構造。

> 想法與技術判斷出自 Sheng，和 Claude 一起起草 · 範例在 Go 1.27.1 實測。