Verify webhook HMAC signatures
Confirm that an incoming webhook was sent by the provider and wasn't replayed. Uses a constant-time comparison and a timestamp tolerance, the same scheme Stripe and many others use.
import { createHmac, timingSafeEqual } from "node:crypto";
type VerifyInput = {
/** The raw request body, exactly as received */
payload: string | Buffer;
/** Header in the form "t=1700000000,v1=<hex signature>" */
header: string | null;
secret: string;
/** Reject signatures older than this to block replays */
toleranceSec?: number;
};
function sign(secret: string, timestamp: number, payload: string | Buffer): Buffer {
return createHmac("sha256", secret).update(`${timestamp}.`).update(payload).digest();
}
export function verifyWebhookSignature({
payload,
header,
secret,
toleranceSec = 300,
}: VerifyInput): boolean {
if (!header) return false;
const parts = new Map(
header.split(",").map((part) => {
const [key, ...rest] = part.trim().split("=");
return [key, rest.join("=")] as const;
}),
);
const timestamp = Number(parts.get("t"));
const signature = parts.get("v1");
if (!Number.isInteger(timestamp) || !signature) return false;
const age = Math.abs(Date.now() / 1000 - timestamp);
if (age > toleranceSec) return false;
const expected = sign(secret, timestamp, payload);
const received = Buffer.from(signature, "hex");
// timingSafeEqual throws on length mismatch, so check that first
return received.length === expected.length && timingSafeEqual(received, expected);
}
/** Produce a header for tests or for webhooks you send yourself */
export function signWebhook(secret: string, payload: string): string {
const timestamp = Math.floor(Date.now() / 1000);
return `t=${timestamp},v1=${sign(secret, timestamp, payload).toString("hex")}`;
}Usage
// app/api/webhooks/route.ts
import { verifyWebhookSignature } from "@/lib/webhook-signature";
export async function POST(req: Request) {
const payload = await req.text(); // raw body, before any JSON parsing
const ok = verifyWebhookSignature({
payload,
header: req.headers.get("x-signature"),
secret: process.env.WEBHOOK_SECRET!,
});
if (!ok) return new Response("Invalid signature", { status: 401 });
const event = JSON.parse(payload);
// ...enqueue the event and return quickly
return Response.json({ received: true });
}How it works & gotchas
- 01Always sign and verify the raw body. Parsing and re-serialising JSON changes whitespace and key order, and the signature will never match.
- 02
timingSafeEqualavoids leaking how many bytes matched through response timing. A plain===on strings does not. - 03The timestamp is part of the signed content, so an attacker can't replay an old request with a fresh timestamp.
- 04Header names and formats differ per provider. Adjust the parsing, but keep the raw body, the HMAC and the constant-time compare.
- 05Webhooks can still be delivered twice legitimately. Deduplicate on the event ID before acting on it.
Need this wired into a real system?
We build and harden production software end to end.
More code helpers
Redis sliding-window rate limiter
Limit requests per user, API key or IP across every instance of your service. A single Lua script keeps the check-and-increment atomic, so concurrent requests can't slip past the limit.
Graceful shutdown for Node.js HTTP servers
Finish in-flight requests and close database connections before the process exits, so deploys and autoscaling don't drop requests or leave half-written work.
Stream LLM responses from a Next.js route handler
Send tokens to the browser as the model produces them instead of making users wait for the full completion. Uses a plain ReadableStream, so it works with any client that can read a fetch body.