Webhook signature verification, end to end
A webhook signature covers the exact bytes on the wire, not the object you meant to send. Why re-serialization breaks verification, and how to test it.

A webhook signature does not certify the event you meant to send. It certifies the bytes that carried it: every space, every escaped character, every trailing zero. The mysterious failures (correct secret, authentic sender, rejected delivery) live in that gap, and they reach production first.
Why webhook signatures exist#
Strip the vocabulary away and a webhook endpoint is a URL on the public internet that accepts POST requests. It does
not stay obscure: it sits in provider dashboards, config files, environment dumps, and access logs. Whoever finds it
can send a request shaped exactly like the events you process, and without a proof of origin your handler will treat
a fabricated invoice.paid as revenue.
The proof is a signature (in practice an HMAC, specified in RFC 2104): you and the producer share a signing secret, the producer computes a keyed hash over each delivery, and you recompute it on receipt. A match establishes two things at once: the request was built by a holder of the secret, and the body arrived unmodified. It deliberately establishes nothing about when. A captured delivery verifies just as well next week, which is why a timestamp participates in the scheme.
Standard Webhooks settles how these pieces travel. Every delivery carries three
protected headers: webhook-id, stable across retries; webhook-timestamp, the time of this attempt; and
webhook-signature, one or more versioned signatures. The signed content is the three joined by dots:
webhook-id.webhook-timestamp.body. The headers are recent;
the pattern they protect reached production in February 2008 and waited fifteen years for a specification.
The last segment is where verification breaks in the wild. The contract says body, and it means the bytes of the body, not the JSON value they happen to encode.
Signatures cover bytes, not objects#
The failure mode almost always looks the same. Your framework parses JSON bodies before your handler runs. That is the right default everywhere except this one route. To verify, you take the parsed object, serialize it back to a string, and compute the HMAC over that. It matches on your machine, with your test payload. Weeks later, one integration starts rejecting authentic deliveries signed with the correct secret, and nothing in your code changed.
Nothing needed to. JSON does not promise one byte form per value. RFC 8259
defines an object as "an unordered collection of zero or more name/value pairs" and notes that parsing libraries
differ on whether member order is even visible to the caller. Whitespace between structural characters is
insignificant. Any character may be escaped or left literal, so the same é legally travels as raw UTF-8 or as the
six-byte escape \u00e9. Each variation encodes the same value with different bytes, and an HMAC sees only bytes.
You do not need a hostile network to watch it happen. One round-trip through your own serializer is enough:
const raw = '{"total": 19.90, "currency": "EUR"}';
const reserialized = JSON.stringify(JSON.parse(raw));
// => '{"total":19.9,"currency":"EUR"}'Thirty-five bytes in, thirty-one out. The object is unchanged; three spaces and a trailing zero are gone; the HMAC input is different, so the signature no longer matches, so an authentic delivery gets rejected. The producer signed its serialization, and no discipline in yours will reliably reconstruct bytes you never kept.
The problem is old enough to have a standard aimed at it: RFC 8785 defines a canonical JSON form because hashing and signing need input expressed in an invariant format. Webhook signing does not use canonicalization, and does not need to. The producer already possesses an invariant form of the payload: the bytes it put on the wire.
A signature verifies bytes. Your only job is to still have them.
How to verify a webhook signature correctly#
The discipline compresses well: keep the raw bytes, recompute the HMAC, compare without leaking, bound the clock, and expect more than one signature. As code:
import { createHmac, timingSafeEqual } from "node:crypto";
// Minutes, not hours: clocks drift both ways.
const TOLERANCE_SECONDS = 300;
export function verifyWebhook(rawBody: Buffer, headers: Headers, secret: string): boolean {
const id = headers.get("webhook-id");
const timestamp = headers.get("webhook-timestamp");
const signatureHeader = headers.get("webhook-signature");
if (!id || !timestamp || !signatureHeader) {
return false;
}
// Reject replays and clock drift outside the tolerance window.
const ageSeconds = Math.abs(Date.now() / 1000 - Number(timestamp));
if (!Number.isFinite(ageSeconds) || ageSeconds > TOLERANCE_SECONDS) {
return false;
}
// Secrets ship base64-encoded behind the whsec_ prefix: decode before use.
const key = Buffer.from(secret.replace("whsec_", ""), "base64");
// Concatenation, not serialization: id.timestamp.raw-bytes, as the producer built it.
const signedContent = Buffer.concat([Buffer.from(id + "." + timestamp + "."), rawBody]);
const expected = createHmac("sha256", key).update(signedContent).digest();
// The header is a space-delimited list. Accept any trusted version that matches.
return signatureHeader.split(" ").some((versioned) => {
const [version, value] = versioned.split(",");
if (version !== "v1" || !value) {
return false;
}
const candidate = Buffer.from(value, "base64");
if (candidate.length !== expected.length) {
return false;
}
return timingSafeEqual(candidate, expected);
});
}Hash the raw body, parse it later#
rawBody is the buffer your framework captured before any parsing. It is the only representation of the payload
allowed near the HMAC. When verification succeeds, parse that same buffer. If your handler cannot reach the unparsed
body, that is the first thing to fix; everything after it is decoration.
Decode the secret the way it was issued#
Standard Webhooks secrets are base64-encoded and prefixed with whsec_ so they are recognizable in a config file.
The HMAC key is the decoded bytes, not the ASCII of the encoded string. Hashing the undecoded secret produces the
most confusing failure in this whole area: both sides run the same algorithm over the same content and never agree,
because they disagree about the key.
Compare in constant time#
An ordinary equality check returns at the first byte that differs, so response time reveals how deep a guessed
signature got. Repeat the measurement enough and forgery becomes incremental instead of impossible. A constant-time
comparison closes that channel; the Standard Webhooks specification requires one for symmetric signatures, and most
standard libraries ship one. In the code above it is timingSafeEqual, guarded by the length check it demands.
Bound the timestamp#
A signature proves origin and integrity, never freshness. The webhook-timestamp header is part of the signed
content, so it cannot be forged without breaking the signature. Checking it against your clock is what turns
"authentic" into "authentic and recent". Keep the tolerance in minutes, apply it in both directions to absorb clock
skew, and resist widening it to accommodate retries: the timestamp identifies the delivery attempt, so a retried
webhook arrives with a fresh timestamp and a fresh signature.
Accept every version you trust#
During a secret rotation the webhook-signature header legitimately carries more than one entry. Verify each
candidate against the versions you currently trust and accept if any matches - that is what makes rotation an overlap
instead of an outage. It is also why Repost's signing model exposes active signing-secret versions and
offers an optional overlap window that keeps the previous version available while your verification code catches up.
Reject in a way you can debug#
Log which check failed - missing headers, malformed timestamp, stale timestamp, signature mismatch - and keep the
response terse: a 4xx with no detail. An unknown sender probing your endpoint has no business learning which hurdle
it cleared. You, three weeks from now, chasing a customer report that says only "verification failed", have every
business knowing.
Test it before your customers do#
A verifier that has only ever seen valid deliveries is untested code sitting on a security boundary. Most of its job is rejection, and every rejection path can be exercised with a scratch secret and a handful of constructed deliveries. The function above is pure, so this is plain unit testing.
| Deliver this | Expect |
|---|---|
| A correctly signed delivery | Accepted |
| The same delivery with one body byte changed | Rejected |
| The same object re-serialized (keys reordered, whitespace stripped) with the original signature | Rejected |
| A valid signature with a timestamp outside the tolerance, in either direction | Rejected |
| A signature computed with an unknown secret | Rejected |
| A header listing a garbage signature, then a valid one | Accepted |
| A payload with non-ASCII content: accents, emoji | Accepted |
A delivery with no webhook-signature header at all | Rejected, without a crash |
The third row is the one that catches real bugs. A byte-verifying implementation rejects it unconditionally: the bytes changed, so the signature no longer covers them. An object-verifying implementation accepts it whenever its own serializer happens to reproduce the original layout, the same coincidence that made it pass on your machine and fail on a customer's payload. If that row comes back green for the wrong reason, your verifier is reading objects, not bytes.
Unit vectors prove the algorithm. They do not prove that your framework hands your handler the same bytes the producer signed. That is an integration property of parsers, middleware, and proxies, and it is where raw-body bugs live. So finish end to end, with real deliveries: capture one in a live inbox, inspect the full request the provider actually sent, forward it to the port your app listens on, and replay that same delivery each time you touch the verifier. Iterating against one authentic request beats iterating against your reconstruction of one.
If you are the producer, the same table is your customers' problem, and they will run it against you. Repost's delivery pipeline signs every destination request over the exact body it sends (stable webhook ID, attempt timestamp, versioned signature) and refuses to deliver at all when no signing secret is available. The published event catalog supports signed sample sending, so integrators can exercise their verifier against real signatures before the first production event instead of after it.
Frequently asked questions #
- Why does webhook signature verification keep failing with the correct secret?
- Almost always because the verifier is not hashing the bytes the producer signed. The usual causes, in order: the body was parsed and re-serialized before hashing, so key order, whitespace, escapes, or number formatting changed; the signing secret was used without base64-decoding it first; the comparison mixes encodings, such as hex against base64; or the timestamp fell outside the tolerance window. Start by comparing the length of the body you hash with the Content-Length the producer sent. If they differ, you are not verifying the raw body.
- Can I verify a webhook signature after parsing the JSON body?
- You can parse whenever you like, as long as the bytes you hash are the raw request body captured before any parser touched it. What you cannot do is reconstruct the signed payload by serializing the parsed object: JSON serializers legally differ on key order, whitespace, and character escapes, so the reconstruction is not guaranteed to match the bytes that were signed. Verify the raw buffer first, then parse that same buffer.
- Do I really need a timing-safe comparison for webhook signatures?
- Yes. A plain equality check returns at the first byte that differs, so response times reveal how much of a guessed signature is correct, which turns brute force from impossible into incremental. A constant-time comparison closes that channel at the cost of one line - the Standard Webhooks specification requires it for symmetric signatures.
- What timestamp tolerance should I use for webhook verification?
- A few minutes. The window exists to stop captured deliveries from being replayed later, so it should be measured in minutes, not hours, and applied in both directions to absorb clock skew between you and the producer. Retries do not need a wider window: the timestamp identifies each delivery attempt, so a retried webhook carries a fresh timestamp and a fresh signature.
- How do I rotate a webhook signing secret without breaking verification?
- Overlap, never cutover. The webhook-signature header is a space-delimited list, so a producer can sign with the old and the new secret at once during rotation while consumers verify against every version they trust. Repost exposes active signing-secret versions and offers an optional overlap window that keeps the previous version available while consumer verification code updates.