package main import ( "crypto/hmac" "crypto/sha256" "encoding/hex" "strings") // sign is the sender side: HMAC-SHA256 over the exact body bytes, as "sha256=<hex>".func sign(secret, rawBody []byte) string { mac := hmac.New(sha256.New, secret) mac.Write(rawBody) return "sha256=" + hex.EncodeToString(mac.Sum(nil))} // verify is the receiver side: recompute over the raw body, compare in constant time.func verify(secret, rawBody []byte, header string) bool { hexMAC, ok := strings.CutPrefix(header, "sha256=") if !ok { return false // a missing header fails, it never skips the check } received, err := hex.DecodeString(hexMAC) if err != nil { return false } mac := hmac.New(sha256.New, secret) mac.Write(rawBody) return hmac.Equal(received, mac.Sum(nil)) // also false when the lengths differ} package main import ( "encoding/json" "fmt" "io" "net/http") // Registered with, e.g.:// http.Handle("POST /webhook", webhookHandler([]byte(os.Getenv("WEBHOOK_SECRET"))))func webhookHandler(secret []byte) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { // the bytes as received, capped at 1 MiB, before any JSON decoding rawBody, err := io.ReadAll(http.MaxBytesReader(w, r.Body, 1<<20)) if err != nil { http.Error(w, "body too large", http.StatusRequestEntityTooLarge) return } if !verify(secret, rawBody, r.Header.Get("X-Hub-Signature-256")) { http.Error(w, "bad signature", http.StatusUnauthorized) return } var event struct { Action string `json:"action"` Number int `json:"number"` } if err := json.Unmarshal(rawBody, &event); err != nil { http.Error(w, "bad JSON", http.StatusBadRequest) return } fmt.Fprintf(w, "accepted %s #%d\n", event.Action, event.Number) })} How it works
hmac.New(sha256.New, secret)returns ahash.Hash;Writethe exact body bytes andSum(nil)returns the 32-byte MAC, hex-encoded aftersha256=.hmac.Equalcompares in constant time and returnsfalsefor inputs of different lengths, so a truncated header needs no special case.strings.CutPrefixandhex.DecodeStringreject a header without thesha256=prefix or with invalid hex before any MAC is computed.- The handler reads the body once with
io.ReadAllbehindhttp.MaxBytesReader(1 MiB here), verifies those bytes, then decodes the same slice withjson.Unmarshal. - The run checks GitHub's example —
Hello, World!with the secretIt's a Secret to Everybodygivessha256=757107ea…3e17— then posts to the handler behind a real listener.
Gotchas
mac.Sum(b)appends the MAC tob:mac.Sum(body)returns the body followed by the MAC (45 bytes in the run, not 32). Passnilunless you mean to append.hex.DecodeStringfails on an odd number of digits (encoding/hex: odd length hex string) — treat any decoding error as a bad signature, never as a reason to skip the check.- The MAC covers bytes, not JSON: the same event re-encoded with
json.Marshal(keys sorted, whitespace dropped) is rejected with401. Decode only after verifying, and only the bytes you verified. r.Header.Getis case-insensitive, but an absent header returns""— which must fail verification, asverifydoes, and not be read as "unsigned, allow".