A webhook endpoint that doesn't verify signatures will happily accept a forged "payment succeeded" event β and hand out paid access for free. Verifying the stripe-signature header is not optional. Here's how to do it correctly in a Next.js 15 App Router route handler, with the two gotchas (raw body + runtime) that trip most people up.
await req.text() and never req.json() before verifying.node:crypto. Add export const runtime = "nodejs" so it doesn't run on the Edge.Stripe's stripe-signature header looks like t=<unix>,v1=<hex>, where v1 is HMAC-SHA256(secret, "{t}.{payload}"). You can verify it with just node:crypto β no dependency:
import { createHmac, timingSafeEqual } from "node:crypto";
const TOLERANCE = 60 * 5; // reject events older than 5 min (replay defense)
export function verifyStripeSignature(payload: string, header: string | null, secret: string): boolean {
if (!header) return false;
const parts: Record<string, string> = {};
for (const kv of header.split(",")) {
const [k, v] = kv.split("=");
if (k && v) parts[k.trim()] = v.trim();
}
const t = parts.t, sig = parts.v1;
if (!t || !sig) return false;
// Replay window
const age = Math.abs(Date.now() / 1000 - Number(t));
if (!Number.isFinite(age) || age > TOLERANCE) return false;
// HMAC over `${t}.${rawBody}`
const expected = createHmac("sha256", secret).update(`${t}.${payload}`).digest("hex");
const a = Buffer.from(expected), b = Buffer.from(sig);
// constant-time compare β and length-check first (timingSafeEqual throws on mismatch)
return a.length === b.length && timingSafeEqual(a, b);
}
export const runtime = "nodejs";
export async function POST(req: Request) {
const payload = await req.text(); // RAW body, before any parse
const secret = process.env.STRIPE_WEBHOOK_SECRET;
if (!secret) return new Response("not configured", { status: 500 });
if (!verifyStripeSignature(payload, req.headers.get("stripe-signature"), secret)) {
return new Response("invalid signature", { status: 400 }); // reject forgeries
}
const event = JSON.parse(payload); // safe to parse AFTER verifying
switch (event.type) {
case "customer.subscription.updated":
case "customer.subscription.deleted":
// ... update your DB (idempotently β Stripe retries)
break;
}
return Response.json({ received: true });
}
req.json()) β breaks the byte-exact hash; people then "fix" it by skipping verification. Don't.expected === sig) β timing-leaky. Use timingSafeEqual with a length check.node:crypto isn't available; force runtime = "nodejs".Use the Stripe CLI: stripe listen --forward-to localhost:3000/api/billing/webhook. It prints a signing secret (whsec_...) β set that as STRIPE_WEBHOOK_SECRET in dev, then stripe trigger customer.subscription.updated to fire a real, correctly-signed test event.