signature_verification_failed

Webhook Signature Verification Failing on Node.js Server

Server & Cloud Intermediate 👁 6 views 📅 Jun 29, 2026

Webhook signatures don't match between Stripe and your server. Common fix: check timestamp tolerance and raw body format.

You set up webhooks, you copy the secret, you test with Stripe CLI — and bam, signature verification fails. I know the frustration. You triple-check the secret key, still nothing. Let me save you the headache.

The Real Fix: Timestamp Tolerance and Raw Body

After you read this, the first thing you do is check timestamp tolerance. Stripe uses timestamps in the signature header to prevent replay attacks. Your server might be rejecting signatures because the clock is off by more than five minutes. Or your middleware is eating the raw request body before Stripe's verification code can see it.

Here's a complete fix for a Node.js Express server. This assumes you use stripe.webhooks.constructEvent():

const express = require('express');
const stripe = require('stripe')('your_secret_key');

const app = express();

// First, get the raw body BEFORE any JSON parsing
// This is critical for Stripe webhooks
app.post('/webhook', express.raw({type: 'application/json'}), (req, res) => {
  const sig = req.headers['stripe-signature'];
  let event;

  try {
    event = stripe.webhooks.constructEvent(
      req.body, // this is a Buffer now, not parsed JSON
      sig,
      'whsec_your_webhook_secret'
    );
  } catch (err) {
    console.log('Signature verification failed:', err.message);
    return res.status(400).send('Signature verification failed');
  }

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

After you set this up, test with Stripe CLI: stripe trigger payment_intent.succeeded. If you see a 200 response, it works. If you still get 400, check the next section.

Why This Works

Stripe signs each webhook payload with HMAC-SHA256. The signature includes the timestamp, the raw JSON body, and your secret key. If your Express app parses the JSON body with express.json() first, the raw body is gone. Stripe's library needs the original bytes to compute the hash. Without them, the signature never matches.

The timestamp check is also key. Stripe's default tolerance is five minutes. If your server's clock is off by six minutes, the signature fails. If your server uses NTP, that's rare but still possible in some cloud environments.

Less Common Variations of the Issue

1. Multiple Middleware Contradict

If your app has both express.json() and express.raw() on the same route, they'll fight. Each middleware can only parse the body once. My advice: move the webhook route to a separate router that only uses express.raw(). The rest of your API stays on JSON.

const webhookRouter = express.Router();
webhookRouter.use(express.raw({type: 'application/json'}));
webhookRouter.post('/webhook', (req, res) => { ... });

app.use(webhookRouter);
// app.use(express.json()) only applies to other routes

2. Clock Drift in Docker Containers

If your Node.js server runs in an old Docker container, the clock might not sync correctly. I've seen this on Alpine-based images. Run date inside the container and compare to Stripe's timestamp. If off by more than a minute, add apt-get install -y ntpdate (or apk for Alpine) and run ntpdate -s time.google.com at startup.

3. Stripe CLI Local Testing

When you test with Stripe CLI's stripe listen --forward-to localhost:3000/webhook, the CLI creates a forwarded signature. That signature is based on the CLI's own secret, not your webhook secret. So you can't use constructEvent during local testing without the correct secret. Solution: grab the secret from the CLI output (whsec_...) and use that in your test code.

// For local CLI test only
const endpointSecret = 'whsec_cli_secret_here';
event = stripe.webhooks.constructEvent(req.body, sig, endpointSecret);

4. Stripe Webhook Secret Rotation

If you recently rotated your webhook secret in the Stripe dashboard, you might have an old one cached in your environment variable. Check process.env.STRIPE_WEBHOOK_SECRET directly. I always log the first few characters to make sure it matches the dashboard.

How to Prevent This from Happening Again

The real fix is to build your webhook handler first, before any other middleware, on its own route. Then every new developer who touches the code doesn't accidentally break it.

Here's my checklist for every webhook setup:

  • Use express.raw() only on the webhook route — never express.json() before it.
  • Set NTP sync on your server — run timedatectl on Linux to verify.
  • Test with Stripe CLI using the CLI's own secret, then switch to the live secret.
  • Log the tail of the signature header when debugging — console.log(sig.slice(-20)) to see if it's malformed.
  • Check Stripe's stripe-signature header for the t= value. Compare with your server's current Unix timestamp. If difference > 300 seconds, your clock is wrong.

I've watched teams spend two days on this. Don't be that team. Fix the raw body, check the timestamp, and test with the right secret. You'll have webhooks working in ten minutes.

Was this solution helpful?