Go's net/http Trailers: Sending Checksums After the Body
Here is a small annoyance. You want to stream a large response and also give the client a SHA-256 of it so it can check the download arrived intact. Headers go first, and you cannot know the hash until you have written every byte. So you either buffer the whole thing (defeating the point of streaming), hash it in a first pass (twice the I/O), or precompute and store the digest somewhere.
HTTP has had a fourth option for a very long time: trailers. They are header fields sent after the body. Go's net/http supports them on both sides, but the API is odd enough, and the failure mode quiet enough, that it is worth walking through with something that runs.
The server: announce first, fill in later
A trailer has to be declared in the normal headers via a Trailer header naming the keys you will send. Then you write the body, and only afterwards set the actual values on w.Header(). Go notices that those keys were announced and writes them after the final chunk.
const sumKey = "X-Content-Sha256"
func download(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Trailer", sumKey)
h := sha256.New()
src := strings.NewReader(strings.Repeat("hello trailers\n", 100000))
if _, err := io.Copy(io.MultiWriter(w, h), src); err != nil {
panic(http.ErrAbortHandler)
}
w.Header().Set(sumKey, hex.EncodeToString(h.Sum(nil)))
}
Two things here. The io.MultiWriter means the hash is computed as the bytes go out, with no second pass. And the panic(http.ErrAbortHandler) is deliberate: if the copy fails halfway, you do not want the server to finish the response politely. Aborting the handler makes the server tear down the connection (or reset the stream on HTTP/2) without a proper terminator, so the client sees an error instead of a truncated file that looks complete. That sentinel panic is documented and does not get logged as a crash.
If you do not know the trailer keys up front, there is a second route: set the key after writing with the http.TrailerPrefix constant, which is the string "Trailer:", as in w.Header().Set(http.TrailerPrefix+"X-Whatever", v). I prefer declaring keys in advance, because clients and intermediaries then know what is coming.
You do not need to set Content-Length and you should not try. On HTTP/1.1 trailers only exist in chunked encoding, and Go picks that for you when a trailer is declared. The client below shows [chunked] in resp.TransferEncoding, which is a handy sanity check.
The client: the trailer only exists after EOF
This is the bit that catches people out. On the client, resp.Trailer is a map that is populated as you read. Before you reach the end of the body, the announced keys are there but empty:
resp, err := http.Get(srv.URL + "/file")
if err != nil {
log.Fatal(err)
}
defer resp.Body.Close()
fmt.Println("before read:", resp.Trailer, resp.TransferEncoding)
// before read: map[X-Content-Sha256:[]] [chunked]
h := sha256.New()
if _, err := io.Copy(h, resp.Body); err != nil {
log.Fatal(err)
}
got := hex.EncodeToString(h.Sum(nil))
fmt.Println(got == resp.Trailer.Get(sumKey))
// true
That output is what I got from running exactly this against an httptest.Server. The important line is the order: read to io.EOF first, then look at resp.Trailer. If you read only the first few KiB, or close the body early, or use something that stops before EOF (a json.Decoder reading one value, for instance), the trailer is never filled in and Get quietly returns an empty string. An empty string is also what a server that never sent the trailer gives you, so treat empty as a failure, not as "nothing to check".
Uploads: the client sends the trailer
The same mechanism works in the other direction, and this is arguably the more useful case: a client streaming a file up can send the digest at the end without reading the file twice. The request side is fiddlier. You set req.Trailer to a map naming the keys (values may be nil for now), set ContentLength to -1 to force chunked encoding, and fill in the value once your body reader hits EOF.
The neat way to do that is a reader that hashes as it goes and writes the trailer when it sees the end:
type sumReader struct {
r io.Reader
h hash.Hash
trailer http.Header
}
func (s *sumReader) Read(p []byte) (int, error) {
n, err := s.r.Read(p)
s.h.Write(p[:n])
if err == io.EOF {
s.trailer.Set(sumKey, hex.EncodeToString(s.h.Sum(nil)))
}
return n, err
}
req, err := http.NewRequest(http.MethodPut, srv.URL+"/file", nil)
if err != nil {
log.Fatal(err)
}
req.Trailer = http.Header{sumKey: nil}
req.ContentLength = -1
req.Body = io.NopCloser(&sumReader{
r: strings.NewReader("some bytes"),
h: sha256.New(),
trailer: req.Trailer,
})
res, err := http.DefaultClient.Do(req)
The net/http documentation is explicit that once the body has returned EOF the caller must not touch the trailer map again, which is why the write happens at exactly that point and nowhere else. I passed a nil body to NewRequest and assigned Body afterwards purely to keep the trailer map and reader tied together; you could equally build the reader first.
The receiving handler mirrors the client from earlier: consume the body, then look at r.Trailer.
func upload(w http.ResponseWriter, r *http.Request) {
h := sha256.New()
n, err := io.Copy(h, r.Body)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
got := hex.EncodeToString(h.Sum(nil))
if want := r.Trailer.Get(sumKey); want != got {
http.Error(w, "checksum mismatch", http.StatusUnprocessableEntity)
return
}
fmt.Fprintf(w, "ok %d bytes\n", n)
}
In a real handler you would write the bytes to a temporary file rather than only hashing them, and only rename it into place after the comparison passes. Otherwise you have verified a checksum on data you have already committed.
Pitfalls
- Key canonicalisation. Go canonicalises header keys, so
X-Content-SHA256becomesX-Content-Sha256. I first wrote the constant with capitals and it worked by luck of how the pieces line up; declaring the canonical form in the constant removes the question. Use one canonical constant everywhere. - Intermediaries. Trailers are a hop-sensitive feature. Some proxies, CDNs and load balancers drop them, and browsers'
fetchdoes not expose them at all. This suits service-to-service transfers where you control both ends, and it suits gRPC (which is built on HTTP/2 trailers), but I would not build a public download story on it without testing the actual path. - Integrity, not authenticity. A SHA-256 in a trailer catches truncation and corruption. It does not stop someone who can alter the body from altering the trailer to match. If tampering matters, send an HMAC keyed with a secret both sides hold, or sign the digest, and compare with
subtle.ConstantTimeCompare. - Truncation on the wire. Chunked encoding already detects a connection dropped mid-body (you get
io.ErrUnexpectedEOF), so the checksum is mostly guarding against bugs on either end and anything that re-encodes the stream, not against a plain network drop. - Time to first byte is unchanged, time to verified is not. The client cannot trust the data until the very end, so it must be prepared to discard everything it has already written. That is the price of not knowing the hash up front.
Actually, that last point is the interesting design consequence: trailers move you from "verify then use" to "use provisionally, then verify". For a file that lands in a temp path that is fine. For a stream you are piping straight into a parser, it means the parser has already seen unverified bytes by the time the checksum arrives, and you should decide whether that is acceptable before reaching for this.