Blog

Why Your Webhook Endpoint Trusts Forged Payloads (And How HMAC Fixes It)

An unauthenticated webhook endpoint will happily process payloads from anyone, so HMAC signatures let you prove a request genuinely came from the sender you expect.

February 14, 20266 min readMuhammad Shehzaib
NODESECURITYWEBHOOKSHMAC

Webhooks are the backbone of event-driven integrations: Stripe tells you a payment succeeded, GitHub tells you a branch was pushed, and your server reacts. The problem is that a webhook endpoint is just a public POST route, and anyone who learns the URL can send fake events. If you trust the body blindly, an attacker can mark invoices paid or trigger workflows that should never run. HMAC signature verification solves this without certificates or shared sessions. The sender computes a keyed hash of the raw request body using a secret only the two of you know, attaches it as a header, and you recompute the same hash to confirm the payload was not forged or altered in transit. This article walks through doing that correctly in Node, including the subtle mistakes that quietly defeat the whole scheme.

What HMAC Actually Proves

HMAC stands for hash-based message authentication code, and it answers two questions at once: did this message come from someone holding the shared secret, and has the message been altered since it was signed. It does not encrypt anything; the payload travels in plaintext and anyone can read it. What HMAC guarantees is integrity and authenticity. The sender runs the raw bytes of the body plus a secret key through a hash like SHA-256, producing a fixed-length digest. Because the secret feeds into the computation, nobody without it can produce a matching digest for a chosen payload. When you receive the request, you run the identical computation and compare. A match means the body is authentic and untampered. A mismatch means either the secret differs, the body changed, or someone is forging requests, and you reject it.

Sign The Raw Body

The single most common HMAC bug is signing the wrong bytes. Frameworks like Express parse JSON automatically, and once a body is parsed and re-serialized, the byte order, whitespace, and key ordering can differ from what the sender hashed. Even one extra space produces a completely different digest, so verification fails for legitimate requests or, worse, you work around it by being lenient. The sender signed the exact raw bytes on the wire, so you must verify against those exact bytes. In Express this means capturing the raw buffer before any JSON middleware runs, typically through the verify callback on the body parser or a dedicated raw body parser for the webhook route only. Hash the buffer, not the parsed object. Only after the signature checks out should you parse the JSON and act on its contents.

Constant-Time Comparison

After computing the expected digest, you compare it to the one the sender sent. Reaching for a plain equality check or string comparison opens a timing side channel. Standard comparisons return as soon as they hit a differing character, so an attacker measuring response times can learn the signature byte by byte and eventually forge a valid one. The defense is a constant-time comparison that always examines every byte regardless of where the first difference occurs. Node exposes this through crypto.timingSafeEqual, which takes two buffers of equal length and returns whether they match without leaking timing information. Convert both digests to buffers of the same length first, because timingSafeEqual throws if the lengths differ. If your two buffers cannot possibly be equal length, treat that as a failed verification rather than letting the function throw an unhandled error.

Include A Timestamp

A valid signature on its own does not stop replay attacks. If an attacker captures a legitimate signed request, they can resend the identical bytes later and your verification will pass, because the payload and signature are genuinely consistent. To defend against this, good webhook schemes sign a timestamp alongside the body. Stripe, for example, constructs the signed string as the timestamp, a separator, and the payload, then sends the timestamp in the same header as the signature. On your side you recompute the signature over that combined string and additionally check that the timestamp is recent, usually within a tolerance of five minutes. Requests older than the window are rejected even if the signature is valid. This narrows the replay opportunity to a few minutes and forces an attacker to act almost immediately rather than hoarding captured requests indefinitely.

Rotating Signing Secrets

Secrets leak, employees leave, and logs occasionally capture things they should not, so you need a way to rotate the signing secret without downtime. The naive approach of swapping the secret atomically guarantees a window where in-flight requests signed with the old secret fail verification. A cleaner pattern is to support multiple active secrets during a rotation period. When a request arrives, compute the expected signature against each valid secret and accept the request if any of them matches, again using constant-time comparison for each. The sender migrates to the new secret on their schedule, and once you confirm no traffic uses the old one, you retire it. Many providers send multiple signatures in a single header precisely to support this overlap. Store secrets in a managed secret store rather than environment files committed to history, and scope each integration to its own secret.

Handling Verification Failures

When verification fails, the correct response is to reject the request and stop, but how you reject matters. Return a 400 or 401 status with a minimal body, and crucially do not echo back why it failed in detail, because verbose errors help attackers probe your logic. Log the failure server-side with enough context to debug genuine integration issues, such as which integration the request claimed to be from and whether the failure was a length mismatch, a timestamp out of range, or a digest mismatch. Never run any side effects before verification succeeds: no database writes, no queue publishes, no downstream calls. A surprising number of bugs come from code that parses and processes the event first and verifies afterward as an afterthought. Verification is a gate, so place it at the very top of the handler before anything else touches the payload.

Idempotency After Verification

Even with valid signatures and timestamp checks, you will receive the same event more than once. Senders retry when your endpoint times out or returns an error, and network hiccups cause duplicate deliveries. If processing an event twice causes harm, such as charging a customer or sending a duplicate email, you need idempotency on top of verification. Most providers include a unique event identifier in the payload. Record processed identifiers in a store with a reasonable retention window, and before acting on an event, check whether you have already handled that identifier. If you have, acknowledge with a success status and do nothing further, since the sender only needs to know you received it. Make the check and the processing atomic where possible, using a unique constraint or a conditional insert, so two concurrent deliveries of the same event cannot both slip through the guard.

Testing Your Verification

Webhook verification is security-critical code that runs on every request, so it deserves real tests rather than hopeful manual checks. Write tests that sign a known payload with a known secret and assert that verification passes, then mutate a single byte of the body and assert that it fails. Add cases for an expired timestamp, a missing signature header, a malformed header, and a signature computed with the wrong secret. Test that signing the parsed-and-reserialized body fails, which catches the raw-body mistake before it reaches production. If you support secret rotation, test that both the old and new secrets verify during the overlap and that the old one fails after retirement. Most providers ship a CLI or dashboard that sends test events with valid signatures against your real endpoint, which is invaluable for confirming the raw-body capture works end to end in your actual framework configuration.