HomeBlog › Webhook signatures

Verifying webhook signatures from Stripe and others

Your webhook endpoint is a public URL that changes your data. If it does not check who sent the request, the only thing standing between a stranger and a free subscription is that they have not found it yet.

Webhook signature illustration: signed events crossing a bridge into an application after their signature is verified, while an event with an invalid signature is rejected and blocked before reaching the app.

Webhooks are how the services you depend on tell you something happened. A payment succeeded. A subscription lapsed. A file finished processing. You give the provider a URL, they POST to it, your application updates.

The awkward property is that the URL has to be publicly reachable, because the provider is on the internet. Which means anyone else on the internet can POST to it too. If your handler trusts what it receives, it will act on whatever it is told.

What an unverified endpoint allows

Take a normal payments integration. Stripe POSTs a payment_intent.succeeded event and your code marks the order paid and grants access.

Without verification, anyone who knows or guesses your endpoint can send the same shape of JSON. Your application has no way to distinguish it. It marks the order paid, because that is what it was told.

The same pattern applies wherever a webhook drives a decision: subscription.created grants a plan, invoice.paid extends a term, user.verified confirms an identity. Each is a free upgrade for anyone willing to send a POST.

Endpoint URLs are not secret either. They appear in provider dashboards, in error monitoring, in logs, in screenshots shared in support threads, and often in patterns predictable enough to guess: /webhooks/stripe is a reasonable first attempt on most sites.

How the signature works

When you create a webhook endpoint, the provider gives you a signing secret. It is not the same as your API key, and it exists only for this.

On each request the provider computes an HMAC over the request body, using that secret, and sends the result in a header along with a timestamp. You recompute the same value with the same secret and compare.

If they match, two things are true: the sender holds the signing secret, and the body has not been altered. If they do not, you reject the request without looking at what it says.

Implementing it

Every major provider ships a helper that does the comparison correctly, and you should use it rather than writing your own. In Node with Stripe:

app.post('/webhooks/stripe',
  express.raw({ type: 'application/json' }),   // raw body, not parsed
  (req, res) => {
    let event;
    try {
      event = stripe.webhooks.constructEvent(
        req.body,                               // the exact bytes received
        req.headers['stripe-signature'],
        process.env.STRIPE_WEBHOOK_SECRET
      );
    } catch (err) {
      return res.status(400).send('invalid signature');
    }
    // only now is it safe to act on event
    handle(event);
    res.json({ received: true });
  });

Three details matter more than they look.

The raw body. Signature verification is over exact bytes. If a body-parsing middleware runs first and you pass it the re-serialised object, verification fails on genuine requests because the bytes differ. This is the most common reason a correct implementation appears broken, and the fix is to exempt the webhook route from body parsing.

Reject before doing anything. The verification must happen before any handling. Logging the payload first is usually fine; writing to the database first is not.

Constant-time comparison. The provider's helper does this. Comparing strings with == can leak information through how long the comparison takes, which is exactly the sort of detail worth delegating to a library.

Replay, and the timestamp

A valid signed request stays valid. Anyone who captures one, from a log, a proxy, an error report, can send it again and it will verify, because nothing about it has changed.

Providers include a timestamp in the signature header for this reason, and their helpers usually reject anything outside a tolerance window by default, commonly five minutes. Do not widen that window to make a flaky test pass.

Idempotency handles the rest. Each event carries an identifier; recording which you have processed and ignoring repeats protects you from both replays and the provider's own legitimate retries. Providers do retry, deliberately, and a handler that is not idempotent will double-count perfectly honest deliveries.

Beyond the big providers

Most established services sign their webhooks. Smaller ones sometimes do not, and then you are choosing between weaker options:

  • A shared secret in the URL or a custom header. Weak, because it travels in logs and proxies, and better than nothing.
  • Source address allowlisting, if the provider publishes stable addresses. Workable, brittle when they change them, and no help if the provider is behind a shared cloud range.
  • Treating the webhook as a hint rather than a fact. This is the strongest option available when there is no signature: the webhook tells you something may have changed, and your code calls the provider's API to find out what is actually true before acting.

That last pattern is worth reaching for whenever the consequence is significant. A forged "payment succeeded" achieves nothing if your handler responds by asking the payment provider whether that payment exists.

Handling the delivery guarantees

Two properties of webhook delivery catch people out, and both interact with verification.

Order is not guaranteed. Events can arrive out of sequence, particularly under retry. A subscription.updated can land before the subscription.created that preceded it. Handlers that assume order produce states that should be impossible. Where it matters, use the timestamp or version on the event and ignore anything older than what you have already applied.

Delivery is at-least-once, not exactly-once. Providers retry when they do not get a prompt success, including when your handler succeeded but was slow to answer. This is the main practical reason to be idempotent, well before anyone malicious is involved.

Which leads to the other common mistake: doing the work before responding. If your handler verifies the signature, then spends fifteen seconds generating a PDF, the provider may time out and retry, and you now have two PDFs. Verify, record the event, return 200, and do the work afterwards from a queue. Fast acknowledgement is part of correct webhook handling, not an optimisation.

Checking what you have

Go through your endpoints. For each one, three questions:

  1. Does it verify a signature? Look for the verification call, and check that it runs before any side effect rather than after.
  2. What happens if verification fails? It should return 400 and do nothing else. An endpoint that logs the failure and carries on has verification in name only.
  3. Is it idempotent? Send the same event twice in staging and confirm the second one changes nothing.

Then test the negative case, which is the one nobody tests: send a request with a deliberately wrong signature and confirm you get a 400 and no state change. A verification path that has only ever been exercised by valid requests has not really been tested.

Both Stripe and most other providers offer a CLI that forwards real events to a local endpoint, which makes this straightforward to exercise during development rather than on a deployed environment. Sending one genuine event and one deliberately corrupted event through the same handler takes a couple of minutes and settles the question properly.

What a Trufend scan can and cannot tell you here. Whether your webhook handler verifies signatures is internal application behaviour. Testing it externally would mean sending forged events to your production system, which Trufend does not do: every check it runs is passive and read-only. Trufend reports the external view of your domain and is not a SOC 2 assessment.

Why this belongs in a readiness guide

It is a small, specific, entirely fixable flaw that turns a public URL into a way to change your data. Verification is a handful of lines using a library you already have installed, and the failure mode without it is somebody granting themselves whatever your webhook grants.

Questions people ask about this

What is a webhook signature?

A cryptographic value the sender computes from the request body and a shared secret, sent in a header. Recomputing it on your side with the same secret proves the request came from someone holding that secret and that the body was not modified.

Is HTTPS enough on its own?

No. HTTPS protects the request in transit and says nothing about who made it. Anyone can send an HTTPS request to your endpoint. Authenticity is what the signature provides.

Why must I use the raw request body?

The signature is computed over the exact bytes sent. If your framework parses the JSON and you then re-serialise it, key order or whitespace can differ and the computed signature will not match even when the request is genuine.

What is a replay attack in this context?

Capturing a genuine signed request and sending it again. The signature is still valid, because nothing was modified. Checking the timestamp in the signature header and rejecting anything older than a few minutes is the standard defence.

Can Trufend tell me if my webhook endpoint is unverified?

No, and it should not try. Determining that would mean sending forged events to your production system. Trufend performs only passive, read-only checks of what your domain exposes, and is not a SOC 2 assessment.

See what your domain exposes publicly

A free Trufend scan reports TLS, DNS, security headers, email authentication and exposed assets for any domain.

Run a free check