Engineering·5 min read·2025-03-16·Colm Byrne, Technical Product Manager

Webhook Signature Verification: Why It's Not Optional (And How to Do It Right)

Someone finds your webhook endpoint URL. They POST a fake payment_intent.succeeded event. Your handler provisions Pro access without a payment. Signature verification prevents this — but only if you implement it correctly. The raw body requirement trips up most implementations.

It is a real attack pattern. It does not require sophisticated tooling.

An attacker finds your webhook endpoint URL — maybe from a public GitHub repo, maybe from a JavaScript bundle, maybe from a subdomain enumeration scan. They look up the Stripe webhook payload format for payment_intent.succeeded, which is fully documented in Stripe's public API reference. They craft a POST request with a realistic-looking payload:

{
  "id": "evt_spoofed_abc123",
  "type": "payment_intent.succeeded",
  "data": {
    "object": {
      "id": "pi_spoofed_xyz",
      "amount": 4900,
      "customer": "cus_legitimate_customer_id",
      "metadata": {
        "plan": "pro",
        "user_id": "12345"
      }
    }
  }
}

They POST it to https://your-app.com/webhooks/stripe. If your handler does not verify the signature, it processes the event. The customer gets provisioned for Pro. Nobody paid.

This is not theoretical. Stripe's documentation explicitly calls out this risk: "We strongly recommend verifying webhook signatures, as they provide a way to ensure that any webhook data came from Stripe."

The word "strongly recommend" undersells it. If you are taking any action based on webhook data — provisioning, fulfillment, account upgrades, financial reconciliation — signature verification is not optional. See the HMAC RFC 2104 for the cryptographic specification, and Stripe's signature verification docs for the complete implementation guide. For the broader debugging context, see the webhook debugging checklist.


What Webhook Signatures Are

Most providers use HMAC-SHA256 to sign webhook payloads. The signing process is:

  1. Take the raw request body as bytes
  2. Compute HMAC-SHA256 of those bytes using a shared secret
  3. Include the HMAC in a request header

The receiving application re-computes the HMAC using the same secret and compares it to the header value. If they match, the payload was signed by someone who knows the shared secret — only Stripe (or whoever your provider is) should have that secret.

Stripe adds a timestamp to the signature to prevent replay attacks: even if an attacker captures a legitimate request, they cannot replay it 24 hours later because the timestamp would be stale.

The signature format Stripe sends looks like:

Stripe-Signature: t=1712345678,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e6cce672d803af76b4753d9823

The t is the Unix timestamp. The v1 is the HMAC. Stripe's library (stripe.webhooks.constructEvent) handles parsing and verification.


The Raw Body Requirement

This is where most implementations break. The HMAC is computed over the raw request body — the exact bytes that arrived on the wire. If anything transforms the body before verification, the HMAC will not match. See the webhook debugging checklist Layer 1 for how raw body issues manifest as silent delivery failures.

In Express, express.json() parses the request body into a JavaScript object. Stringifying that object back does not produce the original bytes:

// Original body bytes:
// {"type":"payment_intent.succeeded","id":"evt_abc"}

// After express.json() parses it and you JSON.stringify() it back:
// {"type":"payment_intent.succeeded","id":"evt_abc"}

// These look the same but may differ in:
// - Whitespace and newlines
// - Key ordering (JavaScript objects are not ordered)
// - Unicode normalization
// - Number precision

// HMAC over original bytes: 5257a869e7eceb...
// HMAC over re-stringified: a1b2c3d4e5f678... (completely different)

The fix is to capture the raw body before JSON parsing using express.raw():

// WRONG: JSON parser runs first, raw body is gone
app.use(express.json());
app.post('/webhooks/stripe', async (req, res) => {
  // req.body is a JavaScript object here — can't verify signature
  const event = stripe.webhooks.constructEvent(req.body, sig, secret);
  // This will throw: "Webhook payload must be provided as a string or a Buffer"
  // or if you stringify it, the signature check will fail
});

// CORRECT: raw body for the webhook route, before any JSON parser
app.use('/webhooks/stripe', express.raw({ type: 'application/json' }));

app.post('/webhooks/stripe', (req, res) => {
  // req.body is a Buffer here — exact bytes from the wire
  const sig = req.headers['stripe-signature'];
  let event;
  try {
    event = stripe.webhooks.constructEvent(req.body, sig, process.env.STRIPE_WEBHOOK_SECRET);
  } catch (err) {
    return res.status(400).send(`Webhook error: ${err.message}`);
  }

  // Now req.body is a Buffer, event is the parsed+verified object
  // Use event, not req.body, for all subsequent logic
});

The route-specific middleware placement matters. If you have app.use(express.json()) globally and then add express.raw() for the webhook route, Express will use the first matching middleware — which may be the global JSON parser. Use route-specific middleware and register it before the global JSON parser, or use a dedicated Express router for webhook routes.

If you are using Next.js API routes, the body is parsed automatically. You need to disable body parsing for the webhook route:

// src/app/api/webhooks/stripe/route.ts

export const runtime = 'nodejs';

// Disable Next.js automatic body parsing
export async function POST(req: Request) {
  const body = await req.text(); // Raw string, not parsed JSON
  const sig = req.headers.get('stripe-signature');

  let event;
  try {
    event = stripe.webhooks.constructEvent(body, sig, process.env.STRIPE_WEBHOOK_SECRET!);
  } catch (err: any) {
    return new Response(`Webhook error: ${err.message}`, { status: 400 });
  }

  // Process event...
}

In the Next.js App Router, req.text() gives you the raw body as a string. Stripe's library accepts both Buffer and string.


Complete Stripe Signature Verification

const stripe = require('stripe')(process.env.STRIPE_SECRET_KEY);
const express = require('express');
const app = express();

// Mount raw body middleware BEFORE any global json middleware
app.use('/webhooks/stripe', express.raw({ type: 'application/json' }));

app.post('/webhooks/stripe', (req, res) => {
  const sig = req.headers['stripe-signature'];

  if (!sig) {
    return res.status(400).send('Missing stripe-signature header');
  }

  let event;
  try {
    event = stripe.webhooks.constructEvent(
      req.body,                              // Buffer — raw bytes
      sig,                                   // The Stripe-Signature header value
      process.env.STRIPE_WEBHOOK_SECRET      // Signing secret from Stripe dashboard
    );
  } catch (err) {
    // Do NOT return 200 here. Return 400.
    // If you return 200, Stripe marks the event delivered and won't retry.
    // Return 400 so Stripe retries and you have a chance to fix the issue.
    console.error('Stripe signature verification failed:', err.message);
    return res.status(400).send(`Webhook error: ${err.message}`);
  }

  // Signature verified — event.type and event.data are trustworthy
  switch (event.type) {
    case 'payment_intent.succeeded':
      // Safe to use event.data.object — it came from Stripe
      handlePaymentSucceeded(event.data.object);
      break;
    case 'customer.subscription.deleted':
      handleSubscriptionDeleted(event.data.object);
      break;
    default:
      console.log(`Unhandled event type: ${event.type}`);
  }

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

What stripe.webhooks.constructEvent() actually checks:

  1. Parses the Stripe-Signature header to extract the timestamp and signature
  2. Verifies the timestamp is within 300 seconds of now (prevents replay attacks)
  3. Recomputes the expected HMAC: HMAC-SHA256(timestamp + '.' + payload, secret)
  4. Compares the recomputed HMAC to the signature in the header using a timing-safe comparison

The timing-safe comparison matters: a naive string comparison leaks information about how many bytes match through timing side channels. Stripe's library uses crypto.timingSafeEqual() internally.


Complete Twilio Signature Verification

Twilio signatures work differently from Stripe. The HMAC is computed over the full URL plus POST parameters, not just the body.

const twilio = require('twilio');

app.post('/webhooks/twilio/sms', express.urlencoded({ extended: false }), (req, res) => {
  const twilioSignature = req.headers['x-twilio-signature'];

  // The URL must be EXACT — including https vs http, port, path, query string
  // If Twilio sends to https://your-app.com/webhooks/twilio/sms
  // and you pass https://your-app.com/webhooks/twilio/sms/ (trailing slash)
  // the signature check WILL FAIL
  const url = `${process.env.APP_URL}/webhooks/twilio/sms`;

  // req.body is the parsed form body (URL-encoded params from Twilio)
  const isValid = twilio.validateRequest(
    process.env.TWILIO_AUTH_TOKEN,
    twilioSignature,
    url,
    req.body  // The POST parameters as a plain object
  );

  if (!isValid) {
    console.error('Twilio signature validation failed');
    return res.status(403).send('Forbidden');
  }

  // Signature verified
  const message = req.body;
  console.log(`Received SMS from ${message.From}: ${message.Body}`);

  // Respond with TwiML
  res.set('Content-Type', 'text/xml');
  res.send('<Response><Message>Got it</Message></Response>');
});

Twilio-specific gotchas:

The URL must exactly match the URL Twilio has configured, including query parameters. If your webhook URL has ?source=twilio, include that in the URL you pass to validateRequest(). If you are behind a reverse proxy that strips HTTPS and your application sees the request as HTTP, the URL reconstruction will be wrong.

A common fix is to force HTTPS in the URL reconstruction regardless of what the incoming request header says:

const url = `https://${req.hostname}${req.path}`;
// Do NOT use req.protocol — it may be 'http' if behind a proxy

Complete GitHub Webhook Signature Verification

GitHub uses X-Hub-Signature-256 with HMAC-SHA256 of the raw body:

const crypto = require('crypto');

app.post('/webhooks/github', express.raw({ type: 'application/json' }), (req, res) => {
  const signature = req.headers['x-hub-signature-256'];

  if (!signature) {
    return res.status(400).send('Missing X-Hub-Signature-256 header');
  }

  const expectedSignature = 'sha256=' + crypto
    .createHmac('sha256', process.env.GITHUB_WEBHOOK_SECRET)
    .update(req.body) // req.body is a Buffer from express.raw()
    .digest('hex');

  // Use timingSafeEqual to prevent timing attacks
  const signatureBuffer = Buffer.from(signature);
  const expectedBuffer = Buffer.from(expectedSignature);

  if (signatureBuffer.length !== expectedBuffer.length ||
      !crypto.timingSafeEqual(signatureBuffer, expectedBuffer)) {
    return res.status(401).send('Invalid signature');
  }

  const event = JSON.parse(req.body.toString('utf8'));
  const eventType = req.headers['x-github-event'];

  // Safe to process
  console.log(`GitHub event: ${eventType}`, event);
  res.status(200).send('ok');
});

GitHub also sends X-Hub-Signature (SHA-1) for legacy compatibility, but you should use X-Hub-Signature-256 (SHA-256). SHA-1 is no longer considered secure for HMAC.


Rotating Signing Secrets Without Downtime

Signing secrets need to be rotated periodically — either on a schedule or after a potential exposure. If you swap the secret immediately, any in-flight webhook requests signed with the old secret will fail verification — causing a delivery gap. The grace-period rotation pattern described here is the same approach used by HookTunnel for outcome receipt secrets. For the full webhook inspection and monitoring context, see HookTunnel's webhook inspection features.

The standard pattern is a grace period: accept both the old and new secret for a window (typically 24 hours), then stop accepting the old secret.

async function verifyStripeSignatureWithRotation(body, sig) {
  const currentSecret = process.env.STRIPE_WEBHOOK_SECRET;
  const previousSecret = process.env.STRIPE_WEBHOOK_SECRET_PREV; // Set during rotation

  // Try current secret first
  try {
    return stripe.webhooks.constructEvent(body, sig, currentSecret);
  } catch (err) {
    // If current secret fails and we have a previous secret, try that
    if (previousSecret) {
      try {
        const event = stripe.webhooks.constructEvent(body, sig, previousSecret);
        // Log that we used the old secret — this tells you when rotation is complete
        logger.warn({ msg: 'Verified with previous signing secret — rotation in progress' });
        return event;
      } catch (prevErr) {
        // Both secrets failed — genuine verification failure
        throw new Error(`Signature verification failed with both current and previous secrets`);
      }
    }
    throw err;
  }
}

The rotation procedure:

  1. Generate a new signing secret in the provider's dashboard
  2. Set STRIPE_WEBHOOK_SECRET_PREV to the current value of STRIPE_WEBHOOK_SECRET
  3. Set STRIPE_WEBHOOK_SECRET to the new value
  4. Deploy
  5. Wait 24 hours (all in-flight requests signed with the old secret will have expired)
  6. Clear STRIPE_WEBHOOK_SECRET_PREV
  7. Deploy

HookTunnel uses this same pattern for outcome receipt signing secrets: when you rotate your receipt secret, both the current and previous secret are valid for 24 hours. The receipt_signing_secret_prev and rotated_at columns in the receipt configuration track the rotation state.


Common Implementation Mistakes

The most dangerous mistake is returning 200 on a verification failure — it silently breaks your security model without any observable signal. For the incident response when this is discovered in production, see the webhook incident runbook.

| Mistake | Consequence | |---------|-------------| | Catching signature error and returning 200 | Stripe marks event delivered, processing never happens, no retry | | Using req.body after express.json() for HMAC | Signature check always fails, handler rejects all events | | Hardcoding the webhook secret in source code | Secret exposed in git history, repository scans | | Skipping verification "just for testing" and forgetting to re-enable | Production runs without verification | | Using string comparison instead of timingSafeEqual | Timing side channel leaks information about the secret | | Building your own HMAC verification instead of using the provider's library | Edge cases around encoding, timing, key normalization | | Wrong URL in Twilio validation (http vs https, trailing slash) | All Twilio events rejected |

The most dangerous mistake is returning 200 on a verification failure. It silently breaks your security model and you will not notice until an attacker exploits it — or until you notice that events are never being processed despite healthy-looking delivery logs.

Return 400. Always. If your raw body middleware is wrong, the 400 response will trigger retries and alert you that something is misconfigured. A misconfigured webhook that retries is recoverable. A misconfigured webhook that silently accepts spoofed events is not.


Verifying Your Implementation

Once you have signature verification in place, test it with a deliberately bad signature:

# Send a request with an incorrect signature — should return 400
curl -X POST https://your-app.com/webhooks/stripe \
  -H "Content-Type: application/json" \
  -H "Stripe-Signature: t=1712345678,v1=badsignature" \
  -d '{"type":"payment_intent.succeeded","id":"evt_test"}'

# Expected: 400 Webhook error: No signatures found matching the expected signature for payload...

Also test the missing signature case:

# Send a request with no signature header — should return 400
curl -X POST https://your-app.com/webhooks/stripe \
  -H "Content-Type: application/json" \
  -d '{"type":"payment_intent.succeeded","id":"evt_test"}'

# Expected: 400 Missing stripe-signature header (or similar)

If either of these returns 200, your signature verification is not running correctly.

HookTunnel captures the signature status inline in the event detail view, so you can see for each incoming event whether the signature was present and valid. For events that arrive without a valid signature (or with no signature at all), HookTunnel flags them separately from events that passed verification — giving you an immediate view of whether anyone is attempting to spoof your endpoints.

Stop guessing. Start proving.

Generate a webhook URL in one click. No signup required.

Get started free →