Skip to content
GreenSnippets

Secure webhook verification, step by step

How to authenticate incoming webhooks with HMAC signatures, avoid the raw-body and timing pitfalls, block replays, and process each event exactly once.

A webhook endpoint is a public URL that triggers actions in your system: mark an order as paid, grant access, send an email. Anyone who finds the URL can post to it, so the signature is the only thing that proves a request came from the provider. This guide covers the four checks a safe endpoint needs. The exact header names and signing format differ between providers, so always follow your provider's documentation for those details.

1. Verify the signature over the raw body

Most providers sign requests with HMAC-SHA256: they compute a keyed hash of the request body using a secret that only you and they know, and send the result in a header. You recompute it and compare.

The most common bug is verifying the wrong bytes. If your framework parses the JSON first and you then serialize it again, whitespace and key order can change, and the signature will not match (or, worse, you will "fix" it by loosening the check). Capture the raw bytes before any parsing, verify, and only then parse.

With plain Node.js you control this directly:

import http from "node:http";

http
  .createServer(async (req, res) => {
    const chunks = [];
    for await (const chunk of req) chunks.push(chunk);
    const rawBody = Buffer.concat(chunks); // exact bytes that were signed

    // 1. verify(rawBody, headers) -> reject with 400/401 if invalid
    // 2. only then: const event = JSON.parse(rawBody.toString("utf8"));
    res.end("ok");
  })
  .listen(3000);

In frameworks, use the documented way to access the raw body for that route (for example a raw-body parser applied only to the webhook path) and keep your normal JSON parser for everything else.

2. Compare in constant time

Comparing signatures with === can leak, through tiny timing differences, how many leading characters were correct. Use a constant-time comparison: crypto.timingSafeEqual in Node (it throws if the buffers differ in length, so check lengths first), hmac.compare_digest in Python, hash_equals in PHP. The snippets linked below show each.

Also reject malformed signatures early (wrong length, non-hex) rather than letting them reach the comparison.

3. Reject old requests to stop replays

A valid signed request can be captured and re-sent later by an attacker. The standard defense is a timestamp that is part of the signed data. Reject requests whose timestamp is outside a short tolerance (five minutes is common). Because the timestamp is signed, an attacker cannot refresh it without the secret.

Keep your server clock synchronized (NTP), or valid requests will be rejected.

4. Process each event exactly once

Providers retry deliveries when your endpoint is slow or returns an error, so the same event will arrive more than once, and sometimes out of order. Design for that:

  • Every event has a unique ID. Store it in a table with a unique constraint and insert it before doing the work.
  • If the insert fails because the ID already exists, you have seen this event: return success without repeating side effects.
  • Make the work itself safe to repeat where possible (for example, "set order status to paid" rather than "add credit").
  • Do not trust the browser redirect that follows a payment; confirm state from the verified webhook (or by calling the provider's API).

A minimal PostgreSQL table for this:

CREATE TABLE webhook_events (
  provider text NOT NULL,
  event_id text NOT NULL,
  received_at timestamptz NOT NULL DEFAULT now(),
  processed_at timestamptz,
  PRIMARY KEY (provider, event_id)
);

Then INSERT ... ON CONFLICT DO NOTHING and check how many rows were inserted. Zero means duplicate.

5. Respond quickly and fail safely

  • Return 2xx only after the event is durably recorded. Do slow work (emails, exports) in a background job.
  • Return 400/401 for bad signatures, with no detail about why it failed.
  • Never log the secret or full signature headers. Log the event ID and the outcome.
  • Store the secret in an environment variable or secret manager, never in the repository, and support rotating it (accept both old and new secrets briefly).

Testing your endpoint

Write tests that sign a payload with a test secret and assert that your handler:

  1. accepts a correctly signed request,
  2. rejects a modified body,
  3. rejects a wrong secret,
  4. rejects a stale timestamp,
  5. returns success but performs no side effects for a duplicate event ID.

Providers also offer test modes and CLI tools that deliver signed sample events to a local server. Use test credentials only; never trigger real charges from automated tests.

Checklist

  • Signature computed over the raw body
  • Constant-time comparison, with length check
  • Signed timestamp with a short tolerance
  • Unique event-ID constraint for idempotency
  • Secrets only in environment, rotation plan documented
  • Tests for valid, tampered, wrong-secret, stale and duplicate requests