← Back to all engineering articles

Webhook Security Primer: Verifying HMAC Signatures, Preventing Replay Attacks, and Secret Header Guards

Because webhooks operate over the public internet, any malicious actor who discovers your endpoint URL can forge HTTP POST requests pretending to be Stripe, GitHub, or Shopify. Relying solely on IP allowlisting or unauthenticated URLs leaves your financial transactions and provisioning workflows completely exposed.

The industry standard defense is HMAC (Hash-based Message Authentication Code) signatures. Here is a definitive guide on how signature verification works, common vulnerabilities developers introduce, and how to test failure modes safely.

The Signature Protocol Anatomy

When a webhook provider sends an event, it computes an HMAC-SHA256 signature using a shared secret key known only to you and the provider.

The provider delivers the signature in a custom HTTP header:

The 3 Critical Rules of Secure Verification

1. Always use Raw Bytes, Never Re-serialized JSON

A frequent bug in web frameworks (like Express or Django) is parsing the request body into a JSON dictionary, and then calling JSON.stringify(body) to compute the HMAC.

Warning:

JSON serialization does not guarantee key ordering or whitespace preservation. If the provider sent {"a":1,"b":2} and your parser re-encodes it as {"b":2,"a":1}, the hash will never match, causing false rejections.

Always verify against the exact, unparsed rawBody bytes received over the wire.

2. Defeat Timing Attacks with Constant-Time Comparison

Standard string comparisons (e.g. signature === expected or ==) return false the moment the first differing byte is found. An attacker measuring round-trip latency over millions of requests can deduce characters one by one.

In Go, always use crypto/subtle.ConstantTimeCompare:

import (
    "crypto/hmac"
    "crypto/sha256"
    "crypto/subtle"
    "encoding/hex"
)

func VerifySignature(payload []byte, secret string, headerSig string) bool {
    mac := hmac.New(sha256.New, []byte(secret))
    mac.Write(payload)
    expected := hex.EncodeToString(mac.Sum(nil))

    return subtle.ConstantTimeCompare([]byte(headerSig), []byte(expected)) == 1
}

3. Enforce Timestamp Tolerance to Prevent Replay Attacks

If an eavesdropper intercepts a valid signed webhook, they can resend that exact byte stream repeatedly (e.g., triggering multiple duplicate refund events).

Stripe and Slack include a Unix timestamp t=... inside the signed payload. When verifying, reject any webhook where:

if math.Abs(time.Now().Sub(requestTime).Seconds()) > 300 {
    // Reject requests older or newer than 5 minutes
    http.Error(w, "timestamp outside tolerance window", http.StatusUnauthorized)
    return
}

Testing Failure Modes in SnapHook

In SnapHook Pro, you can configure Secret Header Gating directly on your persistent endpoints:

{
  "secret_header_key": "X-Hub-Signature-256",
  "secret_header_value": "sha256=test_secret_123",
  "mock_status": 200
}

If a test request does not match the secret header, SnapHook rejects the request with 401 Unauthorized and records the mismatch in the live inspector, allowing you to observe your client's error-handling paths without writing custom test servers.


Inspect your webhooks live in 2 seconds

No signup required. SnapHook creates a dedicated URL, streams incoming HTTP payloads over SSE, and lets you customize synthetic status codes and latency.

Next Up from the Blog

Engineering 7 min read

Stop Guessing What Your Webhooks Actually Send

Why ngrok tunnels, local loopbacks, and temporary console.log server deploys break down when inspecting production webhooks, and how in-memory SSE ring buffers provide zero-leak, zero-install ephemeral capture.