Skip to content
ecdsa.com

HMAC-SHA256 sign/verify · Go

How to sign and verify a message with HMAC-SHA256 in Go

To sign and verify a message with HMAC-SHA256 in Go, use crypto/hmac with crypto/sha256 from the standard library: hmac.New(sha256.New, secret) computes the MAC and hmac.Equal compares it in constant time. The typical case is verifying a webhook header such as X-Hub-Signature-256; this recipe checks GitHub's documented example and runs a net/http handler.

Tested with Go 1.27.3 (crypto/hmac and net/http, linux/arm64).

webhook.go — sign and verify a sha256=<hex> header
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
}
handler.go — a net/http handler that verifies the raw body before decoding JSON
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

  1. hmac.New(sha256.New, secret) returns a hash.Hash; Write the exact body bytes and Sum(nil) returns the 32-byte MAC, hex-encoded after sha256=.
  2. hmac.Equal compares in constant time and returns false for inputs of different lengths, so a truncated header needs no special case.
  3. strings.CutPrefix and hex.DecodeString reject a header without the sha256= prefix or with invalid hex before any MAC is computed.
  4. The handler reads the body once with io.ReadAll behind http.MaxBytesReader (1 MiB here), verifies those bytes, then decodes the same slice with json.Unmarshal.
  5. The run checks GitHub's example — Hello, World! with the secret It's a Secret to Everybody gives sha256=757107ea…3e17 — then posts to the handler behind a real listener.

Gotchas

  • mac.Sum(b) appends the MAC to b: mac.Sum(body) returns the body followed by the MAC (45 bytes in the run, not 32). Pass nil unless you mean to append.
  • hex.DecodeString fails 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 with 401. Decode only after verifying, and only the bytes you verified.
  • r.Header.Get is case-insensitive, but an absent header returns "" — which must fail verification, as verify does, and not be read as "unsigned, allow".

Related recipes