# 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.

- Author: Quentin Mousset
- Published: 2026-07-13
- Canonical: https://repost.sh/blog/signature-verification-end-to-end

![The same JSON object in two serializations: the producer's 35-byte body and the 31-byte string that comes back from JSON.parse then JSON.stringify. Each one hashes to a different HMAC-SHA256 under the same signing secret, so only the producer's bytes match the webhook-signature header and the re-serialized copy is rejected.](/images/blog/figure-bytes-not-objects.webp)

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.

## Key takeaways

- A webhook signature is an HMAC over the exact bytes of the request body. Verify the raw body, never a re-serialized version of the parsed object.
- JSON has no canonical byte form: RFC 8259 lets the same object serialize with different key order, whitespace, and escapes, so parse-then-stringify changes the signed bytes.
- Correct verification is four checks: recompute HMAC-SHA256 over webhook-id.webhook-timestamp.raw-body, compare in constant time, enforce a timestamp tolerance, and accept every trusted signature version.
- Test the rejection paths, not the happy path: a tampered byte, a re-serialized body, a stale timestamp, and an unknown secret must all fail before a customer sees them.

## 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](https://www.rfc-editor.org/rfc/rfc2104)):
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](/docs/send/signing) 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](/blog/who-invented-webhooks).

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](https://www.rfc-editor.org/rfc/rfc8259)
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:

```ts
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](https://www.rfc-editor.org/rfc/rfc8785) 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.

> **The bytes disappear early**: If a global JSON parser runs before your handler, the raw body may be gone by the time your code executes. Most
>     frameworks can retain it (a raw-body option, an unparsed stream, or a per-route exemption from parsing). Whatever
>     the mechanism: capture first, verify, then parse.

## 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:

```ts
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](/security) 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](/docs/send/signing) 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.

**See it on your own events.** Open a live inbox and inspect your first webhook in seconds (50K events/month free).
