Skip to content
ShipSecurelySecurity reference
Menu
Your tool here — sponsor ShipSecurely

Issues

Unverified Stripe webhooks

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.

Share link · Share on X

Last updated

criticalPayments

Shows up in: Stripe, Next.js, Vercel

Markdown version

What it is

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

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.

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.

Copy-paste prompt

Paste this into Cursor, Claude Code, or whichever agent is editing the repo. Read the diff before you merge it.

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.

Questions

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

Related