Verify a Stripe Webhook in Next.js β€” the Secure Way

Updated July 2026 Β· ~6 min read Β· Next.js 15 App Router

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.

The two things Next.js gets in your way about

The verification, without the Stripe SDK

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);
}

The route handler β€” fail closed

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 });
}
⚠️ Fail closed: if the secret is missing or the signature is invalid, reject β€” never "allow on error." And verify before you parse or act on anything in the body.

Mistakes that let forged events through

πŸ›  Want this already wired up? SaaS Starter β†’
A production Next.js 15 boilerplate that ships this exact signature-verified webhook (HMAC-SHA256, constant-time, replay-protected), plus multi-tenancy, RBAC, API keys, and audit logs. Free Lite on GitHub; full version one-time $99.

Testing locally

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.