# 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.

- **Published:** 2026-02-15
- **Author:** Clixo
- **Reading time:** 4 min read
- **Tags:** stripe, webhooks, security, backend, authentication
- **Canonical URL:** https://clixo.sh/blog/stripe-webhook-signature-verification-advanced

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.

```mermaid
flowchart TD
  A["Incoming POST request"] --> B["Read raw body bytes"]
  B --> C["Extract Stripe-Signature header"]
  C --> D["Reconstruct signed string: timestamp.raw_body"]
  D --> E["Compute HMAC-SHA256 with webhook secret"]
  E --> F{Signatures match?}
  F -->|No| G["Return 400 Bad Request"]
  F -->|Yes| H{Timestamp within tolerance?}
  H -->|No| G
  H -->|Yes| I["Process event safely"]
```

## 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.

```javascript
// 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`.

```typescript
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

```python
@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:

```javascript
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](https://clixo.sh/#contact) with Clixo if you want the complete payment backend built and audited.

---

Clixo · 1141 W Bryn Mawr Ave, Itasca, IL 60143, US · [hello@clixo.sh](mailto:hello@clixo.sh)
[Start a build](https://clixo.sh/#contact) · [All services](https://clixo.sh/services) · [Agent guide (llms.txt)](https://clixo.sh/llms.txt)
