Webhook signature verification
Complete guide to building secure webhook receivers: signing schemes, replay protection, idempotency, retry handling, secret rotation, and the gotchas that bite every team.
Why Sign Webhooks At All
A webhook endpoint is just a URL. Anyone who knows it can POST to it. Without verification, anyone can lie to your system.
Consequences of an unverified webhook.
- An attacker POSTs a fake "payment.succeeded" event. You ship the order without taking money.
- An attacker POSTs a fake "subscription.deleted." You cancel a paying user.
- An attacker POSTs a fake "user.created" with their email tied to someone else's account ID.
Webhook signing solves the authentication problem: this request actually came from the provider that holds the secret.
The Signing Construction
Almost every provider uses HMAC-SHA256. The variations are.
- What's in the signed string. Raw body alone, or timestamp plus body, or method plus path plus body.
- The encoding of the tag. Hex or base64.
- The header name and format.
X-Hub-Signature-256: sha256=...vsStripe-Signature: t=,v1=....
The differences exist because providers invented this independently before any standard existed. Cryptographically they are equivalent: HMAC-SHA256 with a shared secret over a deterministic byte string.
The Stripe Pattern: Best In Class
Stripe set the standard most others follow. The signed string is ${timestamp}.${rawBody}. The signature header is.
Stripe-Signature: t=1700000000,v1=abc123def456...,v0=oldformat...
Multiple versions can coexist. v1 is the current scheme. v0 was an older one. New versions can roll out without breaking old clients. This is good design.
Verification.
import crypto from "node:crypto";
interface VerifyOptions {
tolerance?: number; // seconds
}
export function verifyStripeWebhook(
rawBody: string | Buffer,
signatureHeader: string,
secret: string,
options: VerifyOptions = {},
): { valid: boolean; reason?: string; timestamp?: number } {
const tolerance = options.tolerance ?? 300;
const parts = Object.fromEntries(
signatureHeader.split(",").map(p => {
const i = p.indexOf("=");
return [p.slice(0, i), p.slice(i + 1)];
}),
);
const ts = Number(parts.t);
const v1 = parts.v1;
if (!ts || !v1) return { valid: false, reason: "malformed header" };
if (Math.abs(Date.now() / 1000 - ts) > tolerance) {
return { valid: false, reason: "stale timestamp", timestamp: ts };
}
const signed = `${ts}.${typeof rawBody === "string" ? rawBody : rawBody.toString("utf8")}`;
const expected = crypto.createHmac("sha256", secret).update(signed).digest("hex");
const a = Buffer.from(v1, "hex");
const b = Buffer.from(expected, "hex");
if (a.length !== b.length) return { valid: false, reason: "tag length" };
if (!crypto.timingSafeEqual(a, b)) return { valid: false, reason: "tag mismatch" };
return { valid: true, timestamp: ts };
}The Raw Body Problem
This is the bug everyone hits. Frameworks parse JSON automatically and you lose the original bytes.
In Express.
// WRONG: app.use(express.json()) before webhook route
// Now req.body is the parsed object, the raw bytes are gone
// RIGHT: raw middleware specifically for the webhook route
app.post("/webhook", express.raw({ type: "application/json" }), (req, res) => {
// req.body is a Buffer here
const valid = verifyStripeWebhook(req.body, req.header("Stripe-Signature")!, secret);
if (!valid.valid) return res.status(400).send("bad sig");
const event = JSON.parse(req.body.toString("utf8"));
// process event
res.send("ok");
});In Next.js App Router, route handlers give you req.text() which returns the raw body string.
export async function POST(req: NextRequest) {
const rawBody = await req.text();
const sig = req.headers.get("Stripe-Signature")!;
// verify against rawBody
}In Next.js Pages Router, you have to disable the built-in body parser.
export const config = { api: { bodyParser: false } };Then read the raw body with a stream helper. This is a common source of "it works in dev but fails in prod" bugs when middleware ordering differs.
Timestamp and Replay Protection
Without a timestamp, an attacker who captures one valid webhook can replay it forever.
The Stripe pattern: include the timestamp in the signed string and in the header. Verify both.
- Compute HMAC against
timestamp.body. Catches tampering with either field. - Compare timestamp to current time. Reject if older than tolerance (5 minutes typical).
The 5-minute tolerance accounts for clock skew between the provider and your server, plus network delays. Lower if you can keep clocks synced. Higher only if you have a reason.
Edge case: clock skew. If your server clock is wrong by 10 minutes, every webhook fails verification. Use NTP, monitor clock drift, alert on drift over 30 seconds.
Idempotency
Providers retry webhooks. Stripe retries with exponential backoff for up to 3 days. GitHub retries up to 8 times over 8 hours. Your endpoint will receive duplicates.
Two scenarios cause duplicates.
- Your endpoint returns non-2xx. Provider retries.
- Your endpoint succeeds but the response is lost (network issue). Provider thinks it failed, retries.
Solution: use the event ID as an idempotency key.
async function handleWebhook(event: StripeEvent) {
const exists = await db.processedEvent.findUnique({ where: { id: event.id } });
if (exists) return; // already handled
await db.$transaction(async tx => {
await tx.processedEvent.create({ data: { id: event.id, type: event.type } });
// do the work
});
}The processedEvent table is your dedup ledger. Storing the event ID before the work means a retry sees the ID and skips. Using a transaction means the dedup record and the work commit together.
If your work is "create a row in our DB," you can often skip the explicit ledger by using a unique constraint on a field derived from the event ID. Two attempts to insert the same row, the second one errors with unique-violation, you catch and ignore.
Retry Handling
Your endpoint contract.
- 2xx: success, do not retry.
- 4xx: permanent failure, do not retry (provider behavior varies).
- 5xx: temporary failure, retry with backoff.
- Timeout (>10s typically): retry.
Implications.
- Respond fast. Acknowledge the webhook in under a second. If you need to do heavy work, queue it and respond 200 immediately.
- Return 200 even if you intentionally ignored the event. Returning 400 makes the provider keep retrying.
- Distinguish between "we got it and processed it" and "we got it and chose to ignore it." Both are 200.
export async function POST(req: NextRequest) {
const rawBody = await req.text();
const sig = req.headers.get("Stripe-Signature")!;
const result = verifyStripeWebhook(rawBody, sig, process.env.STRIPE_WEBHOOK_SECRET!);
if (!result.valid) {
return new Response("bad signature", { status: 400 });
}
const event = JSON.parse(rawBody);
await jobQueue.enqueue("processStripeEvent", { event });
return new Response("queued", { status: 200 });
}Secret Rotation
You will need to rotate webhook secrets. Someone left the company, a backup leaked, or just hygiene.
Stripe lets you have two webhook secrets active simultaneously per endpoint. The pattern.
- Generate new secret in the Stripe dashboard. Now you have two valid secrets.
- Update your server config to accept either secret.
- Wait for the next event to confirm both work.
- In the dashboard, swap to send only the new secret.
- Remove the old secret from your server config.
const SECRETS = [
process.env.STRIPE_WEBHOOK_SECRET_NEW!,
process.env.STRIPE_WEBHOOK_SECRET_OLD!,
].filter(Boolean);
function verifyWithAny(rawBody: string, sig: string): boolean {
return SECRETS.some(secret => verifyStripeWebhook(rawBody, sig, secret).valid);
}Zero downtime, no missed events.
Multi-Provider Patterns
If you receive webhooks from many providers, factor out a generic verification interface.
interface WebhookProvider {
parseSignature(headers: Headers): { tag: string; timestamp?: number };
buildSigningString(timestamp: number | undefined, rawBody: string): string;
encoding: "hex" | "base64";
}
const STRIPE: WebhookProvider = {
parseSignature: h => {
const parts = Object.fromEntries(h.get("stripe-signature")!.split(",").map(s => s.split("=")));
return { tag: parts.v1, timestamp: Number(parts.t) };
},
buildSigningString: (ts, body) => `${ts}.${body}`,
encoding: "hex",
};
const GITHUB: WebhookProvider = {
parseSignature: h => ({ tag: h.get("x-hub-signature-256")!.replace("sha256=", "") }),
buildSigningString: (_ts, body) => body,
encoding: "hex",
};The Standard Webhooks spec (standardwebhooks.com) tries to unify this. Adoption is climbing. Svix is the leading hosted webhook delivery service and implements the spec.
Defense In Depth
Signature verification is necessary but not sufficient. Stack additional layers.
- TLS only. Reject http:// webhook URLs even in dev.
- Source IP allowlist. Stripe publishes their IP ranges. Drop traffic from anywhere else. Beware NAT changes.
- mTLS. Some enterprise webhook providers offer mutual TLS, your server presents a cert to identify itself.
- Rate limiting. Even with valid signatures, a compromised webhook account should not be able to flood you.
- Monitoring. Alert on signature failures. A spike means someone is probing or your secret leaked.
Sending Webhooks: The Other Side
If you're the provider sending webhooks, similar considerations apply in reverse.
- Sign every outgoing webhook with HMAC-SHA256 over
timestamp.body. - Include the timestamp in the signed string and in a header.
- Use a different secret per receiver. Rotate-able.
- Retry on 5xx and timeouts. Exponential backoff. Give up after a reasonable window (24 hours typical).
- Use a queue between event generation and delivery. Decouples your app from receiver flakiness.
- Set User-Agent including your service name and version.
- Document your IP ranges if you commit to a fixed set.
- Provide a dashboard for receivers to see delivery history and replay events.
Common Pitfalls
- Parsing the body before verification. Lost raw bytes break HMAC.
- Plain string equality. Timing side channel leaks the tag.
- No timestamp tolerance check. Replay attacks valid forever.
- No idempotency. Retries cause duplicate side effects.
- Trusting the provider's auto-generated webhook secret without storing it securely. It is a credential.
- Returning 200 for invalid signatures (to avoid retries). Now your endpoint can be poked by anyone.
- Logging the raw body containing PII or sensitive event content. Scrub logs.
Interview Soundbites
- "HMAC-SHA256 over timestamp plus raw body, constant-time compare, reject stale timestamps over 5 minutes."
- "Raw body, not parsed JSON. The number-one bug is reserializing and breaking the HMAC."
- "Idempotency via the event ID. Providers retry, your handler must be safe to call twice."
- "Always have two secrets active during rotation. Zero downtime."
Learn more
- Docs
- Docs
- Docs
- DocsStandard Webhooks specStandard Webhooks
- Docs