How do I secure a webhook endpoint?

Direct answer

Verify the signature on every request using a constant-time comparison, reject anything with an old timestamp, and treat the payload as a notification rather than as truth, fetch the current state from the API instead of trusting what was sent. A webhook endpoint is a public URL that anyone can post to.

Muhammad HasanUpdated

Webhook receivers are public, unauthenticated by default, and often wired directly into consequential logic. Marking orders paid, provisioning access, sending things.

Verify the signature

Providers sign the request body with a shared secret. Verify it against the raw bytes, before any parsing.

const sig = req.headers['x-signature'];
const expected = crypto
  .createHmac('sha256', process.env.WEBHOOK_SECRET)
  .update(req.rawBody)          // raw bytes, not the parsed object
  .digest('hex');

if (!crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected))) {
  return res.status(401).end();
}

Two things people get wrong. Signing the re-serialised JSON rather than the raw body, key order and whitespace change, the signature fails, and someone "fixes" it by skipping verification. And using === to compare, which leaks information through timing; use the constant-time comparison your language provides.

Many frameworks consume the body before your handler sees it. You'll usually need to configure raw body capture on that route specifically.

Reject old requests

A valid signed request captured once can be replayed indefinitely unless you bound it.

const age = Date.now() / 1000 - Number(timestamp);
if (Math.abs(age) > 300) return res.status(400).end();

Most providers include the timestamp in the signed payload, which is what stops an attacker adjusting it. Five minutes is a common tolerance.

For stronger protection, store the event ID and reject duplicates. Which you want anyway for idempotency.

Don't trust the payload

Even a correctly signed webhook tells you an event happened, not what the current state is. Events arrive out of order, get retried, and can be stale.

For anything consequential, payment succeeded, subscription active. Take the ID from the payload and fetch the object from the provider's API. That's the authoritative answer.

Be idempotent

Providers retry, sometimes aggressively, and deliver duplicates. Store processed event IDs with a unique constraint and return 200 for anything you've already handled.

Without this you get double-fulfilled orders and duplicate emails, and it's a race condition as much as a webhook problem, two retries arriving simultaneously both check "have I seen this" and both proceed.

Respond fast, work later

Return 200 quickly and process asynchronously. Providers time out and retry, which turns slow processing into duplicate delivery.

Don't leak through the URL

A secret in the path (/webhooks/a8f3...) is authentication by obscurity, and it ends up in logs, proxies and referrers. Use it as a supplementary layer if you like, never as the only one.

Rate limit the endpoint. Restrict by source IP where the provider publishes ranges, though ranges change and this shouldn't be your only control.

Reviewing for it

req.body used before signature verification, === on a signature comparison, and missing timestamp checks are all reviewable in a few minutes. The list of webhook endpoints in most codebases is short.

Go deeper

Kolega for AppSec teams

Related answers

See what your own repository returns

Connect a repo and run a scan. No credit card, no pipeline changes.

Get started for free