WritingStripe Webhook Signature Verification: A Complete Security Guide — Clixo
4 min readstripe, webhooks, security, backend, authentication

Stripe Webhook Signature Verification: A Complete Security Guide

Learn how Stripe webhook signature verification works, why raw body handling matters, and how to build a secure, tamper-resistant webhook endpoint in production.

Every Stripe webhook endpoint is a public HTTP endpoint that accepts POST requests. Without proper signature verification, it is also an open door for attackers to fabricate payment events — triggering fulfillment, unlocking access, or corrupting your financial records without a single real transaction. This guide covers exactly how Stripe's signature mechanism works and how to implement it correctly across different server frameworks.

How Stripe Webhook Signature Verification Works

When Stripe sends a webhook, it computes an HMAC-SHA256 signature over the raw request body using your endpoint's webhook signing secret. The signature is included in the Stripe-Signature header alongside a timestamp:

Stripe-Signature: t=1714502400,v1=abc123...,v0=legacy_signature

The t value is the Unix timestamp of when Stripe sent the request. The v1 value is the HMAC signature. Stripe's SDK reconstructs the signed payload as timestamp.raw_body, computes the HMAC with your secret, and compares it to the v1 value in the header.

If they match, the request genuinely came from Stripe. If they do not match, something altered the body or the header in transit — or the request did not come from Stripe at all.

Stripe Webhook Signature Verification: Framework Implementation

Node.js with Express

The most common mistake is using express.json() middleware before your webhook route. Express parses the JSON body into an object, and when your handler calls JSON.stringify() on that object, byte-level differences (key ordering, whitespace) break the signature.

// Correct: parse the raw body for webhook routes only
app.use(
  "/webhooks/stripe",
  express.raw({ type: "application/json" }),
  (req, res) => {
    const sig = req.headers["stripe-signature"];
    let event;
 
    try {
      event = stripe.webhooks.constructEvent(
        req.body,   // Buffer — raw bytes from express.raw()
        sig,
        process.env.STRIPE_WEBHOOK_SECRET
      );
    } catch (err) {
      console.error("Signature verification failed:", err.message);
      return res.status(400).send("Webhook Error");
    }
 
    // handle event...
    res.json({ received: true });
  }
);

Apply express.json() globally and express.raw() on the webhook route specifically — do not try to re-parse a req.body that has already been processed as JSON.

Next.js App Router

Next.js App Router does not run Express middleware. Use req.text() to get the raw body as a string — Stripe's SDK accepts both Buffer and string.

export async function POST(req: Request) {
  const rawBody = await req.text();
  const sig = req.headers.get("stripe-signature")!;
 
  let event: Stripe.Event;
  try {
    event = stripe.webhooks.constructEvent(rawBody, sig, process.env.STRIPE_WEBHOOK_SECRET!);
  } catch (err) {
    return new Response("Signature verification failed", { status: 400 });
  }
 
  // handle event...
  return new Response("ok", { status: 200 });
}

Mark the route segment as export const dynamic = "force-dynamic" if you see caching issues with incoming POST requests.

Python with FastAPI

@app.post("/webhooks/stripe")
async def stripe_webhook(request: Request):
    raw_body = await request.body()
    sig = request.headers.get("stripe-signature")
 
    try:
        event = stripe.Webhook.construct_event(
            raw_body, sig, os.environ["STRIPE_WEBHOOK_SECRET"]
        )
    except stripe.error.SignatureVerificationError:
        raise HTTPException(status_code=400, detail="Invalid signature")
 
    # handle event...
    return {"received": True}

Avoid Pydantic model parsing on the request body before this handler runs. Use await request.body() to get raw bytes.

Timestamp Tolerance and Replay Attack Prevention

Stripe's SDK checks that the timestamp in the Stripe-Signature header is within a configurable tolerance of the current time (default: 300 seconds). This prevents replay attacks — an attacker capturing a valid signed request and re-sending it later.

You can adjust the tolerance:

stripe.webhooks.constructEvent(rawBody, sig, secret, 600); // 10-minute tolerance

Do not disable the tolerance check by setting it to 0 or a very large number. If your server clock is significantly drifted, fix the NTP configuration rather than widening the window.

Testing Signature Verification Locally

Use the Stripe CLI to forward events and confirm your signature verification is working:

stripe listen --forward-to localhost:3000/webhooks/stripe
stripe trigger payment_intent.succeeded

The CLI automatically signs forwarded events with a test secret. Confirm that a 400 response triggers when you temporarily use the wrong secret, and a 200 response triggers with the correct one.

What Happens When Verification Fails

Return 400 Bad Request on signature failure. Do not return 200 — returning success tells Stripe the event was processed, which stops retries and leaves the failure silent. A consistent stream of 400 responses from your webhook endpoint should trigger an alert in your monitoring pipeline.

Securing your webhook endpoint is not optional if your fulfillment logic has any financial consequence. Start a build with Clixo if you want the complete payment backend built and audited.