← Recipes

阅读中文版本

Implement Webhook Signature Verification That Handles Time

Verify HMAC signatures with constant-time comparison, reject timestamps outside a tolerance window, and block replayed delivery ids.

~30 minsecuritywebhooksapi

Task

Build a webhook receiver that authenticates the sender, tolerates clock skew, and cannot be replayed by a captured request.

When to use this

A provider supports webhook signing, but the internal receiver accepted any payload to the URL. A misconfigured partner endpoint sent thousands of forged events into production.

Prerequisites

  • A provider that signs webhooks with an HMAC and a timestamp, such as Stripe or GitHub
  • The shared signing secret available to the receiver
  • A durable store for seen delivery ids

Steps

1. Verify the HMAC with constant-time comparison.

const expected = crypto
  .createHmac("sha256", secret)
  .update(`${timestamp}.${rawBody}`)
  .digest("hex");

const ok = crypto.timingSafeEqual(
  Buffer.from(expected),
  Buffer.from(signature),
);

Expected output: A valid signature passes, and the comparison leaks no timing information about the expected value.

2. Reject timestamps outside the tolerance window.

const TOLERANCE_SECONDS = 300;
const age = Math.abs(Date.now() / 1000 - Number(timestamp));
if (age > TOLERANCE_SECONDS) {
  return res.status(400).json({ error: "timestamp outside tolerance" });
}

Expected output: Requests older than five minutes are rejected, bounding how long a captured request stays useful.

3. Remember delivery ids to defeat replay.

if (await seen.has(deliveryId)) {
  return res.status(200).json({ status: "duplicate" }); // idempotent no-op
}
await seen.set(deliveryId, 1, { EX: 3600 });

Expected output: The same signed delivery cannot be processed twice, and duplicate handling stays idempotent.

4. Use the raw body, not the parsed one.

app.post("/webhooks/provider",
  express.raw({ type: "application/json" }),
  (req, res) => {
    const rawBody = req.body; // Buffer, byte-exact
    // verify signature over rawBody
  });

Expected output: Verification runs over the exact bytes the provider signed, immune to parser re-serialization differences.

5. Test with forged and replayed requests.

curl -s -o /dev/null -w "%{http_code}\n" -X POST https://api.example.com/webhooks/provider -H "x-signature: deadbeef" -d '{}' && curl -s -o /dev/null -w "%{http_code}\n" -X POST https://api.example.com/webhooks/provider -H "x-signature: $GOOD" -d "$SAME_BODY"

Expected output: The forged signature gets 400, and the replayed good request is acknowledged as duplicate without side effects.

Troubleshooting

Problem

Valid signatures rejected

Solution

The signature was computed over re-serialized JSON; verify over the raw request bytes and match the provider documented concatenation format exactly.

Problem

Duplicates processed twice anyway

Solution

The id store is not durable or the window is shorter than provider retries; extend the TTL beyond the provider retry horizon and key it by delivery id, not timestamp.

Problem

Clock skew rejects legitimate events

Solution

Widen the tolerance symmetrically and alert on skew approaching the limit rather than silently dropping events.

Limitations

  • HMAC verification proves the sender holds the secret; a compromised sender cannot be detected by the receiver.
  • Replay protection is only as durable as the delivery-id store; Redis with a TTL covers typical retry horizons.

Authenticate the sender, then trust the message

A webhook URL that accepts any POST is an open write endpoint into your domain. Signature verification closes it: the provider proves possession of the shared secret with an HMAC over the exact bytes sent. Two more details make it complete: a timestamp bounds how long a captured request is presentable, and a delivery-id memory turns replays into acknowledged no-ops instead of double side effects.

const expected = crypto.createHmac("sha256", secret)
  .update(`${timestamp}.${rawBody}`)
  .digest("hex");

const ok = crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(signature))
  && Math.abs(Date.now() / 1000 - Number(timestamp)) < 300;

Sign bytes, not objects

The single most common verification bug is computing the HMAC over a parsed-then-re-serialized body: key order and whitespace differ, and every signature fails. Route the raw request body to the verifier untouched. Frameworks make this a one-line middleware choice, and the provider documentation specifies the exact concatenation.

Idempotency makes replay harmless

Replay protection does double duty: it blocks attackers replaying captured requests, and it makes provider retries safe. Keying by delivery id with a TTL longer than the retry horizon, and answering duplicates with 200-and-no-op, means neither malicious nor benign duplication can double-charge or double-provision anything.