</>CodeWithKarani

Your Webhook Endpoint Trusts Anyone Who Can curl It

Karani GeoffreyKarani Geoffrey8 min read

Here is the handler that ships in most quick-start tutorials, and in a lot of production code:

app.post('/webhooks/stripe', express.json(), async (req, res) => {
  const event = req.body
  if (event.type === 'checkout.session.completed') {
    await markOrderPaid(event.data.object.metadata.orderId)
  }
  res.sendStatus(200)
})

Now here is the entire attack against it, which requires nothing except knowing the URL:

curl -X POST https://api.example.com/webhooks/stripe \
  -H 'Content-Type: application/json' \
  -d '{"type":"checkout.session.completed",
       "data":{"object":{"metadata":{"orderId":"12345"}}}}'

The order is now paid. No card was charged. Your fulfilment process runs.

A webhook endpoint without signature verification is an unauthenticated RPC that writes to your database, and the only thing defending it is that the URL is not in your sitemap. That is obscurity with a TLS certificate on top. And webhook URLs leak constantly: browser devtools during setup, a screenshot in a support ticket, a Postman collection in a public repo, a stack trace, the provider's own dashboard on a shared screen.

  • Verify the HMAC signature before you parse or act on any part of the payload.
  • Verify against the exact raw request bytes. If body-parsing middleware runs first and you re-serialize with JSON.stringify, the digest will not match even for a legitimate request.
  • Compare with a constant-time function: crypto.timingSafeEqual in Node, hmac.compare_digest in Python. Never ==.
  • Use the signing secret for that specific endpoint. Reusing one secret across several endpoints is a top cause of "verification failed".
  • Reject stale timestamps, then de-duplicate by event ID. Verification stops forgery; it does not stop replay of a genuine event.

The error you get once you add verification

Webhook signature verification failed. Err: No signatures found matching the expected signature for payload.

Almost everyone hits this on the first attempt, concludes the library is broken, and reaches for the workaround that removes the protection entirely. It is not broken. In my experience this message means one of exactly three things: you verified against a re-serialized body instead of the raw bytes, you used the wrong endpoint's signing secret, or something between the internet and your process modified the body in transit.

Why verification breaks even when you do add it

JSON is not canonical

An HMAC is computed over bytes. These two payloads are the same JSON document and completely different byte strings:

{"id":"evt_1","type":"charge.succeeded","amount":1000}
{"amount": 1000, "id": "evt_1", "type": "charge.succeeded"}

The sender signed the first. If express.json() parsed the request into an object and you called JSON.stringify(req.body) to get "the body" back, you produced the second, or something close to it. Key order, whitespace, unicode escaping and number formatting are all free choices for a JSON serializer, and every one of them changes the digest. Stripe's documentation puts it plainly: the body must be the string it sent, in UTF-8, without any changes.

Broken: parse first, re-serialize to verify raw bytes express.json() JSON.stringify(body) digest ≠ Correct: keep the bytes, verify, then parse raw bytes express.raw() HMAC over the buffer digest = Only after the digest matches do you call JSON.parse. Parsing is an action taken on data you have decided to trust, so it belongs after the decision, not before it.
The order of these two operations is the whole article. Verify the bytes, then parse them.

Every provider signs slightly differently

ProviderHeaderAlgorithm and encodingSigned content
StripeStripe-SignatureHMAC-SHA256, hex, in t=...,v1=... formtimestamp + "." + raw body
GitHubX-Hub-Signature-256HMAC-SHA256, hex, prefixed sha256=raw body
ShopifyX-Shopify-Hmac-Sha256HMAC-SHA256, base64raw body
Paystackx-paystack-signatureHMAC-SHA512, hexraw body
M-Pesa Darajanonenonesee below

That last row matters if you work with mobile money in Kenya. Daraja callbacks arrive unsigned, so HMAC verification is not available to you at all. The substitute is an IP allowlist at your reverse proxy plus, critically, never trusting the callback's contents as the source of truth: treat it purely as a nudge, then confirm the transaction independently with a status query before you release goods. If you are wiring that up, the same endpoint also needs to survive the same callback arriving twice.

Timing-safe comparison is not paranoia

A normal string comparison returns as soon as it finds a differing byte. The time it takes therefore leaks how many leading bytes were correct, and over enough requests an attacker can reconstruct a valid signature one byte at a time. Over the public internet this is difficult; on a shared network or from a co-located attacker it is a documented, practical technique. The fix is one function call, so there is no reason to argue about the threat model.

The fix, step by step

Step 1: Get the raw body, on that route only

const express = require('express')
const app = express()

// Mount the raw parser for the webhook path BEFORE the global JSON parser.
app.post(
  '/webhooks/stripe',
  express.raw({ type: 'application/json' }),
  stripeWebhookHandler
)

// Everything else can use JSON as normal.
app.use(express.json())

Order matters. If app.use(express.json()) appears above this route, it has already consumed and parsed the stream and req.body will be an object, not a Buffer. That single line of ordering is responsible for a large share of "verification failed" reports.

Framework equivalents worth knowing: in a Next.js App Router route handler, await request.text() gives you the raw body. In the Pages Router you must disable the built-in parser with export const config = { api: { bodyParser: false } } and read the stream yourself. In FastAPI, await request.body() returns the raw bytes.

Step 2: Verify before anything else happens

const stripe = require('stripe')(process.env.STRIPE_SECRET_KEY)

function stripeWebhookHandler(req, res) {
  const signature = req.headers['stripe-signature']
  let event

  try {
    event = stripe.webhooks.constructEvent(
      req.body,                                  // the Buffer, untouched
      signature,
      process.env.STRIPE_WEBHOOK_SECRET          // this endpoint's secret
    )
  } catch (err) {
    console.warn('webhook signature rejected', err.message)
    return res.status(400).send(`Webhook Error: ${err.message}`)
  }

  // Only now is it safe to look at the contents.
  res.sendStatus(200)
  void handleEvent(event)
}

constructEvent does three things for you: recomputes the HMAC over timestamp + "." + payload, compares it in constant time, and rejects events whose timestamp is outside the default tolerance of 300 seconds. That last part is your replay window, and it is the reason to use the library rather than hand-rolling the HMAC.

Step 3: When there is no library, do it by hand correctly

const crypto = require('crypto')

function verifyGithub(rawBody, headerValue, secret) {
  if (!headerValue) return false

  const expected =
    'sha256=' + crypto.createHmac('sha256', secret).update(rawBody).digest('hex')

  const a = Buffer.from(expected, 'utf8')
  const b = Buffer.from(headerValue, 'utf8')

  // timingSafeEqual throws if the lengths differ, so check first.
  if (a.length !== b.length) return false
  return crypto.timingSafeEqual(a, b)
}

The length check is not optional: crypto.timingSafeEqual throws Input buffers must have the same byte length rather than returning false, and an unhandled throw inside a webhook handler is an unhandled crash. In Python the equivalent is hmac.compare_digest(expected, received), which handles length differences itself.

Step 4: One secret per endpoint, and never a fallback

Providers issue a distinct signing secret per configured endpoint. Your staging endpoint and your production endpoint have different secrets, and Stripe's test mode and live mode differ again. Name the environment variables so a mix-up is obvious, and never write this:

// Never. This is a switch that disables security when a deploy forgets a variable.
if (!process.env.WEBHOOK_SECRET) return next()

Fail closed. If the secret is missing, the process should refuse to start.

Step 5: De-duplicate, then respond fast

A valid signature proves the message came from the provider. It does not prove you have not already processed it. Providers retry on timeouts and non-2xx responses, so the same signed event will legitimately arrive more than once. Record the event ID in a table with a unique constraint and let the database reject duplicates. Then acknowledge within a couple of seconds and do the real work asynchronously, because a slow handler produces retries, and retries produce duplicates.

Verification

# 1. Forge a request against your own endpoint. It must be rejected.
curl -i -X POST https://api.example.com/webhooks/stripe \
  -H 'Content-Type: application/json' \
  -d '{"type":"checkout.session.completed"}'
# expect: HTTP/1.1 400 and nothing written to your database

# 2. A genuine event must be accepted.
stripe listen --forward-to localhost:3000/webhooks/stripe
stripe trigger checkout.session.completed
# expect: 200, and exactly one order updated

# 3. Send the same genuine event twice (use the provider's redeliver button).
# expect: 200 both times, still exactly one order updated

Test one is the security check, test three is the correctness check, and you need both. An endpoint that verifies signatures but double-books a payment is still broken, just in a way your finance team finds rather than an attacker.

What people get wrong

"The URL is a random string, nobody will find it." Webhook URLs end up in browser devtools, screenshots, support tickets, committed Postman collections, error tracking payloads and provider dashboards. And a secret in a URL travels in logs at every hop. If the URL were a credential, it would be the worst-managed credential in your system.

"We are behind Cloudflare, so it is fine." A CDN does not know which POSTs to your API are genuine webhooks. Unless you have explicitly restricted the path to the provider's published IP ranges, and keep that list current, the request reaches your origin exactly as before.

"JSON.stringify(req.body) gets me the body back." It gets you a body back. Dismantled above: key order and whitespace are serializer choices, and the HMAC covers bytes. This is the workaround people reach for when verification keeps failing, and it either fails permanently or, worse, appears to work in testing because your parser happens to round-trip the provider's exact formatting today.

"Verify the signature at the end, after we have the data." Then you have already parsed attacker-controlled input, possibly logged it, possibly looked up a record with an ID from it. Verification is a gate, not an audit. It goes first.

"if (sig === expected) is close enough." One function call separates a constant-time comparison from a leaky one. There is no performance argument, no readability argument, and no reason to have this discussion in code review twice.

When it is still broken

  • Log the byte length of the raw body and compare it to the provider's Content-Length. A mismatch means something in front of your app modified the body. API gateways, some WAF configurations and certain proxy setups will re-encode or normalise a request body.
  • Check the encoding. Passing a Buffer where the library expects a UTF-8 string, or the reverse, changes the digest. Both should represent the same bytes; make sure they actually do.
  • Confirm you are using the endpoint's secret, not the API key. They look similar in a .env file at 1am and they are not interchangeable.
  • Check your reverse proxy is not buffering or rewriting. If you terminate TLS at nginx, confirm the body passes through untouched; this is a good moment to review the rest of your reverse proxy configuration while you are in there.
  • Rotate the secret and try once more. If an old secret was ever committed to a repository, rotating is required anyway, and it eliminates one variable.

The rule I apply to every integration now: an endpoint that mutates state on input from the internet has exactly two acceptable states, authenticated or disabled. A webhook receiver is not an exception to that because the provider is reputable. It is the same endpoint with a friendlier caller most of the time.

Frequently asked questions

Why does Stripe webhook signature verification fail even though my secret is correct?
Almost always because you are verifying against a re-serialized body rather than the raw request bytes. If express.json() or an equivalent parser runs before your handler, req.body is an object, and JSON.stringify on it produces different bytes from what Stripe signed because key order and whitespace are serializer choices. Mount express.raw({ type: 'application/json' }) on the webhook route before the global JSON parser.
What actually happens if I skip webhook signature verification?
Anyone who learns the URL can POST a payload that your handler treats as a genuine provider event, so a single curl command can mark an order paid, trigger fulfilment or fire a deployment. Webhook URLs leak through devtools, screenshots, support tickets and committed API collections, so the URL is not a credential. Verification is the only thing that distinguishes a real event from a forged one.
How do I get the raw request body for webhook verification in Next.js?
In an App Router route handler, call await request.text() to get the raw body as a string before parsing it. In the Pages Router you must disable the built-in parser with export const config = { api: { bodyParser: false } } and read the request stream yourself. In FastAPI the equivalent is await request.body(), which returns the raw bytes.
Does verifying the signature mean I can stop worrying about duplicate webhook events?
No. A valid signature proves the event came from the provider, not that you have not already processed it. Providers retry on timeouts and non-2xx responses, so the same correctly signed event will legitimately arrive more than once. Store the event ID with a unique constraint, let the database reject duplicates, and acknowledge quickly so the provider stops retrying.
#Webhooks#Stripe#HMAC#Node.js#API Security
Keep reading

Related articles