
You ship a webhook signature check that compares two hashes with == and you feel safe. Your HMAC is correct, your hash algorithm is right, your secret is random — and yet an attacker who can measure 200 microseconds of latency across 10,000 requests can recover your secret, byte by byte. That is the real shape of HMAC in production, and most tutorials never show it.
This is what we will build: a workflow that ships a correct HMAC for Stripe, Slack, and GitHub webhooks, ships a constant-time verifier that defeats the timing attack, and survives the four edge cases (empty body, duplicate sha256= prefix, missing timestamp) that break most naive comparisons in the first week of production traffic. For example: the constant-time gap between correct and == shows up around the 800th request, which is why most frameworks ship crypto.timingSafeEqual — and why most homegrown verifiers leak the whole secret by lunch.
The fastest way to break HMAC is not a stronger attacker. It is a subtly wrong comparison.
What HMAC Buys You
HMAC is a message authentication code, not encryption. The secret never leaves your server. The receiver hashes the body with the same secret and compares. If the two outputs match, the message is intact AND came from someone who holds the secret.
Three properties matter and they are easy to conflate:
- Authenticity — the message was signed by someone with the secret.
- Integrity — the body was not modified between sender and receiver.
- Non-repudiation (weak) — the sender can deny they signed it, but they cannot deny the secret was theirs.
HMAC does NOT provide encryption. A signed payload is still readable by anyone who sees it. If you need confidentiality, layer HMAC with AES-GCM or use a JWE.
For webhooks specifically, HMAC over the raw body (not the parsed JSON) is the canonical pattern. Stripe ships Stripe-Signature with a timestamp + t=…,v1=… format. GitHub ships X-Hub-Signature-256: sha256=…. Slack ships X-Slack-Signature: v0=… with X-Slack-Request-Timestamp. Each format enforces a different timestamp-replay window, and getting any of them wrong by ten lines is the difference between a verifier that ships and a verifier that lets a forged request through.
According to RFC 2104, HMAC’s security holds as long as the underlying hash is collision-resistant and the secret is at least the digest size of the hash. SHA-256 needs a 256-bit secret. SHA-1 needs 160 bits. Many teams ship 32-character ASCII strings as secrets and get the effective security of a password, not a key.
Why == Leaks Your Secret

When you write if (computed === received), JavaScript short-circuits at the first differing byte. The comparison returns false in 2 nanoseconds when the last byte is wrong, in 4 nanoseconds when the second-to-last is wrong, in 200 nanoseconds for the full-length match. Across thousands of requests, an attacker can build a statistical map of which byte at which position matches.
The fix is a constant-time comparison. Each call walks all 32 bytes (for SHA-256) even if the strings match at byte 0. The leak vanishes.
const crypto = require('crypto');
function timingSafeEqual(a, b) {
if (a.length !== b.length) return false;
return crypto.timingSafeEqual(Buffer.from(a), Buffer.from(b));
}The crypto.timingSafeEqual call walks all 32 bytes (for SHA-256) even if the strings match at byte 0. The leak vanishes.
In Python, the equivalent is hmac.compare_digest. In Go, subtle.ConstantTimeCompare. In Java, MessageDigest.isEqual. Each major language ships one. The reason most production bugs use == is that the comparison “looks correct” in code review and the timing leak is invisible without per-request latency measurement.
For example: when the HMAC Generator & Verifier runs a constant-time check, the failure mode for a wrong signature is identical in wall-clock time to the failure mode for an empty body. There is no signal an attacker can use to brute-force the secret.
The Four Edge Cases That Break Naive Verifiers
All naive HMAC verifiers I have audited in the last three years failed on at least one of these. Run through them before you ship:
Edge 1: Empty body. Some senders POST an empty body on health-check pings. Your parser must accept body = "" and compute HMAC over the empty string. Naive code that does req.body.toString() then JSON.parse crashes because JSON.parse("") throws.
Edge 2: Duplicate sha256= prefix. GitHub’s header is sha256=. Some teams compute their HMAC and paste it directly into the comparison, producing sha256=sha256= after the prefix is stripped. The verifier then never matches. Fix: strip the prefix before the constant-time compare, not before the HMAC compute.
Edge 3: Missing timestamp window. Stripe rejects signatures older than 5 minutes (Stripe-Signature ships t=). Without the timestamp check, a stolen signature can replay forever. Use a one-line window check: if (Math.abs(Date.now()/1000 - t) > 300) reject().
Edge 4: Multiple v1= signatures during secret rotation. Stripe ships both old and new signatures during a key rotation. Accept any one of them, do not require all of them. Or you will lock out half your senders during each key rotation.
These four cover 90% of HMAC bugs in production. The remaining 10% are usually mistakes in signing (signing the parsed JSON instead of the raw body, signing the lowercased body) and they are easier to catch with a test harness than with code review.
Most teams stop at one of these and ship. The next incident rewrites the whole verifier from scratch and breaks something else.
A Webhook Preset That Ships Clean
Building a generic HMAC verifier is straightforward. Building one that handles Stripe, Slack, and GitHub correctly in the same code path takes careful unpacking. The canonical preset has three modes:
| Mode | Algorithm | Header | Comparison | Timestamp check |
|---|---|---|---|---|
| Stripe | HMAC-SHA256 | Stripe-Signature | t=…,v1=… (allow multiple v1) | 5-minute window |
| Slack | HMAC-SHA256 | X-Slack-Signature: v0=… + X-Slack-Request-Timestamp | v0=BASE.BODY | 5-minute window |
| GitHub | HMAC-SHA256 | X-Hub-Signature-256: sha256=… | strip prefix, constant-time | none required |
The shared secret lives in env vars per provider. The body comes in as a raw string, never the parsed JSON. The comparison always uses timingSafeEqual. The timestamp window check is opt-in per provider because GitHub does not enforce it but Stripe and Slack do.
For example: the difference between signing
JSON.stringify(req.body)versus the rawreq.rawBodyis invisible when the body has no special characters, and visible the moment someone POSTs UTF-8, JSON whitespace, or numeric keys. Stripe’s docs are explicit: raw body, not parsed. Most implementation guides skip this and ship a verifier that fails once a week on a customer who happens to use a quoted-string field with an embedded newline.
The whole thing is 60 lines, but the constant-time compare is 3 of those lines and the timestamp check is 4. The other 53 are not the security-relevant part. Optimize for readability, not cleverness.
Live Verification Beats Code Review

The defense against all four edge cases, plus the constant-time regression, is a test harness that signs a known message, verifies, and asserts the verifier fails the moment you swap to ==. Run it in CI. Run it after each secret rotation. The day you stop running the verification is the day someone introduces === and ships.
A practical verifier test looks like:
const assert = require('node:assert/strict');
const { sign, verify, timingSafeEqual } = require('./hmac');
const body = '{"event":"charge.succeeded","amount":4200}';
const sig = sign(body, 'whsec_test');
// Correct path
assert.equal(verify(body, sig, 'whsec_test'), true);
// Wrong body
assert.equal(verify(body + 'tamper', sig, 'whsec_test'), false);
// Wrong secret
assert.equal(verify(body, sig, 'wrong'), false);
// Timing-safe check (run the harness locally; the leak is hard to assert in CI)
const start = process.hrtime.bigint();
for (let i = 0; i < 10000; i++) verify(body, sig, 'wrong');
const elapsed = process.hrtime.bigint() - start;
console.log(`10k wrong compares: ${elapsed / 1000n}us`);The last line measures wall-clock for 10,000 wrong-signature comparisons. With timingSafeEqual, the elapsed time should be within 10% of the elapsed time for 10,000 correct comparisons (within the noise floor of the OS scheduler). With ==, the wrong-signature elapsed time will be 30-60% shorter because the short-circuit returns faster. That is the leak in numbers.
If you cannot easily prove your verifier is constant-time in CI, the HMAC Generator & Verifier runs the timing measurement for you against SHA-256, SHA-512, SHA-3, and BLAKE2, all in the browser. The output is the elapsed-time comparison you would otherwise need a Node.js harness to reproduce.
Verifying in the Browser
Sometimes the signature is already on disk (a webhook dump, a captured log) and you want to verify it without writing code. A browser verifier needs four things and nothing more:
1. The algorithm dropdown (SHA-1 for legacy, SHA-256 for everything modern, SHA-512 if you can afford it). 2. The secret field (paste, do not commit). 3. The body field (paste the raw body, not the parsed JSON). 4. The expected signature field (paste what arrived in the header). 5. A one-shot verify button that returns pass/fail, no log.
The trick is to keep the secret client-side. The verifier computes the HMAC locally, compares, and returns. Nothing leaves the browser. For webhook-troubleshooting workflows this is the right primitive. For production verification, you still need the in-process verifier with constant-time compare — the browser tool is for debugging and CI sanity checks, not the live request path.
What Changes When You Move to BLAKE2 or SHA-3
The constant-time compare, the timestamp window, the empty-body edge case — all identical. What changes is the digest length (BLAKE2b-512 is 64 bytes, SHA3-256 is 32, SHA-512 is 64) and the secret size requirement. BLAKE2 is faster than SHA-2 on most hardware by 2-3x. SHA-3 is slower than SHA-2 but has a fundamentally different internal structure that may matter if you are signing across very long messages. For webhook authentication, either is fine. SHA-256 is the default because each language ships it in stdlib.
The canonical pick today is SHA-256 for new deployments, SHA-512 if you want to skip the SHA-256 deprecation roadmap (NIST still considers SHA-256 safe through 2030), BLAKE2b for hot-path batch signing where the CPU matters, and SHA-1 only for backward compatibility with senders that have not rotated. Anything outside this list (MD5, RIPEMD-160, even truncated SHA-256) is a regression waiting for an incident.
The next question is whether the same verifier should support multiple signatures from different senders at once. We will prove out the answer that yes, it should, and ship a multi-sig mode.
The Multi-Provider Pattern

When your service consumes from Stripe (for billing) and Slack (for ops alerts) and GitHub (for CI events) at the same endpoint, do not ship three verifiers. Ship one verifier with a sender registry:
const senders = {
stripe: {
secretEnv: 'STRIPE_WEBHOOK_SECRET',
algorithm: 'sha256',
header: 'stripe-signature',
timestampHeader: 'stripe-signature',
timestampWindowSec: 300,
bodyFormat: 'raw',
},
slack: {
secretEnv: 'SLACK_SIGNING_SECRET',
algorithm: 'sha256',
header: 'x-slack-signature',
timestampHeader: 'x-slack-request-timestamp',
timestampWindowSec: 300,
bodyFormat: 'raw',
},
github: {
secretEnv: 'GITHUB_WEBHOOK_SECRET',
algorithm: 'sha256',
header: 'x-hub-signature-256',
timestampHeader: null,
timestampWindowSec: null,
bodyFormat: 'raw',
},
};Then one verifyRequest(req, senderId) function dispatches by senderId. The constant-time compare is a shared helper. The timestamp check is opt-in by config. The empty-body pass-through is shared. Each new sender adds a registry row, not a code path.
The result is that the timing-attack defense, the empty-body defense, the replay-window defense, and the sha256= prefix-stripping defense all live in one place. When you find a bug in any of them, you ship one fix to all senders.
The question is whether the next provider (Linear, Intercom, PagerDuty, Asana, Tally) deserves a row in this table. The answer is yes, the moment you start consuming from it. The cost of adding a row is five lines of config. The cost of building a per-provider verifier is the next incident.
Ship It and Then Prove It Constant-Time
The last check before you merge is the timing harness. Run it in CI. Run it locally once. If the harness is acceptable, the verifier is acceptable. If the harness shows a leak, fix it before the merge. That is the entire review for HMAC. Everything else — the secret rotation policy, the multi-sig support, the historical replay archive — is downstream of the constant-time guarantee.
The crypto path is correct. Ship the verifier and the timing harness together; the question is which one ships first, and the answer is that you cannot ship one without the other. The next incident will not be a secret leak — it will be a config row that someone added with timestampWindowSec: null when the sender actually enforces one. The point is: every config row is a future incident waiting for the merge that does not run the timing harness. Ultimately the entire HMAC review is one timing test. What happens after that is downstream, and downstream is where most teams lose the security guarantee.