DEV Community

Cover image for Can someone fake a Stripe payment to your app? Only if your webhook skips the signature check
WebhookProof
WebhookProof

Posted on

Can someone fake a Stripe payment to your app? Only if your webhook skips the signature check

Here's a question worth asking about any app that sells something through Stripe: what happens if someone who never paid sends your webhook endpoint a JSON body that looks like a completed checkout?

If the endpoint checks Stripe's signature, nothing happens. The request is rejected. If it doesn't, your app grants access, sends the download link, or writes the order, exactly as if the payment were real. This post covers why that works, what Stripe recommends against it, and how to test your own endpoint in one command. Every claim links to the doc it comes from (see Sources at the end).

Stripe spells out the risk

From Stripe's webhook docs: "Without verification, an attacker could send fake webhook events to your endpoint to trigger actions like fulfilling orders, granting account access, or modifying records. Always verify that webhook events originate from Stripe before acting on them."

A webhook endpoint is just a public URL that accepts POST requests. Nothing about HTTP proves who sent the request. The JSON body carries no proof either: anyone can type "type": "checkout.session.completed".

Why a hidden URL doesn't help

It's tempting to think an obscure path is enough. In practice, webhook routes follow a few familiar patterns (/api/webhooks/stripe, /api/stripe/webhook), they show up in tutorials and generated code, and they end up in public repos. Treat the URL as public and rely on the checks below instead.

What Stripe says protects you

Stripe's docs list two protections and say to "Use both of these protections":

  • Signature verification. "Stripe signs every webhook event by including a signature in the Stripe-Signature header. Verify this signature using our official libraries or manually to confirm that the event wasn't sent or modified by a third party."
  • IP allowlisting. "Stripe sends webhook events from a set list of IP addresses. Configure your server or firewall to only accept webhook requests from these addresses." The current list is on Stripe's IP addresses page.

The signature is computed with a secret only Stripe and your server know. Stripe: "Stripe generates signatures using a hash-based message authentication code (HMAC) with SHA-256." Each endpoint has its own: "Stripe generates a unique secret key for each endpoint. If you use the same endpoint for both test and live API keys, the secret is different for each one."

With the official Node library, verification is one call. It needs the raw request body, the header, and the endpoint's whsec_ secret:

// Next.js App Router route handler
import Stripe from 'stripe'

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!)

export async function POST(req: Request) {
  const body = await req.text() // raw body, not parsed JSON
  const sig = req.headers.get('stripe-signature')

  let event: Stripe.Event
  try {
    event = stripe.webhooks.constructEvent(body, sig!, process.env.STRIPE_WEBHOOK_SECRET!)
  } catch {
    return new Response('Invalid signature', { status: 400 })
  }

  // Only now is it safe to act on event.type
  return new Response('ok', { status: 200 })
}
Enter fullscreen mode Exit fullscreen mode

The raw body matters. Stripe: "Stripe requires the raw body of the request to perform signature verification. If you're using a framework, make sure it doesn't manipulate the raw body. Any manipulation to the raw body of the request causes the verification to fail."

If you verify manually instead of with a library, Stripe adds a detail that's easy to miss: "To protect against timing attacks, use a constant-time-string comparison to compare the expected signature to each of the received signatures."

What about replaying a real event?

A signature stops forged events. A captured real event is a different attack, and Stripe covers it too. "A replay attack is when an attacker intercepts a valid payload and its signature, then re-transmits them. To mitigate such attacks, Stripe includes a timestamp in the Stripe-Signature header. Because this timestamp is part of the signed payload, it's also verified by the signature, so an attacker can't change the timestamp without invalidating the signature."

The libraries enforce it for you: "Our libraries have a default tolerance of 5 minutes between the timestamp and the current time." Leave that tolerance alone. Stripe warns: "Don't use a tolerance value of 0. Using a tolerance value of 0 disables the recency check entirely."

A second check that a forged event can't pass

Stripe's fulfillment guide already builds in a defense. Its fulfill_checkout function is required to "Retrieve the Checkout Session from the API with the line_items property expanded" and "Check the payment_status property to determine if it requires fulfillment."

That means fulfillment trusts the answer from Stripe's API, called with your secret key, not the contents of the incoming request. A made-up session ID won't come back from the API as paid.

The same applies to the success page. Stripe's guide has the landing page "extract the Checkout Session ID from the URL" and then "Run your fulfill_checkout function with the ID provided", which retrieves and checks the session. A page that grants access just because someone loaded /success?session_id=..., without that lookup, is the same hole in a different place.

Test your own endpoint in one command

Send an unsigned request with an empty JSON body to your own endpoint:

curl -i -X POST \
  -H 'content-type: application/json' \
  -d '{}' \
  https://yourdomain.com/api/webhooks/stripe
Enter fullscreen mode Exit fullscreen mode
  • 400 (or another 4xx from your signature check): good. Unsigned requests are rejected.
  • 200: the endpoint accepts requests without a valid signature. Fix this before anything else.
  • 3xx, 401, 403, 404: something in front of your code answered first (a redirect, an auth wall, a wrong path). That's a separate problem, and it usually means Stripe's real events aren't getting through either.

Only test endpoints you own or are authorized to test.

Checklist

  • [ ] Every event is verified with the official library (or a constant-time manual check) before any action
  • [ ] Verification uses the raw request body and the whsec_ secret for this endpoint and this mode
  • [ ] Signature failures return 400 and do nothing else
  • [ ] The timestamp tolerance is the default, never 0
  • [ ] Requests are limited to Stripe's published webhook IP addresses where your host allows it
  • [ ] Fulfillment retrieves the Checkout Session from the API and checks payment_status
  • [ ] The success page never grants access from the URL alone

WebhookProof's free checker runs the unsigned-request test above for you: paste a webhook URL and it sends 3 harmless requests to report whether Stripe can reach the endpoint and whether it rejects unsigned events. The full write-up is here: Can someone fake a payment to your app?

WebhookProof is an independent tool, not affiliated with Stripe, Inc. Quotes are from the linked documentation as read on 2026-10-26.

Sources

  • Stripe, Receive Stripe events in your webhook endpoint (fake-event risk, use both protections, IP allowlisting, signature verification, unique secret per endpoint and mode, raw body, HMAC SHA-256, constant-time comparison, replay attacks, 5-minute default tolerance, don't use 0): https://docs.stripe.com/webhooks
  • Stripe, IP addresses (webhook source IPs): https://docs.stripe.com/ips
  • Stripe, Fulfill orders, full-page Checkout (retrieve the Checkout Session, check payment_status, landing page extracts the session ID and runs fulfill_checkout): https://docs.stripe.com/checkout/fulfillment?ui=full-page

Top comments (0)