Verifying webhook signatures with HMAC: a practical guide
Never trust a webhook until you verify its signature over the raw body. HMAC verification in Node, Python and Go, plus timestamp and replay checks.

On this page(10 sections)
A webhook endpoint is a public URL that changes your database when someone posts JSON to it. Without signature verification, anyone who discovers the URL can mark messages as bounced, unsubscribe your users or trigger downstream automation.
Verifying an HMAC signature takes a dozen lines of code, and most of the bugs come from getting those lines subtly wrong.
How HMAC webhook signatures work
The sender and receiver share a secret, created when you register the endpoint. For each delivery, the sender computes an HMAC, a keyed hash, over the exact bytes of the request body using that secret, and puts the result in a header:
POST /webhooks/email HTTP/1.1
Content-Type: application/json
X-Signature: sha256=5d2f1c...
X-Timestamp: 1790553600
{"event":"message.bounced","message_id":"msg_01J8Z...","to":"[email protected]"}
The receiver computes the same HMAC over the body it received, with its copy of the secret, and compares. If they match, the body came from someone who knows the secret and was not modified in transit.
Header names and formats vary between providers. Some sign only the body; others sign a string that combines a timestamp, a message ID and the body. Always follow your provider's documentation for exactly what is signed. The principles below apply to all of them.
Rule 1: verify the raw body
The signature covers specific bytes. If your framework parses the JSON and you re-serialize it before verifying, whitespace, key order or number formatting may change, and the signature will not match, or worse, you will be tempted to "fix" verification by weakening it.
Capture the raw request body before any parsing middleware touches it, verify the signature over those bytes, and only then parse.
Rule 2: compare in constant time
A normal string comparison returns as soon as it finds a differing character. In principle, an attacker can measure response times to learn how many leading characters of their forged signature are correct. Constant-time comparison functions take the same time regardless of where the strings differ. Every mainstream language has one; use it.
Rule 3: check a timestamp to limit replay
A valid signed request can be captured and resent. If the provider includes a timestamp in the signed content, reject requests whose timestamp is too far from your current time, for example more than five minutes. If the provider sends a unique delivery ID, also record processed IDs so a replay within the window is ignored.
Only trust a timestamp that is covered by the signature. A timestamp header that is not part of the signed content can be changed freely by an attacker.
Implementations
The examples assume the provider signs the string "{timestamp}.{raw_body}" with HMAC-SHA256 and sends the hex digest as sha256=<hex>. Adapt the signed string to your provider's scheme.
Node.js
import crypto from "node:crypto";
export function verify(rawBody, sigHeader, tsHeader, secret, toleranceSec = 300) {
const ts = Number(tsHeader);
if (!Number.isFinite(ts) || Math.abs(Date.now() / 1000 - ts) > toleranceSec) return false;
const expected = "sha256=" + crypto
.createHmac("sha256", secret)
.update(`${ts}.`).update(rawBody)
.digest("hex");
const a = Buffer.from(expected);
const b = Buffer.from(String(sigHeader || ""));
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
With Express, capture the raw body for the webhook route using express.raw({ type: "application/json" }) rather than the JSON parser.
Python
import hashlib, hmac, time
def verify(raw_body: bytes, sig_header: str, ts_header: str, secret: str, tolerance: int = 300) -> bool:
try:
ts = int(ts_header)
except (TypeError, ValueError):
return False
if abs(time.time() - ts) > tolerance:
return False
mac = hmac.new(secret.encode(), f"{ts}.".encode() + raw_body, hashlib.sha256)
expected = "sha256=" + mac.hexdigest()
return hmac.compare_digest(expected, sig_header or "")
In Flask, use request.get_data(); in Django, request.body; in FastAPI, await request.body(), all before parsing JSON.
Go
func verify(body []byte, sigHeader, tsHeader, secret string, tolerance time.Duration) bool {
ts, err := strconv.ParseInt(tsHeader, 10, 64)
if err != nil {
return false
}
if d := time.Since(time.Unix(ts, 0)); d > tolerance || d < -tolerance {
return false
}
mac := hmac.New(sha256.New, []byte(secret))
mac.Write([]byte(tsHeader + "."))
mac.Write(body)
expected := "sha256=" + hex.EncodeToString(mac.Sum(nil))
return hmac.Equal([]byte(expected), []byte(sigHeader))
}
Read the body once with io.ReadAll(r.Body), verify, then unmarshal from the same byte slice.
Testing your verifier
Signature code is easy to get almost right, so test it deliberately rather than waiting for production traffic:
- A known-good fixture. Capture one real delivery (body, headers and the secret from a test endpoint) and assert that your function returns true for it.
- A tampered body. Change one character of the payload and assert false.
- A wrong secret. Assert false.
- A stale timestamp. Shift the timestamp outside the tolerance window, recompute a valid signature for it, and assert false. This proves the timestamp check runs even when the signature is otherwise correct.
- A missing or malformed header. Assert false without throwing an unhandled exception.
- The framework path. Send a request through your real routing and middleware in an integration test, which catches the most common production bug: a JSON parser consuming the body before your verifier sees the raw bytes.
Many providers also offer a "send test event" button in their dashboard. Use it after every deploy that touches the webhook route.
Rotating the secret without downtime
Secrets need rotation when staff change, when one may have leaked, or on a schedule. Many providers let an endpoint have two active secrets during a transition, and some send multiple signatures in one header. Your verifier should accept a list of secrets and pass if any of them matches:
- Add the new secret to your configuration alongside the old one.
- Rotate the secret at the provider.
- Once all deliveries verify with the new secret, remove the old one.
Other hardening worth doing
- HTTPS only. Signatures prove origin and integrity, but the payload may contain personal data such as email addresses.
- Respond quickly. Verify, persist the event, return
2xx, and process asynchronously. Slow handlers cause provider timeouts and retries. - Return
401or400on verification failure, and log it with the source IP, but never log the secret or the full computed signature. - Do not use IP allowlists as the only check. They are a reasonable extra layer if the provider publishes stable ranges, but they are brittle and do not protect the payload's integrity.
- Keep the secret out of source control, in the same secret store as your API keys.
Verification checklist
- Raw body captured before JSON parsing.
- HMAC computed over exactly what the provider signs.
- Constant-time comparison.
- Signed timestamp checked against a tolerance window.
- Delivery IDs recorded to drop replays and duplicates.
- Multiple secrets supported for rotation.
- Fast
2xxresponse with asynchronous processing.
Key takeaways
- Webhook endpoints are public, so every delivery must be verified before it changes state.
- Verify HMAC over the raw body, not a re-serialized object.
- Use
timingSafeEqual,hmac.compare_digestorhmac.Equalfor comparison. - Reject stale timestamps and duplicate delivery IDs to limit replay.
- Support two secrets at once so rotation never breaks deliveries.
Start with Koltrix
Your domain, one inbox, and an API that sends.
A team inbox where AI sorts and drafts (nothing is sent without your click), plus the transactional API and SMTP relay your product sends with. 7 days free, no card.


