What an HMAC Request Signature Should Cover: Method, Path, Query and the Raw Body
Everyone who signs API requests with HMAC sorts out a shared secret, a timestamp and a nonce. Fewer people stop to ask which parts of the request the signature is protecting. Of the platforms I’ve integrated with, some signed the body alone, and some parsed the body into an object and serialised it again before checking the signature. Both designs ran in production for a long time before the problem showed up.
I sign the method, path, query and body as one canonical string. I hash the body bytes as received, without re-serialising them. Below I reproduce both mistakes in Go 1.27.1 and then build the canonical string. I cover replay protection and idempotency, and how their responsibilities differ, in The Same Payment Sent Twice; here I only deal with what the signature covers.
Signing only the body lets the path and query change
The argument for signing just the body usually goes: the body is the business data, the path only routes the request. The path and query also determine the operation. /credit and /debit move money in opposite directions, and currency=TWD rather than currency=USD makes about a thirtyfold difference.
I use two helpers to sign and verify requests. The standard library says hmac.Equal compares MACs “without leaking timing information”, so I use it here. A plain bytes.Equal or string comparison may take longer the more leading bytes match. My own prototype had this problem in an API key comparison, which I fixed before review. It’s easy to write an ordinary == without noticing the risk.
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)
}
Start from POST /v1/wallet/credit?currency=TWD with the body {"account":"a1","amount":"100"}, then change the query to USD, or the path to debit, leaving the body exactly as it was:
POST /v1/wallet/credit?currency=USD body-only=true canonical=false
POST /v1/wallet/debit?currency=TWD body-only=true canonical=false
Both tampered requests pass the body-only check. I’ve integrated with a platform designed this way: path and query sat outside the signature. When reporting endpoints arrived, the report type couldn’t live in the path, so it went into a signed field in the body and every report shared one endpoint. That works, but routing and access control can no longer tell which operation a request calls, and neither can anyone reading the logs.
Re-serialising before you verify breaks honest requests
Another mistake is to verify bytes that differ from the ones that were signed. Typically the framework has already decoded the body into a struct, and the verification step marshals it back to JSON:
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)
raw body {"amount":"100.5","account":"a1"} verify=true
re-marshalled {"account":"a1","amount":"100.5"} verify=false
The data is unchanged, but the fields now follow the struct’s order, which changes the hash. Whitespace, how a number is written (1e2 or 100) and Unicode escapes would all do the same.
Go 1.27 makes this worse. encoding/json/v2 is now part of the standard library, and where v1 sorts map keys on output, v2 leaves them unsorted by default. I marshalled one map with eight keys fifty times with each and counted how many different outputs came back:
distinct outputs over 50 runs:
v1 Marshal 1
v2 Marshal 8
v2 Marshal Deterministic 1
Run it again and the v2 count will likely differ; this run gave eight. Deterministic(true) sorts the keys, but its documentation limits the promise to repeated runs of one binary. Change the source, the toolchain version, GOOS/GOARCH or the build flags and it no longer holds. The signer and verifier are almost always built separately and often written in different languages. During a design review in August, I therefore set a rule: nothing in the canonical string may depend on an encoder’s output order, and the body is always the raw bytes received.
Building the canonical string
My canonical string contains the method, the path, the sorted query, the timestamp, the nonce and the SHA-256 of the raw body, one per line:
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
}
POST
/v1/wallet/credit
currency=TWD
1791547200
n-7f3a
35dd7c542e6f365b4ea248f71092ae4a8761af211e547487a43c8192d1fad9cf
url.Values.Encode() re-encodes the query sorted by key, so the order the client wrote the parameters in doesn’t matter:
query reordered, same canonical: true
Re-encoding the query is fine even though re-serialising the body is not. A query is a flat list of pairs, so a spec can define the sort order and the percent-encoding, and two independent implementations will agree. For JSON, both ends would have to agree on how to handle nesting, number formats and whitespace to reproduce the same bytes. Hashing the body as received takes far less work. Repeated keys need a decision, though, because Encode keeps their values in the order they appeared:
tag=b&x=1&tag=a -> tag=b&tag=a&x=1
tag=a&x=1&tag=b -> tag=a&tag=b&x=1
These queries contain the same parameters but produce different canonical strings. The spec should either declare that the order of repeated values is significant or forbid repeated keys outright.
AWS SigV4 makes the same choices. Its canonical request combines the HTTP method, the URI-encoded path, the query sorted by key, the signed headers and their names, and the SHA-256 of the payload. The documentation warns that platform URI encoders vary and recommends writing your own. RFC 9421, HTTP Message Signatures, from February 2024, lets you sign @method, @path and @query, and covers the body through Content-Digest. Both include the method, path and query in the signature and protect the body with a hash of the bytes sent.
Replay checks and key management
The prototype’s replay check uses a key made of the API key, the path and the signature. At first the path was missing from it, and the signature didn’t cover the path either. Two requests with the same body sent to different endpoints within the same second therefore had identical signatures, and the second was turned away as a replay. The idempotency post says the nonce check should be scoped to the path. Leaving the path out of the signature allows tampering and confuses replay detection.
Signing also involves managing the keys. When a secret is rotated in the prototype, a new secret reference is issued and the old key remains valid for fifteen minutes, so both keys can verify signatures during the handover. One bug I hit came from creating an operator: the secret was written first and the row inserted second. A duplicate API key made the insert fail, but by then the secret had been written and had replaced the existing secret for that key. The fix is to write the secret only once the row has been inserted successfully.
Notes
- If only the body is signed, the path and query can be swapped and the signature still verifies;
/creditturned into/debitis the opposite operation. - Verify against the raw bytes received. Parsing and re-serialising changes field order, whitespace and number formatting, and with them the hash.
- Go 1.27’s
encoding/json/v2does not sort map keys by default, andDeterministic(true)only guarantees stable output within one binary, so it is no substitute for a cross-system normal form. - The canonical string holds the method, path, sorted query, timestamp, nonce and body hash, which matches the scope of AWS SigV4 and RFC 9421.
- Compare MACs with
hmac.Equal, include the path in the replay check, give rotated keys a grace period, and write a secret only after its row exists.
Further reading
- AWS documentation, Create a signed AWS API request: the six parts of a SigV4 canonical request and the rules for sorting and encoding the query.
- RFC 9421, HTTP Message Signatures: the IETF standard defining signable components such as
@method,@pathand@query, with the body covered throughContent-Digestfrom RFC 9530. - Go documentation for crypto/hmac and encoding/json/v2: the timing note on
hmac.Equal, and the wording that limitsDeterministicto a single binary. - Wikipedia, HMAC: the definition and construction of HMAC.
Ideas and technical judgement by Sheng; drafted with Claude · examples run on Go 1.27.1.