---
title: "Unverified Stripe webhooks"
slug: unverified-stripe-webhooks
severity: critical
category: Payments
summary: "A webhook is a public URL. If you trust the JSON without checking Stripe's signature, anyone can tell your app that a payment succeeded."
platforms:
  - Stripe
  - Next.js
  - Vercel
updated: "2026-10-13"
related:
  - client-sets-price-or-role
  - secret-keys-in-the-frontend
---

Stripe sends events such as `checkout.session.completed` to a URL you choose. That URL is on the internet. Stripe's webhook docs say they sign every event in the `Stripe-Signature` header, and you verify that signature with the endpoint's secret to confirm Stripe sent the body and it was not modified.

If the handler does `await request.json()` and then marks an order paid because `type` is a success event, anyone can POST that JSON. Your database will believe them.

Stripe also says verification needs the raw body. If the framework parses JSON first, or you re-serialize the object, the signature check fails or, worse, you skip it because it was annoying. Test mode and live mode have different endpoint secrets. Each endpoint has its own secret.

## How to check in 5 minutes

1. Find the route that mentions `stripe` and `webhook`, often `app/api/stripe/webhook/route.ts`.
2. Confirm it reads the raw body (`request.text()`, not `request.json()`) and passes that string, the `Stripe-Signature` header, and the endpoint secret to Stripe's library.
3. Confirm a failed check returns a 400 and does not update the order. A `try/catch` that logs the error and continues is the bug.
4. Confirm the secret is an environment variable, not a `NEXT_PUBLIC_` value, and that live and test deployments do not share one secret by accident.

## How to fix

### Next.js

```ts
import Stripe from "stripe";

export async function POST(request: Request) {
  const body = await request.text();
  const signature = request.headers.get("stripe-signature");
  const secret = process.env.STRIPE_WEBHOOK_SECRET;
  if (!signature || !secret || !process.env.STRIPE_SECRET_KEY) {
    return new Response("Misconfigured", { status: 500 });
  }

  const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);
  let event: Stripe.Event;
  try {
    event = stripe.webhooks.constructEvent(body, signature, secret);
  } catch {
    return new Response("Invalid signature", { status: 400 });
  }

  if (event.type === "checkout.session.completed") {
    // Update the order from event.data, which Stripe signed.
  }
  return new Response("ok");
}
```

Use the method Stripe's current Node docs show if it differs from `constructEvent`. The requirement is the same: raw payload, header, endpoint secret, and no side effects when verification throws.

Ignore events you do not handle. Do not grant access from `payment_intent.succeeded` and also from the client after `confirmCardPayment`. The webhook is the source of truth. The browser is not. See [the client sets the price or role](/issues/client-sets-price-or-role).

### Stripe

In the Dashboard webhook settings, Stripe documents an endpoint secret per endpoint. If you roll the secret, Stripe can keep the old one valid for up to 24 hours so you can deploy the new value. Test the handler with the Stripe CLI (`stripe listen --forward-to ...`), which Stripe's docs describe for local testing, and use the signing secret that command prints. A secret from the Dashboard will not match CLI-forwarded events.

### Vercel

The Route Handler above works on Vercel. Put `STRIPE_WEBHOOK_SECRET` and `STRIPE_SECRET_KEY` in project environment variables. Do not log the body of a failed signature check if the log pipeline is shared. Return 400 and stop.

### Supabase

If the handler writes "this user is paid" with the Supabase secret key, that write bypasses RLS, which is fine only after the signature check has passed. Do not let the browser write `plan = 'pro'` itself.

## Agent prompt

```text
You are fixing Stripe webhook verification. Do not add features.

Find the webhook route. Change it so that:
1. It reads the raw request body as text.
2. It verifies the Stripe-Signature header with the endpoint secret using Stripe's official library.
3. If verification throws, it returns 400 and does not write to the database.
4. Only the verified event grants a purchase, plan, or credit. Remove any path where the browser can set paid=true or plan=pro by itself.
5. The webhook secret is a server environment variable, never NEXT_PUBLIC_.

Do not print secrets. Tell me which env vars production needs, and that test and live endpoints use different secrets.
```

## FAQ

### Can I check a shared query parameter instead of the signature?

No. A query secret leaks via logs, browser history, and referrers. Stripe's documented check is the signature over the raw body.

### The signature check fails locally and I commented it out. Is that okay for now?

No. Use `stripe listen` and the secret it prints, which Stripe's webhook docs describe. A handler that accepts unsigned events is the production bug, even if you only meant it for a weekend.

## Sources

- [Stripe webhooks: signature verification and the raw body](https://docs.stripe.com/webhooks)
