# Webhook signature verification: HMAC-SHA256 and replay protection

> A webhook is authenticated by an HMAC-SHA256 signature the sender computes over a timestamp plus the exact raw body, using a shared secret, and sends in a header. The receiver recomputes the HMAC, compares it in constant time, and rejects the request if the signature mismatches or the timestamp is outside a tolerance window (replay protection).

- Canonical URL: https://www.broadnet.me/reference/webhook-signature-verification
- Language: en
- Last updated: 2026-07-19
- Publisher: BroadNet (https://www.broadnet.me)

How to verify a webhook is authentic and untampered: HMAC-SHA256 over the raw body, the signature and timestamp headers, replay protection with a timestamp tolerance and nonce, constant-time comparison to defeat timing attacks, and safe secret rotation with overlapping keys.

## Why signatures, not IP allowlists

A webhook endpoint is a public HTTPS URL, so anyone can POST to it. TLS proves the client reached your server; it does nothing to prove the request actually came from the messaging platform. IP allowlisting is brittle (sender IPs change, and it fails behind shared egress). The standard answer is a keyed message authentication code: the sender and receiver share a secret, the sender signs each payload with it, and the receiver verifies. HMAC (RFC 2104) with SHA-256 (RFC 6234) is the ubiquitous construction because it is fast, standardised, and — unlike a bare hash of secret concatenated with message — provably resistant to length-extension attacks. A valid signature proves two things at once: the payload was produced by a holder of the secret, and it was not altered in transit.

## The signature and timestamp headers

The sender includes at least two headers: a timestamp (when the request was signed) and a signature. The signed content is not the JSON alone but a canonical string that binds the timestamp to the body — typically the timestamp, a separator, and the raw request body — so an attacker cannot lift a valid signature onto a different payload or replay it at a different time. Naming varies by vendor (Webhook-Signature / Webhook-Timestamp, or X-Signature / X-Timestamp), but the construction is the same. The signature is usually hex or base64 and often carries a scheme prefix (for example v1,) so the secret can be rotated to a new scheme without breaking old verifiers.

*Signed content and header layout*

```text
signed_content = timestamp + '.' + raw_body
signature      = HMAC_SHA256(secret, signed_content)

Webhook-Timestamp: 1752916522
Webhook-Signature: v1,3f2b8c9a1d...   # hex or base64, scheme-prefixed
```

## Verifying against the raw body

The single most common verification bug is signing a re-serialised body. HMAC is computed over exact bytes, so the receiver must verify against the raw request body precisely as received — before any JSON parse-and-re-encode. Parsing to an object and re-stringifying reorders keys, changes whitespace and normalises numbers and Unicode escapes, producing different bytes and a signature mismatch even when nothing was tampered with. Practically this means capturing the raw body in the web framework before body-parsing middleware consumes it, verifying the HMAC over those bytes, and only then parsing the JSON for use. Charset matters too: sign and verify the same octet sequence (UTF-8), not a decoded string.

*Verify (pseudocode) — raw body, constant-time*

```text
raw   = request.raw_body            # exact bytes, pre-parse
ts    = header('Webhook-Timestamp')
sig   = header('Webhook-Signature')

if abs(now() - ts) > TOLERANCE: reject   # replay window
expected = hmac_sha256(secret, ts + '.' + raw)
if not constant_time_equal(expected, sig): reject
accept(parse_json(raw))
```

## Replay protection: timestamp tolerance and nonce

A signature alone does not stop replay — a captured-but-valid request could be re-sent later. Two mechanisms close that gap. First, because the timestamp is inside the signed content, the receiver checks it against its own clock and rejects anything older than a tolerance (commonly a few minutes, wide enough to absorb clock skew and retry delay, narrow enough to limit the replay window). Second, for stricter guarantees the receiver records a nonce — the unique event id — for the tolerance period and rejects a second request bearing an id it has already seen. Timestamp tolerance bounds the window; the nonce cache makes replay within that window ineffective. Note this is complementary to consumer idempotency, which handles legitimate duplicates rather than malicious replays.

*Replay-protection controls*

| Control | Defends against | Tuning |
|---|---|---|
| Timestamp in signed content | Lifting a signature onto a new time | Must be signed, not a bare header |
| Timestamp tolerance window | Delayed replay of a captured request | Few minutes; balance skew vs exposure |
| Nonce / event-id cache | Replay inside the tolerance window | Cache ids for at least the tolerance |
| Constant-time compare | Timing side-channel on the signature | Always, regardless of the above |

## Constant-time comparison

Comparing the computed and received signatures with an ordinary equality check leaks information: a byte-by-byte comparison returns faster on an early mismatch, and an attacker who can measure that timing can recover the correct signature one byte at a time. Verification must therefore use a constant-time (length-independent) comparison — the standard-library timing-safe compare in each platform — which always examines the full length before returning. This is cheap and non-negotiable; it is the difference between a signature scheme that is sound and one that is defeatable with enough timed requests.

## Secret rotation

Signing secrets must be rotatable without dropping legitimate webhooks mid-flight. The safe pattern is overlapping keys: the sender is provisioned a new secret while the old one stays valid, and during the overlap the sender either signs with both (emitting two signatures) or the receiver accepts a signature that matches either secret. Once every producer is confirmed on the new secret, the old one is retired. The scheme prefix on the signature (v1, v2,) makes this observable and lets a versioned algorithm change ride along. BroadNet's hosted webhook signature validator lets you check a payload, timestamp and secret against an expected HMAC without writing code; the mechanics of HMAC-SHA256 DLR signing are covered in the answers library.

## Frequently asked questions

### How is a webhook signature computed?

The sender builds a canonical string binding the timestamp to the raw body (typically timestamp + '.' + body), computes HMAC-SHA256 over it with a shared secret, and puts the result in a signature header alongside the timestamp. The receiver recomputes the same HMAC and compares. A match proves the payload came from a secret holder and was not altered.

### Why must I verify against the raw request body?

HMAC is computed over exact bytes. If you parse the JSON and re-serialise it, key order, whitespace, number formatting and Unicode escapes change, so the bytes differ and the signature fails to match even when nothing was tampered with. Capture the raw body before body-parsing middleware, verify, then parse.

### How does timestamp tolerance stop replay attacks?

The timestamp is part of the signed content, so it cannot be altered without breaking the signature. The receiver compares it to its own clock and rejects anything older than a tolerance (commonly a few minutes). That bounds how long a captured valid request can be replayed; a nonce cache closes replays inside that window.

### Why is constant-time comparison required?

An ordinary equality check returns early on the first mismatching byte, and an attacker who can time the response can recover the correct signature byte by byte. A constant-time compare always examines the full length before returning, removing the timing side-channel. Use the platform's timing-safe comparison function.

### How do I rotate a webhook signing secret safely?

Use overlapping keys. Provision the new secret while the old one stays valid; during the overlap the sender signs with both or the receiver accepts either, so no legitimate webhook is dropped. Once all producers are confirmed on the new secret, retire the old one. A scheme prefix on the signature makes the transition observable.

### Is HTTPS not enough to trust a webhook?

No. TLS proves the transport was encrypted and the client reached your server, but not that the request came from the messaging platform — anyone can POST to a public URL. Only a shared-secret HMAC signature authenticates the sender and proves the payload was not modified. IP allowlisting is a weak substitute because egress IPs change.

## Sources

- [IETF RFC 2104 — HMAC: Keyed-Hashing for Message Authentication](https://datatracker.ietf.org/doc/html/rfc2104)
- [IETF RFC 6234 — US Secure Hash Algorithms (SHA and HMAC-SHA)](https://datatracker.ietf.org/doc/html/rfc6234)

## Related

- [Webhook delivery & reliability](https://www.broadnet.me/reference/webhook-delivery-and-reliability)
- [Message status & inbound webhooks](https://www.broadnet.me/reference/message-status-and-inbound-webhooks)
- [API authentication & signing](https://www.broadnet.me/reference/api-authentication-and-signing)
- [Webhook signature validator](https://www.broadnet.me/tools/webhook-signature-validator)
- [HMAC-SHA256 DLR signing](https://www.broadnet.me/answers/hmac-sha256-dlr-signing)
