Webhook Signature Verification: Why XOR Instead of === — Defending Against Timing Attacks
When writing code to verify a webhook signature, it's common to compare the computed hash against the received signature with a single line like if (computed === received). It's functionally correct, but from a security standpoint, that one line can leave a subtle hole open. This guide walks through why that hole exists, and how constant-time comparison via XOR accumulation closes it — using the tool's actual implementation as the example.
1. The Timing Gap Created by Short-Circuit Evaluation
In most languages and engines, string comparison walks character by character from the start and returns false the instant it hits a mismatch. This is called short-circuit evaluation. A guess that differs from the correct value at the very first character finishes almost instantly, while a guess that shares a long matching prefix with the correct value takes measurably longer to compare. On a single request, this time difference is buried in network jitter and essentially undetectable — but repeat the same position thousands or tens of thousands of times and average the results, and the gap can become statistically distinguishable. Exploiting this principle to guess the correct hash one character at a time is what's called a timing attack.
2. Why Webhook Signature Verification Is Especially Exposed
A webhook endpoint is, by definition, a URL exposed to the public internet, which means anyone can send it as many repeated requests as they like. There's essentially no way to stop an attacker from submitting arbitrary signature values and timing the responses. If the server verifies the signature with a naive comparison like computed === received, an attacker has the theoretical room to change one character at a time and hunt for the point where the response takes microscopically longer. Pulling this off in practice requires enough samples to overcome network jitter, so the real-world difficulty is high — but "leaving a theoretically exploitable flaw in place" is exactly the kind of thing security audits flag again and again.
3. Constant-Time Comparison: Look at Everything Before You Answer
The fix is simple: always compare the entire length, all the way through, regardless of where a mismatch occurs, so the time the comparison takes is independent of the value itself. This is called constant-time comparison, and the most common implementation XORs each position and OR-accumulates the results. If even one character differs, the XOR result becomes non-zero, and this accumulator is never returned early before the loop finishes. Node.js's standard-library crypto.timingSafeEqual is built on this same principle.
| Comparison method | Behavior | Timing safety |
|---|---|---|
a === b | Returns false immediately at the first mismatched character | Vulnerable (in theory) |
| Length check first + XOR accumulation | Returns false immediately on a length mismatch; if lengths match, XORs every position through to the end before the final verdict | Safe |
What's interesting here is that the "length check first" step itself leaks no information. An HMAC-SHA256 hash is always a fixed 64-character hex string, so as long as you know the signature format, the length is already public information. The only thing that actually needs protecting is the per-character value comparison — and it's enough for just that part to run to completion without early returns.
4. How This Actually Works in GitHub/Stripe Signature Recomputation
Looking at a real implementation that computes HMAC-SHA256 with the browser's built-in Web Crypto API: it hashes the secret key together with the raw body (for Stripe, the string timestamp.body), converts the result to a hex string, and then compares it against the received signature using the XOR-accumulation method described above. It also matters in practice that this entire computation and comparison happens inside the browser with no server involved — none of the secret key, body, or signature is ever sent over the network, because the verification tool itself shouldn't become another leak path.
5. So How Should You Actually Verify Signatures?
- When comparing signatures in server code, use your language's built-in constant-time comparison function: Node.js has
crypto.timingSafeEqual, Python hashmac.compare_digest, and Go hashmac.Equal— all already implemented on this principle, so there's no need to write your own. - If you do have to implement it yourself, only allow early return on a length mismatch (length isn't secret), and never let the loop that compares the actual values break early.
- To learn more about how HMAC generation and verification work, generate values yourself with the HMAC Generator, or inspect the structure of another signed token format like JWT with the JWT Validator.
Frequently Asked Questions
Q. Does using === automatically mean you're going to get hacked?
No. Pulling off this attack in practice requires a huge number of repeated requests and precise timing measurement, enough to overwhelm network latency variance — so the real-world difficulty is high. That said, constant-time comparison costs almost nothing to implement, so there's no good reason to leave that theoretical risk in place.
Q. Isn't returning false immediately on a length mismatch also vulnerable to timing attacks?
When the hash length is fixed by the algorithm, as with HMAC-SHA256, the length itself isn't secret — it's already public information — so returning early at that stage is safe. What needs protecting is the content of the value, not its length.
Q. Why use a library instead of implementing constant-time comparison yourself?
There have been real, documented cases where compiler or JIT optimizations shortened a loop in ways the developer didn't intend, making it hard to guarantee that hand-written code actually runs in constant time. It's safer to use a proven standard implementation like Node's crypto.timingSafeEqual.
Q. Is it safe to do the signature verification itself in the browser?
The verification that actually matters — on the server that receives the real webhook — must always happen in server code. A browser-based tool is meant for previewing and debugging whether a signature a sending service produced matches a particular body/secret combination, and you should confirm it operates in a way where the secret, body, and signature are never sent externally before you rely on it.