Your webhook signature check is probably comparing strings wrong
Why == leaks timing information when you verify HMAC signatures, and how to fix it in Python, Node.js, Go and PHP.
Almost every service that sends webhooks — payment providers, Git hosting, messaging platforms — signs the payload with an HMAC so you can check it really came from them. The verification code usually fits in three lines. It's also one of the places where we most often leave a finding.
The pattern
expected = hmac.new(secret, request.body, hashlib.sha256).hexdigest()
if request.headers["X-Signature"] == expected:
handle(request)
This looks correct, and functionally it is. The problem is ==.
Why == is the wrong tool
String comparison in most runtimes returns as soon as it finds the first byte that differs. A signature that matches the first ten characters takes slightly longer to reject than one that matches none. The difference is tiny and buried in network jitter, so a single request tells an attacker nothing.
But attackers don't send a single request. With enough samples and some statistics, differences that small have been measured across local networks and within the same cloud region. The attack recovers the expected signature for a chosen payload one position at a time, which turns "guess a 256-bit value" into "guess 64 hex characters, one by one".
Is this the most likely way your system gets breached? No. But the fix costs nothing, so there's no reason to spend a meeting arguing about exploitability.
The fix: constant-time comparison
Every mainstream language ships a comparison function whose running time doesn't depend on where the inputs differ.
Python
import hashlib
import hmac
expected = hmac.new(secret, raw_body, hashlib.sha256).hexdigest()
received = request.headers.get("X-Signature", "")
if not hmac.compare_digest(received, expected):
abort(401)
With str arguments, compare_digest only accepts ASCII and raises TypeError otherwise — one more reason to make sure every exception on this path ends in a 401.
Node.js
const crypto = require("node:crypto");
const expected = crypto.createHmac("sha256", secret).update(rawBody).digest();
const received = Buffer.from(req.get("X-Signature") ?? "", "hex");
if (received.length !== expected.length ||
!crypto.timingSafeEqual(received, expected)) {
return res.sendStatus(401);
}
timingSafeEqual throws if the buffers have different lengths, so check the length first. Leaking the length is fine: it's public anyway.
Go
mac := hmac.New(sha256.New, secret)
mac.Write(body)
expected := mac.Sum(nil)
received, err := hex.DecodeString(r.Header.Get("X-Signature"))
if err != nil || !hmac.Equal(received, expected) {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
PHP
$expected = hash_hmac('sha256', $rawBody, $secret);
if (!hash_equals($expected, $_SERVER['HTTP_X_SIGNATURE'] ?? '')) {
http_response_code(401);
exit;
}
Three mistakes that usually sit next to this one
- Signing the parsed body instead of the raw one. If your framework parses JSON and you re-serialize it before computing the HMAC, key order and whitespace can change. Verification starts failing intermittently, and someone "fixes" it by skipping the check. Always compute the HMAC over the exact bytes you received.
- No timestamp, no replay protection. A valid signed request can be captured and replayed indefinitely. Well-designed providers include a timestamp in the signed data — reject anything older than a few minutes.
- Failing open. A missing header, an exception inside the verification code, or an empty secret in a freshly created environment should all lead to a rejected request, not a skipped check.
Checklist
- Compare signatures with a constant-time function, never
==. - Compute the HMAC over the raw request body.
- Validate a signed timestamp and enforce a short tolerance window.
- Treat every error path as "reject".
- Keep the webhook secret out of the repository, and rotate it if it leaks.
Want a second pair of eyes on your code? Get in touch.