A webhook endpoint is a public, unauthenticated, attacker-reachable HTTP route that triggers privileged business logic — it creates orders, grants entitlements, downgrades accounts, moves money. Anyone on the internet can POST to it. The only thing standing between a stranger's request and your createOrder() call is whatever you put there. So webhook security is not a checkbox you tick once you've copied the signature-verification snippet from the docs. The signature check is the front door. This article is about everything it leaves unlocked.
I want to be precise about scope. I have a separate article on the reliability side of webhooks — idempotency, retries, and queue architecture for Stripe. That one is about not losing or duplicating events. This one is about not getting attacked through the same endpoint. They are companions, and where they touch I'll point you there rather than repeat it.
What the Signature Check Actually Proves (and What It Doesn't)
Signature verification answers exactly one question: did this request body come from the party that holds the shared secret, unmodified? The provider HMACs the raw bytes with a secret only the two of you know, sends the digest in a header, and you recompute it. If they match, the bytes weren't forged or tampered with in transit.
That is real and necessary. But notice everything it does not say. It does not say the request is recent. It does not say it's the first time you've seen it. It does not say the claims inside the payload are true in the sense people assume. It does not protect the endpoint from being hammered. Treating "signature valid" as "request is safe to act on" is the root mistake behind most webhook incidents I've seen.
Let me get the verification itself right first, because two subtle bugs there undermine everything else.
Verify the Raw Body, Not Re-Serialized JSON
The signature is computed over the exact bytes the provider sent. The instant you JSON.parse and re-serialize, byte order, whitespace, and number formatting can change — and your recomputed HMAC no longer matches. Worse, if you verify a re-serialized copy but then act on a different parse, you've verified one thing and executed another.
In the Next.js App Router, read the raw text before parsing:
// app/api/webhooks/route.ts
import { createHmac, timingSafeEqual } from "node:crypto";
const WEBHOOK_SECRET = process.env.WEBHOOK_SECRET ?? "";
export async function POST(req: Request): Promise<Response> {
// Read raw bytes FIRST. Never call req.json() before verifying.
const rawBody = await req.text();
const signature = req.headers.get("x-webhook-signature");
if (!signature) {
return new Response("Bad Request", { status: 400 });
}
if (!isValidSignature(rawBody, signature, WEBHOOK_SECRET)) {
return new Response("Bad Request", { status: 400 });
}
// Only now is it safe to parse — and we parse the SAME bytes we verified.
const event = JSON.parse(rawBody) as WebhookEvent;
// ... handle event
return new Response("OK", { status: 200 });
}
The anti-pattern is the one almost every quick tutorial ships:
// ANTI-PATTERN: parsing before verifying re-serializes the body.
// The recomputed HMAC won't match, or you verify bytes you don't act on.
const event = await req.json();
const recomputed = JSON.stringify(event); // NOT the original bytes
if (!isValidSignature(recomputed, signature, WEBHOOK_SECRET)) {
/* ... */
}
Compare in Constant Time
Once you have your computed HMAC, comparing it to the provided one with === leaks information. String equality short-circuits on the first differing byte, so the time it takes to return false correlates with how many leading bytes matched. Given enough attempts, an attacker can recover a valid signature byte by byte. This is a real timing side-channel, not a theoretical one.
Use crypto.timingSafeEqual, which always compares the full buffers in fixed time:
// lib/verify-signature.ts
import { createHmac, timingSafeEqual } from "node:crypto";
export function isValidSignature(rawBody: string, provided: string, secret: string): boolean {
const expected = createHmac("sha256", secret).update(rawBody, "utf8").digest("hex");
const a = Buffer.from(expected, "utf8");
const b = Buffer.from(provided, "utf8");
// timingSafeEqual throws if lengths differ — guard first, also in constant intent.
if (a.length !== b.length) {
return false;
}
return timingSafeEqual(a, b);
}
// ANTI-PATTERN: naive comparison leaks timing, enabling byte-by-byte recovery.
return expected === provided;
A note on the length guard. Returning early on a length mismatch does leak the length of the expected digest. But for a fixed-length HMAC — SHA-256 is always 64 hex chars — that's a constant you already know, so it reveals nothing. timingSafeEqual requires equal-length buffers and throws otherwise, which is why the guard is there.
Store the secret server-side only — in an environment variable or a secrets manager, never in the client bundle. Use a different secret per endpoint so a leak from one integration doesn't compromise the others.
Replay Attacks: A Valid Signature Is Still Valid Tomorrow
Here's the first thing the signature doesn't cover. Suppose an attacker captures one legitimate, correctly-signed request — from a leaked log, a proxy they control, a misconfigured TLS-terminating box. They can replay those exact bytes to your endpoint as many times as they like. The signature is still valid, because nothing about the bytes changed. If that event grants a subscription or credits an account, you've just been replayed into doing it again.
Two defences, used together:
-
A signed timestamp with a tolerance window. Good providers include a timestamp in the signed payload (Stripe signs
t=...alongside the body; others put it in a signed header). Reject anything older than a few minutes — five is a common choice. This bounds how long a captured request stays useful. - Deduplication on the provider's event ID. Even inside the window, a replay should execute at most once. Record each event's unique ID the first time you process it and short-circuit on repeats.
// Reject stale events. The timestamp MUST be inside the signed payload,
// otherwise an attacker just rewrites it.
const FIVE_MINUTES = 5 * 60 * 1000;
function isFresh(eventTimestampMs: number, now = Date.now()): boolean {
return Math.abs(now - eventTimestampMs) <= FIVE_MINUTES;
}
The deduplication half is exactly the idempotency mechanism I cover in depth elsewhere — the same event store that stops duplicate retries from your provider also stops malicious replays. I won't rebuild the table here; see idempotency keys for API retries for the durable-store design and the Stripe webhook pipeline for how it slots into a queue. The security framing is just this: idempotency is not only a correctness feature, it's your replay defence.
One caveat worth stating plainly: the timestamp only helps if it's part of the signed data. A timestamp in an unsigned header is attacker-controlled — they'll just set it to now. Check what your provider actually signs.
The Sharpest Point: A Valid Signature Does Not Make the Payload Trustworthy
This is the one I most want you to internalize, because it's the mistake that costs real money.
Authentication is not authorization of the payload's claims. A valid signature proves the message came from the provider. It does not mean the numbers inside the message are a safe basis for a financial decision. Developers conflate "this came from Stripe" with "I can trust the amount field in here," and those are different statements.
The killer pattern looks reasonable:
// ANTI-PATTERN: trusting money/entitlements straight from the webhook body.
// The signature only proves the bytes are authentic, not that this amount
// is the authoritative, final, captured value for this order.
const event = JSON.parse(rawBody) as WebhookEvent;
if (event.type === "checkout.completed") {
await grantPlan(event.data.customerId, event.data.amount); // do NOT do this
}
Why is this dangerous even with a perfectly valid signature? Because the webhook is a notification, not a ledger. Payloads can be stale (an earlier version of an object that later changed), partial (the provider sends a lean object and expects you to expand it), or reflect a state that has since been refunded, disputed, or cancelled. In multi-step flows the amount in the event you happened to receive may not be the amount that was actually captured. The webhook is telling you "something happened, go look" — not "here is the final truth, act on it."
The correct posture: treat the webhook as a trigger to re-fetch the authoritative object from the provider's API (or from your own database, if you're the system of record), and base privileged decisions on that.
// Re-fetch the source of truth before acting on money or access.
if (event.type === "checkout.completed") {
const session = await stripe.checkout.sessions.retrieve(event.data.id, {
expand: ["payment_intent"],
});
const pi = session.payment_intent;
if (typeof pi !== "string" && pi?.status === "succeeded") {
// Use the authoritative amount, not the one carried in the webhook body.
await grantPlan(session.customer as string, pi.amount_received);
}
}
Yes, this costs an extra API round-trip. For anything touching money or access, pay it. The webhook tells you when; the API tells you what. I make this re-fetch the default on every payment integration I build for clients, and it's the single change that prevents the most expensive class of webhook bug.
slug="api-integrations"
text="Inbound webhooks that grant access or move money deserve more than a copied signature snippet. I design the full threat model — replay defence, payload re-fetch, rate limiting, secret rotation — into the integration."
/>
The Endpoint Is a DoS Vector by Design
Your webhook route is public and it does work. That combination is a resource-exhaustion target. An attacker doesn't need a valid signature to hurt you — they just need to make you spend CPU.
Three layers of defence:
Respond fast, process asynchronously. If you run business logic synchronously inside the request handler, a flood of requests ties up your workers one-for-one until the pool is exhausted and legitimate traffic stalls. Accept the event, persist or enqueue it, return 2xx immediately, and do the real work in a background worker. This decouples "how fast can I acknowledge" from "how long does the work take." The queue architecture for this is the same one in the Stripe reliability article — there it's framed as retry-safety, but it's a DoS control too.
Fail cheaply before you fail expensively. Computing an HMAC over a body is not free. An attacker spraying multimegabyte garbage payloads makes you hash every one of them if verification is your first check. Order your validations cheapest-first: reject on missing required headers, then on body size, and only then compute the HMAC. You want the attacker's junk to be discarded before it costs you a hash.
export async function POST(req: Request): Promise<Response> {
// Cheap rejections first — don't waste CPU hashing obvious junk.
const signature = req.headers.get("x-webhook-signature");
if (!signature) {
return new Response("Bad Request", { status: 400 });
}
const lengthHeader = req.headers.get("content-length");
const MAX_BYTES = 1_000_000; // 1 MB; tune to your provider's real maximum
if (lengthHeader && Number(lengthHeader) > MAX_BYTES) {
return new Response("Payload Too Large", { status: 413 });
}
const rawBody = await req.text();
if (rawBody.length > MAX_BYTES) {
// content-length can lie or be absent; enforce after reading too.
return new Response("Payload Too Large", { status: 413 });
}
// Only now do the expensive HMAC.
if (!isValidSignature(rawBody, signature, WEBHOOK_SECRET)) {
return new Response("Bad Request", { status: 400 });
}
// ...
return new Response("OK", { status: 200 });
}
Rate-limit abusive sources. A single source flooding the endpoint should get shed before it reaches the expensive path. I keep the mechanics in a separate piece on Redis rate limiting for APIs; the webhook-specific note is that you rate-limit by source identity you can actually attribute, which leads directly to the next point.
Don't Trust Network Metadata You Can't Verify
It's tempting to allow-list the provider's source IPs as authentication. Be honest about what this buys you.
Source-IP allow-listing is defence in depth, not authentication. It can shrink the attack surface — if your platform reliably gives you the true peer IP, dropping everything outside the provider's published ranges removes a lot of internet noise. But it has two failure modes that breed false confidence:
-
X-Forwarded-Foris client-supplied and spoofable unless every proxy in front of you overwrites it and you read only the hop you control. Behind some setups, an attacker simply sets the header to a whitelisted IP and walks in. If you're going to use forwarded headers, trust only the value written by your load balancer, not the full chain. - Provider IP ranges change. Allow-lists rot, and a stale one will silently start dropping legitimate events — a reliability incident dressed up as a security feature.
The signature is the real authentication, because it's the only thing an attacker who can reach your endpoint cannot forge. Use IP allow-listing to reduce noise if it's cheap and your infrastructure makes the peer IP trustworthy. Never let it replace signature verification, and never assume X-Forwarded-For is gospel.
Secret Rotation and Error Hygiene
Two operational details that quietly matter.
Rotate secrets without downtime. When you roll a webhook secret, in-flight and recently-sent events were signed with the old one. If you hard-swap, you'll reject valid events during the cutover. Instead, accept either secret during a rotation window: verify against the new secret, and if that fails, try the old one. Once the provider is fully on the new secret and the window has passed, drop the old.
// Accept old + new during a rotation window, then retire the old secret.
const SECRETS = [process.env.WEBHOOK_SECRET_CURRENT, process.env.WEBHOOK_SECRET_PREVIOUS].filter(
(s): s is string => Boolean(s)
);
function verifyWithRotation(rawBody: string, signature: string): boolean {
// timingSafeEqual inside isValidSignature keeps each check constant-time.
return SECRETS.some((secret) => isValidSignature(rawBody, signature, secret));
}
Don't leak verification details in error responses. A 400 that helpfully explains why verification failed — "signature mismatch" vs "timestamp expired" vs "unknown event id" — is a debugging aid for an attacker probing your endpoint. Return a flat, uninformative 400 to the caller and put the detail in your logs, not the response body. Log enough to investigate an incident; never enough to hand an attacker a map.
// ANTI-PATTERN: the response tells an attacker exactly what to fix next.
return new Response(JSON.stringify({ error: "HMAC mismatch on body" }), { status: 400 });
// Opaque to the caller, detailed in your logs.
logger.warn({ reason: "signature_mismatch", eventId }, "webhook rejected");
return new Response("Bad Request", { status: 400 });
Calibrate the Defence to the Blast Radius
I am not telling you to build all of this for every webhook. That would be its own kind of malpractice — over-engineering an endpoint whose worst-case is "a Slack message gets posted twice."
For a low-stakes internal webhook — a CI notification, an internal Slack ping, anything where a forged or replayed event causes mild annoyance and nothing more — a valid signature over HTTPS is genuinely enough. Don't bolt a rotation scheme and a re-fetch round-trip onto something whose blast radius is zero.
The full threat model earns its complexity precisely when the endpoint moves money or grants access. That's where a replayed event mints a free subscription, where a trusted-but-stale amount under-charges a customer, where a flood takes down checkout. The rule of thumb I use: list what the worst legitimate-looking-but-malicious request can do at this endpoint. If the answer involves money, entitlements, or data exposure, build the whole model. If it's a notification nobody pays for, ship the signature and move on.
Takeaways
- The signature check proves origin and integrity — nothing else. It does not prove freshness, uniqueness, or that the payload's claims are a safe basis for action.
-
Verify the raw body before parsing, and compare HMACs with
timingSafeEqual, never===. - Defend replays with a signed-timestamp tolerance window plus event-ID deduplication. Both, not either.
- Never trust money or entitlement values straight from the webhook body. Treat the event as a trigger to re-fetch the authoritative object, then act on that.
- Treat the endpoint as a DoS vector: acknowledge fast and process async, cap body size, fail cheaply before hashing, and rate-limit abusive sources.
-
IP allow-listing is defence in depth, not authentication —
X-Forwarded-Foris spoofable; the signature is the real gate. - Rotate secrets with an overlap window, and keep verification-failure detail in your logs, not your responses.
- Calibrate to the blast radius. Signature + HTTPS is fine for a low-stakes internal hook; the full model is for endpoints that move money or grant access.
Top comments (0)