Security

Webhook Signatures: Verifying What Your Server Is Told

A webhook endpoint is an unauthenticated API that changes your data. How to sign and verify properly — HMAC over the raw body, timestamps and replay windows, constant-time comparison, key rotation, and the idempotency and SSRF issues on both sides.

Emilian GheoneaAugust 3, 20268 min read

Here is an uncomfortable way to describe a webhook endpoint: it is a publicly reachable URL that accepts unauthenticated input and mutates your database in response.

That is what an unverified webhook is. Anyone who learns the URL — from a log, a misconfigured proxy, a former employee, or simply by guessing /webhooks/stripe — can POST a JSON body claiming a payment succeeded, a subscription upgraded, or a user's plan changed. Your handler, having no way to tell, does what it is told.

Signature verification is what turns that endpoint back into an authenticated API. It is not complicated, and it is wrong in production more often than almost anything else in this space.

Why not just use a secret in the URL?

The common shortcut is a hard-to-guess path: /webhooks/a8f3c9e1b7d2. This is bearer authentication in the least protected location available.

URLs end up in access logs on every server and proxy in the path, in Referer headers, in browser history, in error trackers, in APM traces, and in the support ticket where someone pasted their config. And a URL secret cannot be scoped: whoever has it can send any payload. It authenticates the destination, not the message.

A signature authenticates the message. That is the difference that matters.

The mechanism

The sender and receiver share a secret. The sender computes an HMAC over the exact bytes it is sending, plus a timestamp, and includes both in a header:

POST /webhooks/embedauth
X-Webhook-Timestamp: 1754236800
X-Webhook-Signature: v1=5d41402abc4b2a76b9719d911017c592…
Content-Type: application/json

{"type":"user.created","data":{"id":"user_123"}}

The receiver recomputes the HMAC over the same inputs and compares. A matching signature proves two things at once: the message came from someone holding the secret, and not one byte of it has been altered.

The signed value should be a structured string, not the body alone:

signed_payload = timestamp + "." + raw_body
signature      = HMAC-SHA256(secret, signed_payload)

Including the timestamp inside the signed material is what makes replay protection work — otherwise an attacker can replay a captured request with a fresh timestamp and the original, still-valid signature.

Verification, correctly

import crypto from 'node:crypto';

const TOLERANCE_SECONDS = 300;

export function verify(rawBody, timestampHeader, signatureHeader, secrets) {
  // 1. Timestamp must be present, numeric, and recent — checked BEFORE the HMAC.
  const timestamp = Number(timestampHeader);
  if (!Number.isFinite(timestamp)) return false;
  const age = Math.abs(Date.now() / 1000 - timestamp);
  if (age > TOLERANCE_SECONDS) return false;

  // 2. HMAC over timestamp + "." + the RAW body bytes.
  const signedPayload = `${timestampHeader}.${rawBody}`;

  // 3. Accept any currently-valid secret, so rotation is not an outage.
  return secrets.some((secret) => {
    const expected = crypto
      .createHmac('sha256', secret)
      .update(signedPayload)
      .digest('hex');
    const provided = signatureHeader.replace(/^v1=/, '');
    // 4. Constant-time comparison, with a length guard.
    if (expected.length !== provided.length) return false;
    return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(provided));
  });
}

Four details, each of which is a real failure when omitted.

The raw body, byte for byte. This is the mistake that costs the most debugging time. If your framework parses JSON before your handler runs, and you re-serialize the parsed object to verify, the signature will fail — because key ordering, unicode escaping, whitespace, and number formatting all differ between the sender's serializer and yours. You must capture the raw bytes before any parsing:

// Express: raw body only on the webhook route
app.post('/webhooks/x', express.raw({ type: 'application/json' }), handler);
// Next.js App Router: request.text() gives you the raw body
const rawBody = await request.text();

Constant-time comparison. A normal === on strings returns as soon as it finds a differing character, and that timing difference is measurable over enough requests. It is a narrow attack and it costs nothing to close. Note the length check first — timingSafeEqual throws on mismatched lengths, and that throw is itself a side channel if it happens before you have normalized.

Timestamp before HMAC. Checking the cheap thing first means a flood of garbage requests does not cost you a hash computation each. A five-minute tolerance is standard: long enough to survive clock skew and slow networks, short enough that a captured request has a small replay window.

Multiple valid secrets. Rotation requires an overlap period, exactly as in key rotation. Accept both during the window, then drop the old one.

Replay protection needs more than a timestamp

The tolerance window bounds replay to five minutes. Within that window, a captured request can still be replayed — and if the event is "credit this account," five minutes of replays is a real problem.

Full protection needs the event ID recorded:

const eventId = JSON.parse(rawBody).id;
const isNew = await store.setIfNotExists(`webhook:${eventId}`, '1', { ttl: 600 });
if (!isNew) return new Response('ok', { status: 200 }); // already processed

The TTL only needs to exceed the tolerance window, so the store stays small.

This is the same mechanism as idempotency, which you need anyway for an entirely non-security reason: every webhook sender retries, and at-least-once delivery means duplicates are normal operation, not an attack. Handling the duplicate correctly — returning success without reprocessing — solves both problems with one piece of code. The most common webhook bug in production is not a signature failure; it is a handler that credits an account twice because the sender retried after a timeout.

Responding correctly

The sender's retry behaviour is driven entirely by your status code, and getting this wrong causes cascading problems.

Return 2xx as fast as possible. Verify, persist the raw event, enqueue for processing, respond. Do the actual work asynchronously. Senders typically time out in a few seconds, and a slow handler causes retries, which cause duplicates, which cause exactly the double-processing the previous section addressed.

Return 4xx for permanent failures. A bad signature or a malformed body will never succeed on retry. Telling the sender to stop is correct — and specifically, return the same status for a bad signature as for a missing one, with no detail about which check failed, so the endpoint is not a debugging aid for an attacker.

Return 5xx for transient failures. Your database is down; the event is valid. A 5xx asks for a retry, which is what you want.

Never return a 3xx. Many senders do not follow redirects, and those that do may drop the body or the signature headers.

What a valid signature does not prove

A verified signature is a strong statement with narrow scope, and treating it as broader than it is produces a specific class of bug.

It proves the message was produced by someone holding the secret and has not been modified. It does not prove the message is current, that the described state still holds, or that the event has not already been acted upon.

That distinction matters most for state-carrying events. A subscription.updated webhook tells you what was true when it was sent. Webhook delivery is not ordered — a retry of an older event can arrive after a newer one, and a five-second network delay is enough to invert two events emitted a second apart. If your handler applies each event as the current truth, an out-of-order retry will silently roll a customer's plan back to a previous state, with a perfectly valid signature.

Two guards handle this. Order by the sender's timestamp or sequence number, not by arrival, and discard an event older than the state you already have. Or, more robustly, treat the webhook as a signal rather than as data: on receipt, call the sender's API to fetch the current state of the object, and apply that. The webhook tells you something changed; the API tells you what is true now. This costs a round trip and eliminates the entire category of ordering bugs, which is usually the right trade for anything touching billing or permissions.

A signature also does not tell you the sender is who you think it is beyond "holds this secret." If the same secret is shared across environments, a staging system can send production-valid events. One secret per endpoint, per environment.

Rotation and secret management

The webhook secret is a long-lived shared credential, which makes it exactly the kind of thing that leaks quietly.

One secret per endpoint, not one per integration and certainly not one globally. Blast radius.

Support two active secrets so rotation is: add new → sender starts using it → wait → remove old. Without this, rotation means a window where every delivery fails.

Never log the secret, and never log the signature header — the latter is not immediately reusable, but it appears in log aggregation next to the body it signs, which is more than you want to hand over.

Store secrets encrypted at rest, and show them once at creation. A secret retrievable from your own UI is a secret every account with dashboard access has.

If you are the sender

The other side has its own set of problems, and one of them is severe.

Signing key isolation. Per-endpoint secrets, generated with a CSPRNG, never derived from anything guessable.

Retry with exponential backoff and a cap. Retrying a broken endpoint forever is a self-inflicted denial of service on your own infrastructure. Back off, cap the attempts, and disable the endpoint after sustained failure — with a notification to the customer explaining why.

A dead-letter queue and a replay tool. Customers will have outages. The ability to say "here are the 400 events you missed, click to resend" is the difference between a support ticket and an incident.

SSRF is your problem, not theirs. This is the one that catches teams by surprise. A webhook destination is a user-supplied URL that your server fetches — which is the textbook definition of a server-side request forgery vector. A customer who registers http://169.254.169.254/latest/meta-data/ as their webhook endpoint has just aimed your infrastructure at your own cloud metadata service, and your delivery worker will helpfully fetch it and report the response body back to them.

The defenses:

  • Require HTTPS, and refuse plaintext.
  • Resolve the hostname and block private ranges — RFC 1918, loopback, link-local, and the cloud metadata addresses specifically.
  • Re-check after DNS resolution, and pin the resolved IP for the connection. A hostname that resolves to a public address during validation and a private one at request time is DNS rebinding, and it defeats naive checks.
  • Do not follow redirects, or a public URL can redirect you into the private range.
  • Send from an egress-restricted network — the most robust control, because it does not depend on getting the parsing right.
  • Set aggressive timeouts so a slow endpoint cannot tie up delivery workers.

Deliver useful events. Include an event ID, a type, a timestamp, and enough data that the receiver does not have to call back to make sense of it. And document your retry schedule, your timeout, and your signature scheme — the receiver cannot implement any of this correctly without those numbers.

Key takeaways

  • An unverified webhook endpoint is an unauthenticated mutation API. A secret in the URL is not a fix.
  • HMAC over timestamp + "." + raw body. The raw bytes — re-serializing a parsed object will not match.
  • Check the timestamp before the HMAC, use constant-time comparison with a length guard, and accept multiple valid secrets so rotation is not an outage.
  • Deduplicate on event ID. Retries are normal operation, not an attack, and the same code solves both.
  • 2xx fast, work async. 4xx for permanent failures, 5xx for transient, never 3xx. Identical response for bad and missing signatures.
  • As a sender, customer-supplied webhook URLs are an SSRF vector — HTTPS only, block private ranges after resolution, pin the IP, no redirects, egress restrictions.

EmbedAuth signs every outbound webhook with a per-endpoint secret over the raw payload and a timestamp, supports overlapping secrets for zero-downtime rotation, and retries with backoff into a replayable queue — the webhook events reference documents the payload shapes.

Written by

Emilian Gheonea

Senior Blockchain & Full-Stack Software Engineer. I build EmbedAuth — an embeddable authentication platform for SaaS — and write about the auth problems most teams hit too late.