webhookverify

package module
v0.1.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Jun 11, 2026 License: MIT Imports: 11 Imported by: 0

README

webhook-verify-go

Tiny, dependency-free Go verifiers for HMAC-signed webhooks - the Go twin of webhook-verify (TypeScript). Every provider's scheme is the same primitive - a shared secret, an HMAC over bytes both sides can reconstruct, a constant-time compare - in one of three dialects:

  • Raw body - HMAC-SHA256 over the exact request bytes. GitHub's X-Hub-Signature-256, and the right default when signing webhooks between your own services.
  • Timestamped - t=<unix>,v1=<hex> over "<t>.<body>", Stripe-style. The timestamp plus a tolerance window is the replay defense the other two dialects don't have.
  • URL + sorted params - base64 HMAC-SHA1 over the public webhook URL with the POST params appended in key order. Twilio's X-Twilio-Signature and Mandrill's X-Mandrill-Signature.

Standard library only (crypto/hmac), no dependencies. The test suite includes parity vectors generated by the TypeScript implementation - both libraries produce byte-identical signatures for every dialect.

Install

go get github.com/JacobStephens2/webhook-verify-go
import webhookverify "github.com/JacobStephens2/webhook-verify-go"

Raw body (GitHub, your own services)

The body you verify must be the unparsed request bytes. Read the body before anything decodes it; parse only after verifying.

http.HandleFunc("/webhooks/github", func(w http.ResponseWriter, r *http.Request) {
	rawBody, err := io.ReadAll(r.Body)
	if err != nil {
		http.Error(w, "", http.StatusBadRequest)
		return
	}
	if !webhookverify.VerifyGitHub(rawBody, r.Header.Get("X-Hub-Signature-256"), secret) {
		http.Error(w, "", http.StatusForbidden)
		return
	}
	var event PushEvent
	json.Unmarshal(rawBody, &event) // parse only after verifying
	// ...
})

For service-to-service webhooks, SignBody/VerifyBody are the same scheme without GitHub's sha256= prefix. Pass nil options for the default (SHA-256, hex), or pick the dialect explicitly:

sig := webhookverify.SignBody(payload, secret, &webhookverify.BodyOptions{
	Algorithm: webhookverify.SHA512,
	Encoding:  webhookverify.Base64,
})

Timestamped (Stripe-style), both directions

// Producer - sign what you send:
header := webhookverify.SignTimestamped(payload, secret, time.Now().Unix())
req.Header.Set("Webhook-Signature", header)

// Receiver - default tolerance is 5 minutes either direction:
if !webhookverify.VerifyTimestamped(rawBody, r.Header.Get("Webhook-Signature"), secret, nil) {
	http.Error(w, "", http.StatusForbidden)
	return
}

// Tests inject the clock instead of sleeping:
webhookverify.VerifyTimestamped(rawBody, header, secret, &webhookverify.TimestampedOptions{
	Tolerance: 10 * time.Minute,
	Now:       time.Unix(1750000000, 0),
})

Multiple v1 entries in one header are accepted (sent during key rotation); any single match passes.

URL + sorted params (Twilio, Mandrill)

The URL in the signature is the one the provider called - the public https:// URL, query string included. Behind a reverse proxy your app sees something else; reconstruct the public URL from configuration, not from the request.

webhookverify.VerifyTwilio(
	"https://app.example.com/sms/status", // public URL, as configured in Twilio
	params,                               // POST params as map[string]string
	r.Header.Get("X-Twilio-Signature"),
	os.Getenv("TWILIO_AUTH_TOKEN"),
)

webhookverify.VerifyMandrill(
	"https://app.example.com/webhooks/mandrill",
	params,
	r.Header.Get("X-Mandrill-Signature"),
	os.Getenv("MANDRILL_WEBHOOK_KEY"),
)

API

Function Scheme
SignBody(rawBody, secret, opts) HMAC of the raw bytes (default SHA-256 hex; opts may be nil)
VerifyBody(rawBody, signature, secret, opts) Verify a raw-body signature
VerifyGitHub(rawBody, header, secret) GitHub X-Hub-Signature-256 (sha256=<hex>)
SignTimestamped(rawBody, secret, timestampSeconds) Produce t=<unix>,v1=<hex> over "<t>.<body>"
VerifyTimestamped(rawBody, header, secret, opts) Verify with a replay-tolerance window (opts may be nil)
VerifyTwilio(url, params, header, authToken) Twilio X-Twilio-Signature
VerifyMandrill(url, params, header, webhookKey) Mandrill X-Mandrill-Signature
SafeEqual(a, b) Constant-time string compare

Every verifier returns a bool and treats a missing or malformed header as false - nothing panics on attacker-controlled input.

Security notes (read these)

  • Verify the raw bytes. A signature over the body is a signature over the exact bytes on the wire. Parse after verifying, never before.
  • Comparisons are constant-time (crypto/hmac.Equal). A plain == against an attacker-supplied signature is a timing oracle.
  • Only the timestamped dialect defends against replay. A captured raw-body or URL+params request verifies forever; if replays matter, add event-ID deduplication on top.
  • Fail closed. An unset secret in your config should reject requests, not skip verification. The dangerous failure mode is the receiver that silently stops checking.
  • A valid signature authenticates the sender, nothing more. It doesn't authorize the payload's contents, dedupe retries, or replace TLS.

License

MIT © Jacob Stephens

Documentation

Overview

Package webhookverify provides tiny, dependency-free HMAC webhook verifiers - the Go twin of github.com/JacobStephens2/webhook-verify.

Every provider's scheme is the same primitive - a shared secret, an HMAC over bytes both sides can reconstruct, a constant-time compare - in one of three dialects: raw body (GitHub-style), timestamped with replay tolerance (Stripe-style), and URL + sorted params (Twilio, Mandrill).

Every verifier returns a bool and treats a missing or malformed header as false - nothing panics on attacker-controlled input.

Index

Constants

This section is empty.

Variables

This section is empty.

Functions

func SafeEqual

func SafeEqual(a, b string) bool

SafeEqual reports whether two strings are equal, in constant time. Length differences return false immediately - length is not a secret in any scheme here.

func SignBody

func SignBody(rawBody []byte, secret string, opts *BodyOptions) string

SignBody is the raw-body dialect, producer side: an HMAC over the exact bytes you send. Pair with VerifyBody on the receiving service. A nil opts means HMAC-SHA256, hex-encoded.

func SignTimestamped

func SignTimestamped(rawBody []byte, secret string, timestampSeconds int64) string

SignTimestamped is the timestamped dialect, producer side: Stripe-style "t=<unix>,v1=<hex>" where the signed payload is "<t>.<rawBody>". The timestamp is what gives the receiver a replay defense.

func VerifyBody

func VerifyBody(rawBody []byte, signature, secret string, opts *BodyOptions) bool

VerifyBody is the raw-body dialect, receiver side. rawBody must be the unparsed request bytes - verifying a re-serialized parse is the classic way this fails.

func VerifyGitHub

func VerifyGitHub(rawBody []byte, header, secret string) bool

VerifyGitHub verifies GitHub's X-Hub-Signature-256 header: "sha256=" + hex HMAC-SHA256 of the raw body, keyed with the webhook secret.

func VerifyMandrill

func VerifyMandrill(url string, params map[string]string, header, webhookKey string) bool

VerifyMandrill verifies Mandrill's X-Mandrill-Signature: same construction as Twilio's, keyed with the webhook key from the Mandrill webhook settings page. The signed URL is the webhook URL exactly as configured in Mandrill.

func VerifyTimestamped

func VerifyTimestamped(rawBody []byte, header, secret string, opts *TimestampedOptions) bool

VerifyTimestamped is the timestamped dialect, receiver side. Accepts multiple v1 entries in one header (sent during key rotation); any single match passes. Rejects timestamps outside the tolerance window in either direction.

func VerifyTwilio

func VerifyTwilio(url string, params map[string]string, header, authToken string) bool

VerifyTwilio verifies Twilio's X-Twilio-Signature: base64 HMAC-SHA1 over the URL + sorted params base, keyed with the account's auth token. url must be the public URL Twilio called, query string included - not what your app sees behind a proxy.

Types

type Algorithm

type Algorithm int

Algorithm selects the HMAC hash. The zero value is SHA256.

const (
	SHA256 Algorithm = iota
	SHA1
	SHA512
)

type BodyOptions

type BodyOptions struct {
	Algorithm Algorithm
	Encoding  Encoding
}

BodyOptions configures the raw-body dialect. The zero value (and a nil pointer) means HMAC-SHA256, hex-encoded.

type Encoding

type Encoding int

Encoding selects how the HMAC digest is encoded into the signature string. The zero value is Hex.

const (
	Hex Encoding = iota
	Base64
)

type TimestampedOptions

type TimestampedOptions struct {
	// Tolerance is the maximum allowed age (and future clock skew),
	// truncated to whole seconds. Zero means the default, 5 minutes.
	Tolerance time.Duration
	// Now is an injectable clock for tests. The zero value means time.Now().
	Now time.Time
}

TimestampedOptions configures VerifyTimestamped. The zero value (and a nil pointer) means a 5-minute tolerance and the real clock.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL