July 18, 2026
Verifying webhooks: signatures, timestamps, and replay attacks

The first version of our webhook receiver checked exactly one thing: did the incoming request carry a header matching a shared secret we and the sender both knew. It shipped, it worked, and it took a threat-modeling conversation with a colleague, not an actual incident, to notice the gap. A shared secret proves the sender knows the secret. It says nothing about whether this specific request is the one they meant to send right now, or a copy of a request they sent an hour ago, captured somewhere in transit or in a log, and replayed later by someone who has no secret at all, just a saved HTTP request.
What a shared-secret header actually proves, and what it doesn’t
func verify(r *http.Request, sharedSecret string) bool {
return r.Header.Get("X-Webhook-Secret") == sharedSecret
}
This authenticates the sender in the loosest possible sense, if you have the header value, you must have gotten it from somewhere legitimate, at some point. It does not authenticate the request. Anyone who ever captures one valid request, whether from a compromised logging pipeline, a misconfigured proxy that logs full headers, or just a browser extension with too many permissions on a machine that happened to see the traffic, can replay that exact request as many times as they want, forever, and this check passes every single time. For a webhook that fires “payment succeeded, credit the account,” that is not a theoretical gap. It is a way to credit an account repeatedly using one captured request.
Signing the payload with a timestamp inside it
The fix has two parts that need to be layered together, not either one alone. First, sign the actual payload with HMAC using a secret key, so the signature is tied to the specific content, not just proof of secret possession:
func verifySignature(payload []byte, timestamp string, signature string, secret []byte) bool {
// sign timestamp + payload together, not the payload alone
mac := hmac.New(sha256.New, secret)
mac.Write([]byte(timestamp + "."))
mac.Write(payload)
expected := hex.EncodeToString(mac.Sum(nil))
// constant-time comparison: avoid leaking match length via timing
return hmac.Equal([]byte(expected), []byte(signature))
}
func verifyWebhook(r *http.Request, payload []byte, secret []byte) error {
timestamp := r.Header.Get("X-Webhook-Timestamp")
signature := r.Header.Get("X-Webhook-Signature")
ts, err := strconv.ParseInt(timestamp, 10, 64)
if err != nil {
return errors.New("invalid timestamp")
}
if time.Since(time.Unix(ts, 0)) > 5*time.Minute {
return errors.New("timestamp outside tolerance window")
}
if !verifySignature(payload, timestamp, signature, secret) {
return errors.New("signature mismatch")
}
return nil
}
Including the timestamp inside the signed content, not just alongside it as a separate unchecked field, is the part that actually matters. If the timestamp were sent unsigned, an attacker replaying a captured request could just rewrite the timestamp header to look current, since nothing ties that header’s value to the signature. Signing timestamp + "." + payload together means changing the timestamp invalidates the signature, so a replayed request is stuck with its original, now-stale timestamp, which the tolerance-window check rejects.
The constant-time comparison is a smaller but real detail: a naive expected == signature string comparison in most languages returns as soon as it finds the first mismatched byte, which means the total comparison time leaks how many leading bytes matched. Against a network service, that timing difference is measurable and gives an attacker a byte-by-byte oracle to forge a valid signature without ever seeing the secret. hmac.Equal (or whatever your language’s constant-time comparison is called) always takes the same time regardless of where the mismatch is.
Why this is easy to skip and hard to notice missing
Shared-secret verification satisfies the obvious question, “can a random stranger hit this endpoint and have it do anything,” and the answer genuinely becomes no. That is real progress over no verification at all, which is probably why it is where a lot of implementations stop. The replay gap does not show up in normal testing, because normal testing sends a request once and checks the response, and a single legitimate request replaying itself once looks identical to two legitimate requests. It only becomes visible if you specifically ask “what happens if this exact request is sent again tomorrow,” which is a threat-modeling question, not a functional-testing one, and it is easy to ship a webhook receiver without anyone on the team asking it out loud.
What “verified” actually needs to mean
A webhook signature that doesn’t also pin down when is really only answering half the security question. “Who sent this” without “and this specific instance of it, sent now” leaves a captured request as valid forever as a fresh one. Signing the timestamp alongside the payload, rejecting anything outside a tight tolerance window, and comparing signatures in constant time turns “prove you know the secret” into “prove you generated this exact signature, for this exact payload, within the last few minutes,” which is the actual guarantee a webhook receiver needs before it does anything that can’t be undone.