Webhook security has two sides: verifying that incoming events are genuine (signature verification), and proving to your customers that outgoing events came from you (outbound signing). Both use the same cryptographic primitive: HMAC-SHA256.
This post explains how it works, where it's commonly implemented wrong, and how to do it correctly.
What Is HMAC?
HMAC stands for Hash-based Message Authentication Code. It combines a secret key with a message to produce an authentication code:
HMAC-SHA256(key, message) → signature
The signature has two properties:
- ›Authentication — only someone who knows the key can produce the same signature
- ›Integrity — any modification to the message produces a different signature
For webhooks, the "message" is the request body, and the "key" is a shared secret known only to you and the webhook provider.
How Stripe Signs Webhooks
Stripe's format is the de-facto standard and worth understanding in detail:
Stripe-Signature: t=1614556800,v1=abc123...,v0=def456...
- ›
t— Unix timestamp of when Stripe sent the event - ›
v1— HMAC-SHA256 signature - ›
v0— Deprecated MD5 signature (ignore this)
The signed payload is: {timestamp}.{body}
signed_payload = f"{timestamp}.{request_body}"
signature = hmac.new(secret.encode(), signed_payload.encode(), sha256).hexdigest()
Why include the timestamp? This is a replay attack prevention mechanism. If an attacker captures a valid webhook request and resends it later, the timestamp in the signature will be old. You should reject events where t is more than 5 minutes old:
const maxAge = 5 * time.Minute
webhookTime := time.Unix(timestamp, 0)
if time.Since(webhookTime) > maxAge {
return errors.New("webhook timestamp too old — possible replay attack")
}
Signature Verification by Provider
Each provider has a slightly different format. Here's a reference:
GitHub
X-Hub-Signature-256: sha256=abc123...
func verifyGitHub(body []byte, header, secret string) bool {
// Strip "sha256=" prefix
sig := strings.TrimPrefix(header, "sha256=")
mac := hmac.New(sha256.New, []byte(secret))
mac.Write(body)
expected := hex.EncodeToString(mac.Sum(nil))
return hmac.Equal([]byte(expected), []byte(sig))
}
Shopify
X-Shopify-Hmac-SHA256: base64encodedhmac==
func verifyShopify(body []byte, header, secret string) bool {
mac := hmac.New(sha256.New, []byte(secret))
mac.Write(body)
expected := base64.StdEncoding.EncodeToString(mac.Sum(nil))
return hmac.Equal([]byte(expected), []byte(header))
}
Slack
X-Slack-Signature: v0=abc123...
X-Slack-Request-Timestamp: 1614556800
func verifySlack(body []byte, sig, timestamp, secret string) bool {
baseString := fmt.Sprintf("v0:%s:%s", timestamp, string(body))
mac := hmac.New(sha256.New, []byte(secret))
mac.Write([]byte(baseString))
expected := "v0=" + hex.EncodeToString(mac.Sum(nil))
return hmac.Equal([]byte(expected), []byte(sig))
}
The Timing Attack Problem
Here's the most common security mistake in signature verification:
// ❌ VULNERABLE — timing side-channel
if computedSig != receivedSig {
return false
}
Why is this dangerous? String comparison in most languages returns false as soon as it finds the first non-matching character. This means:
- ›A signature with a correct first byte takes slightly longer to reject than one with a wrong first byte
- ›An attacker can measure these tiny timing differences and learn the correct signature one byte at a time
- ›This is a timing attack, and it works against any string comparison done byte-by-byte
// ✅ SAFE — constant-time comparison
if !hmac.Equal([]byte(computedSig), []byte(receivedSig)) {
return false
}
hmac.Equal (Go), hmac.compare_digest (Python), crypto.timingSafeEqual (Node.js) all compare the full string in constant time regardless of where the first mismatch occurs.
| Language | Constant-time function |
|---|---|
| Go | crypto/hmac.Equal(a, b []byte) bool |
| Python | hmac.compare_digest(a, b) |
| Node.js | crypto.timingSafeEqual(a, b) |
| Ruby | ActiveSupport::SecurityUtils.secure_compare(a, b) |
| PHP | hash_equals($a, $b) |
| Java | MessageDigest.isEqual(a, b) |
Storing Secrets Safely: AES-256-GCM
Signatures solve the transmission problem. But where do you store the webhook signing secret?
If you store secrets in plaintext in your database:
- ›A database breach exposes all customer signing secrets
- ›An attacker can forge webhooks to any of your customers' endpoints
GetHook uses AES-256-GCM (Authenticated Encryption) to encrypt secrets at rest.
func Encrypt(plaintext, key []byte) ([]byte, error) {
block, err := aes.NewCipher(key) // 32-byte key → AES-256
if err != nil {
return nil, err
}
gcm, err := cipher.NewGCM(block) // GCM mode for authenticated encryption
if err != nil {
return nil, err
}
nonce := make([]byte, gcm.NonceSize())
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
return nil, err
}
// Seal appends: nonce || ciphertext || GCM tag
ciphertext := gcm.Seal(nonce, nonce, plaintext, nil)
return ciphertext, nil
}
Why GCM specifically? AES-GCM provides authenticated encryption — it encrypts the data AND provides a MAC (authentication tag). If the ciphertext is tampered with in the database, decryption fails with an authentication error. This prevents padding oracle attacks and ciphertext manipulation.
Key Management
AES encryption is only as strong as your key management. GetHook requires an ENCRYPTION_KEY environment variable (64 hex characters = 32 bytes) and refuses to start without it.
Best practices:
- ›Never commit the key to git — use
.envfiles excluded from version control - ›Rotate keys on schedule — re-encrypt all secrets with the new key on rotation
- ›Different keys per environment — dev, staging, and production should have different keys
- ›Key derivation for per-customer secrets — use HKDF to derive unique keys per customer rather than using the master key directly
The Full Security Model
Here's GetHook's complete security model for secrets:
┌─────────────────────────────────────────────────────────────────┐
│ INBOUND (Provider → GetHook) │
│ │
│ Provider signs with HMAC-SHA256 │
│ GetHook verifies signature using stored (encrypted) secret │
│ Signature verified before event enters queue │
│ Invalid signatures rejected with 401 │
└─────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────┐
│ OUTBOUND (GetHook → Your Customer) │
│ │
│ Per-destination signing secret (encrypted with AES-256-GCM) │
│ Decrypted in memory only at delivery time │
│ Signs payload with Stripe-compatible format │
│ Secret never returned in API responses after creation │
└─────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────┐
│ AT REST (Database) │
│ │
│ Signing secrets: AES-256-GCM encrypted │
│ API keys: SHA-256 hashed (one-way, not recoverable) │
│ Event payloads: stored as-is (customer controls what to send) │
└─────────────────────────────────────────────────────────────────┘
Security Checklist for Webhook Implementers
If you're building a webhook system, verify each item:
Inbound verification:
- › Compute HMAC over the raw request body (before JSON parsing)
- › Use constant-time comparison
- › Validate timestamp (reject events older than 5 minutes)
- › Return 401 on signature mismatch (not 400, which some providers treat as "permanent failure")
- › Log signature failures (security monitoring signal)
Secret storage:
- › Encrypt secrets at rest (AES-256-GCM minimum)
- › Never log secrets
- › Never return secrets in API responses after initial creation
- › Separate encryption keys from application secrets
Outbound signing:
- › Sign with per-customer secrets (not a global key)
- › Include timestamp in signed payload (prevent replay)
- › Document the signature format for your customers
Why GetHook Handles This For You
Implementing all of the above correctly is achievable. Doing it for every provider format (Stripe, GitHub, Shopify, Slack, Twilio, PagerDuty, and the dozen others your customers will ask for) is a significant ongoing maintenance burden.
Provider formats change. Shopify updated their signature format in 2023. GitHub deprecated SHA-1 and moved to SHA-256. Slack added additional signed fields. If you're maintaining your own verification engine, you need to track all of these changes.
GetHook maintains provider-specific verification presets and keeps them up to date. Your handler never needs to know which format the event came in — verification happens at the gateway before the event ever reaches your code.