go 108 lines · 3 tabs

Sign Outgoing Webhook Requests With an HMAC-SHA256 Signature and Timestamp

Shared by codesnips Sep 2026
3 tabs
package webhooksign

import (
	"crypto/hmac"
	"crypto/sha256"
	"encoding/hex"
	"fmt"
	"time"
)

type Signer struct {
	Secret []byte
	Now    func() time.Time
}

func NewSigner(secret []byte) *Signer {
	return &Signer{Secret: secret, Now: time.Now}
}

func canonical(ts int64, body []byte) []byte {
	return []byte(fmt.Sprintf("v1:%d:%s", ts, body))
}

// Sign returns the timestamp used and the hex-encoded HMAC-SHA256 digest.
func (s *Signer) Sign(body []byte) (ts int64, signature string) {
	ts = s.Now().Unix()
	mac := hmac.New(sha256.New, s.Secret)
	mac.Write(canonical(ts, body))
	return ts, hex.EncodeToString(mac.Sum(nil))
}
3 files · go Explain with highlit

Signing outgoing webhooks lets receivers verify that a payload really came from the sender and was not tampered with in transit. The pattern shown here computes an HMAC-SHA256 over a canonical string built from a timestamp and the request body, keyed by a shared secret. Including the timestamp inside the signed string is what defends against replay attacks: a receiver rejects requests whose timestamp is too old, so a captured payload cannot be resent indefinitely.

signer.go defines the core Signer, which holds the secret and a Now clock function so tests can inject deterministic time. Sign builds the canonical message as v1:<unix-seconds>:<body> and returns both the timestamp and a hex-encoded digest. Keeping the message format explicit and versioned (v1:) matters because both sides must agree byte-for-byte; any difference in field order or separators produces a mismatched signature. The scheme prefix also leaves room to rotate the algorithm later without breaking old receivers.

transport.go wires the signer into Go's HTTP stack as a http.RoundTripper. This is the idiomatic place to cross-cut every outgoing request: SigningTransport.RoundTrip reads and buffers the body with io.ReadAll, restores it via io.NopCloser so the real transport can still send it, then sets X-Signature and X-Signature-Timestamp headers. Buffering is necessary because a request body is a one-shot stream; reading it to sign would otherwise consume it. It clones the request with req.Clone to avoid mutating the caller's object, which keeps the transport safe for concurrent reuse. Requests without a body are signed over an empty payload, so GETs still carry a valid signature.

verify.go shows the receiving side. Verify recomputes the digest from the same canonical string and compares using hmac.Equal, a constant-time comparison that avoids timing side channels — a plain == would leak information about how many leading bytes matched. It also enforces a freshness window with tolerance, returning ErrExpired when the timestamp drifts beyond it. Together these three files form a symmetric, testable signing contract: the same message construction on both ends, constant-time verification, and timestamp-bound replay protection.


Related snips

Share this code

Here's the card — post it anywhere.

Sign Outgoing Webhook Requests With an HMAC-SHA256 Signature and Timestamp — share card
Link copied