April 11, 2026 10 min read

Webhook Signature Verification in Payment Integrations — The Silent Vulnerability That Almost Cost Us $120K

We trusted incoming webhook payloads for eight months before an attacker figured out they could POST fabricated payment confirmations to our endpoint. Here's how we caught it, what we fixed, and the Go code that now guards every inbound event.

The Incident

It was a Thursday afternoon when our finance team flagged something odd: seventeen orders marked as "paid" in our system had no matching transactions on the payment provider's dashboard. Total exposure: roughly $120,000 in goods that were about to ship for free.

The root cause was embarrassingly simple. Our webhook handler accepted any POST request to /webhooks/payments, parsed the JSON body, and updated order status. No signature check. No timestamp validation. Nothing. An attacker had been replaying slightly modified copies of legitimate webhook payloads — changing the order ID and amount — and our system happily processed every single one.

We caught it before anything shipped, but only because a finance analyst noticed the discrepancy during a manual reconciliation run. If that reconciliation had been a day later, we'd have been out six figures.

How Webhook Signature Verification Actually Works

Most payment providers (Stripe, Adyen, PayPal, etc.) sign every outbound webhook with an HMAC-SHA256 hash. The idea is straightforward: the provider and your server share a secret key. When the provider sends a webhook, it computes a hash of the request body using that key and attaches it as a header. Your server recomputes the hash and compares. If they match, the payload is authentic.

Payment Provider
HMAC-SHA256(secret, body)
POST /webhooks/payments
+ Signature Header
Your Server: Recompute HMAC
Compare signatures
Match
Process Event
Mismatch
Reject (403)

Sounds simple, but there are three places teams consistently get this wrong: using a plain == comparison instead of a constant-time function, ignoring the timestamp header, and reading the body after middleware has already mutated it.

The Go Implementation

Here's the middleware we ended up writing. It's been running in production for over a year now with zero false rejections.

package webhook

import (
    "crypto/hmac"
    "crypto/sha256"
    "encoding/hex"
    "fmt"
    "io"
    "math"
    "net/http"
    "strconv"
    "time"
)

const maxTimestampAge = 5 * time.Minute

func VerifySignature(secret []byte, next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        // 1. Read raw body before anything else touches it
        body, err := io.ReadAll(r.Body)
        if err != nil {
            http.Error(w, "bad request", http.StatusBadRequest)
            return
        }
        defer r.Body.Close()

        // 2. Extract signature and timestamp from headers
        sig := r.Header.Get("X-Webhook-Signature")
        ts := r.Header.Get("X-Webhook-Timestamp")
        if sig == "" || ts == "" {
            http.Error(w, "missing signature headers", http.StatusForbidden)
            return
        }

        // 3. Validate timestamp to prevent replay attacks
        tsUnix, err := strconv.ParseInt(ts, 10, 64)
        if err != nil {
            http.Error(w, "invalid timestamp", http.StatusForbidden)
            return
        }
        age := time.Duration(
            math.Abs(float64(time.Now().Unix()-tsUnix)),
        ) * time.Second
        if age > maxTimestampAge {
            http.Error(w, "timestamp too old", http.StatusForbidden)
            return
        }

        // 4. Compute expected HMAC-SHA256
        //    Sign "timestamp.body" to bind the two together
        mac := hmac.New(sha256.New, secret)
        mac.Write([]byte(fmt.Sprintf("%s.%s", ts, body)))
        expected := hex.EncodeToString(mac.Sum(nil))

        // 5. Constant-time comparison — critical!
        if !hmac.Equal([]byte(expected), []byte(sig)) {
            http.Error(w, "invalid signature", http.StatusForbidden)
            return
        }

        next.ServeHTTP(w, r)
    })
}

Why Each Step Matters

Let me walk through the non-obvious decisions.

Reading the raw body first (step 1). If you let middleware like a JSON decoder consume r.Body before you hash it, you'll be hashing a re-serialized version of the payload. Re-serialization can reorder keys, strip whitespace, or normalize Unicode — any of which changes the hash. Always hash the raw bytes.

Binding the timestamp into the signed payload (step 4). We concatenate the timestamp and body as timestamp.body before hashing. This means an attacker can't take a valid signature from an old webhook and pair it with a new timestamp. The timestamp is part of what's signed.

Constant-time comparison (step 5). A regular string comparison like == short-circuits on the first mismatched byte. An attacker can measure response times to figure out how many leading bytes of their forged signature are correct, then brute-force the rest one byte at a time. hmac.Equal from Go's standard library always takes the same amount of time regardless of where the mismatch occurs.

Key takeaway: Signature verification is not optional for payment webhooks. It's not a "nice to have" or a "we'll add it later" item. If your webhook endpoint accepts unsigned requests, you have an open door for anyone to mark orders as paid, trigger refunds, or manipulate account balances. Treat an unverified webhook handler with the same urgency as an unauthenticated admin panel.

Common Mistakes vs. Correct Approaches

After auditing a handful of codebases (ours included), I kept seeing the same patterns. Here's a quick comparison.

Area Common Mistake Correct Approach
Signature comparison Using == or strings.Compare Using hmac.Equal (constant-time)
Body reading Hashing after JSON decode/re-encode Hashing raw io.ReadAll(r.Body) bytes
Replay protection No timestamp check at all Reject events older than 5 minutes
Timestamp binding Checking timestamp but not including it in HMAC input Signing timestamp.body as a single message
Secret rotation Single secret with no rotation plan Accept multiple secrets during rotation window
Error responses Returning detailed error messages to caller Generic 403 with internal structured logging

Replay Attack Prevention in Practice

Timestamp validation alone isn't bulletproof. If an attacker intercepts a webhook in transit (say, via a compromised log aggregator), they have a five-minute window to replay it. For most payment systems, that's an acceptable risk because the downstream handler should be idempotent — processing the same event ID twice should be a no-op.

But if you want belt-and-suspenders protection, keep a short-lived cache of recently seen event IDs. We use a Redis set with a TTL matching our timestamp window:

// After signature verification passes:
eventID := payload.EventID
added, err := redisClient.SetNX(ctx, "webhook:seen:"+eventID, 1, maxTimestampAge).Result()
if err != nil {
    // Redis down — fail open or closed depending on your risk tolerance
    log.Error("redis check failed", "error", err)
}
if !added {
    // Already processed this exact event
    w.WriteHeader(http.StatusOK)
    return
}

This gives you two layers: the timestamp rejects anything stale, and the deduplication cache catches replays within the valid window. In eighteen months of production traffic, we've seen exactly three legitimate duplicate deliveries caught by this cache — all from the provider's retry logic during network blips.

Lessons Learned

Looking back, the scariest part wasn't the vulnerability itself — it was how long it went unnoticed. Eight months. We had unit tests, integration tests, code reviews, and a security checklist. None of them caught it because nobody thought to test what happens when you send a webhook without a valid signature.

After the incident, we added three things to our process:

  1. Negative test cases for every webhook endpoint. Every handler gets a test that sends a request with a tampered signature and asserts a 403.
  2. A shared middleware library. Individual teams no longer write their own verification logic. There's one package, reviewed and maintained centrally, that every service imports.
  3. Alerting on unsigned requests. We log and alert on any webhook request that arrives without a signature header. Even if we reject it, we want to know someone is probing.

If you're running payment webhooks in production right now, go check your handler. Open the code, search for where you verify the signature. If you can't find it in under thirty seconds, you have the same problem we did.

References

Disclaimer: This article reflects the author's personal experience and opinions. Product names, logos, and brands are property of their respective owners. Pricing and features mentioned are subject to change — always verify with official documentation.