← Alle Neuigkeiten
resilience5 Min. Lesezeit

Why webhook handlers need signature verification and idempotency

Von Seaworthy · 27. Juli 2026

A red padlock resting on a black computer keyboard, symbolizing endpoint security

Photo by FlyD on Unsplash

Two ways a webhook handler quietly ruins your day

Webhooks are the part of the stack everyone treats as an afterthought. You wire up Stripe or Paddle, point an endpoint at /api/webhooks/payment, parse the JSON, update the database, ship it. It works in testing. It works in the demo. Then it breaks in one of two ways, both of which look nothing like a bug report and both of which involve money.

The first way: someone who isn't Stripe sends you a request that looks exactly like Stripe's. The second way: Stripe sends you the same event twice, and your handler runs it twice. Different failure modes, same root cause: the handler trusts the request more than it should.

Failure mode one: anyone can call your endpoint

Your webhook URL is public. It has to be, that's the point, Stripe needs to reach it from the internet. But "public URL that triggers privileged actions" is also the description of a vulnerability if you don't check who's actually calling it.

Here's the naive version, which I promise exists in production right now on more than one AI-scaffolded SaaS:

// DO NOT DO THIS
app.post('/api/webhooks/stripe', express.json(), async (req, res) => {
  const event = req.body;

  if (event.type === 'payment_intent.succeeded') {
    await grantAccess(event.data.object.customer);
  }

  res.status(200).send('ok');
});

There is nothing here that proves this request came from Stripe. Anyone with your endpoint URL, found by watching your frontend's network tab or just guessing the obvious path, can POST a fake payment_intent.succeeded event with any customer ID they like and get free access. No card required, because there's no card involved at all.

The fix is signature verification, and every provider that matters ships a library for it. Stripe signs each request with a secret only you and Stripe know, and includes the signature in a header. You verify it before you trust anything in the body:

import Stripe from 'stripe';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);

app.post(
  '/api/webhooks/stripe',
  express.raw({ type: 'application/json' }), // must be raw, not parsed JSON
  async (req, res) => {
    let event;
    try {
      event = stripe.webhooks.constructEvent(
        req.body,
        req.headers['stripe-signature'],
        process.env.STRIPE_WEBHOOK_SECRET
      );
    } catch (err) {
      return res.status(400).send(`Webhook signature verification failed: ${err.message}`);
    }

    if (event.type === 'payment_intent.succeeded') {
      await grantAccess(event.data.object.customer);
    }

    res.status(200).send('ok');
  }
);

Two things trip people up here. First, constructEvent needs the raw request body, byte for byte, to compute the signature correctly. If you've already run a global express.json() middleware upstream, the body is parsed and re-serialized JSON, which won't match the signature Stripe computed on the original bytes. You need the raw body specifically for this route, which is why the raw parser is scoped to just this handler. Second, the webhook secret is not your API key. It's a separate value, per-endpoint, that you get when you register the webhook in the dashboard. Mixing those up is a classic five-minutes-of-confused-debugging moment.

Paddle works the same way in spirit: an HMAC-SHA256 signature in a header, computed from your webhook secret, checked before you touch the payload. If you're building this from scratch instead of using an SDK, the pattern is: read the raw body, compute the HMAC yourself with the shared secret, compare it to the header using a constant-time comparison, and reject anything that doesn't match. Don't use === on the raw strings, that's a timing side channel, use crypto.timingSafeEqual.

Failure mode two: the same event arrives more than once

Signature verification tells you the event is real. It says nothing about whether you've already processed it. And Stripe, Paddle, and basically every webhook provider will send you the same event more than once, on purpose. If your endpoint doesn't respond fast enough, times out, or returns a non-2xx status because your database had a bad moment, the provider retries. That's the correct behavior on their end. Delivery is "at least once," never "exactly once."

So imagine your handler does this:

if (event.type === 'checkout.session.completed') {
  await fulfillOrder(event.data.object); // ships the product, sends the email
}

fulfillOrder decrements inventory and emails a confirmation. The first delivery succeeds but your server takes nine seconds to respond because of a slow query elsewhere in the request path, Stripe's client times out waiting, and it retries the same event a minute later. Now you've shipped two units for one purchase and the customer got two "your order is on the way" emails. Multiply that by every slow response or transient 500 you'll ever have, and it stops being a hypothetical.

The fix is an idempotency check: before you act on an event, check whether you've already acted on it. Every Stripe and Paddle event carries a stable event ID. Store the IDs you've processed and skip anything you've seen:

async function handleEvent(event) {
  const alreadyProcessed = await db.processedWebhookEvents.findUnique({
    where: { eventId: event.id },
  });
  if (alreadyProcessed) return; // seen it, nothing to do

  if (event.type === 'checkout.session.completed') {
    await fulfillOrder(event.data.object);
  }

  await db.processedWebhookEvents.create({ data: { eventId: event.id } });
}

Watch the ordering here too: recording the event ID needs to happen atomically with the side effect it guards, ideally in the same database transaction, otherwise a crash between "ship the order" and "record the event ID" reopens the exact race you're trying to close. A UNIQUE constraint on eventId at the database level is your actual safety net; the findUnique check is just there to skip the work early and avoid a wasted call to your fulfillment logic.

Both checks, together

Signature verification answers "is this real." Idempotency answers "have I already done this." Skip either one and the failure is invisible until it's a support ticket or a chargeback. Seaworthy's static analysis specifically flags webhook handlers that parse and act on request bodies without a signature check, and handlers that mutate state (orders, subscriptions, credits) without any idempotency guard against the event ID, exactly this pair of defects, because they're the two ways a webhook route quietly stops being trustworthy.

Dieser Artikel wurde von KI generiert und fasst öffentlich verfügbare Quellen zusammen.

Von den Machern von Seaworthy

Seaworthy scannt dein Repository nach den Problemen, die in Artikeln wie diesem behandelt werden.

Sicherheitslücken, offengelegte Geheimnisse und Fehlkonfigurationen — erkannt, bevor du bereitstellst. Kostenlos, kein Konto nötig.

$ npx seaworthycode