javascript 104 lines · 2 tabs

Verify Stripe-Style Webhook Signatures With HMAC in Express Before Processing

Shared by codesnips Jul 2026
2 tabs
const crypto = require('crypto');

function computeSignature(secret, timestamp, payload) {
  return crypto
    .createHmac('sha256', secret)
    .update(`${timestamp}.${payload}`, 'utf8')
    .digest('hex');
}

function constantTimeEqual(a, b) {
  const bufA = Buffer.from(a, 'utf8');
  const bufB = Buffer.from(b, 'utf8');
  if (bufA.length !== bufB.length) return false;
  return crypto.timingSafeEqual(bufA, bufB);
}

function parseSignatureHeader(header) {
  const parts = {};
  for (const item of String(header).split(',')) {
    const [key, value] = item.split('=');
    if (key && value) parts[key.trim()] = value.trim();
  }
  return { timestamp: parts.t, signature: parts.v1 };
}

function verifyWebhook(secret, toleranceSeconds = 300) {
  return function (req, res, next) {
    const { timestamp, signature } = parseSignatureHeader(req.get('Stripe-Signature'));
    if (!timestamp || !signature) {
      return res.status(400).json({ error: 'missing signature header' });
    }

    const age = Math.floor(Date.now() / 1000) - Number(timestamp);
    if (!Number.isFinite(age) || age > toleranceSeconds) {
      return res.status(400).json({ error: 'timestamp outside tolerance' });
    }

    const payload = req.body.toString('utf8');
    const expected = computeSignature(secret, timestamp, payload);
    if (!constantTimeEqual(expected, signature)) {
      return res.status(400).json({ error: 'signature mismatch' });
    }

    try {
      req.event = JSON.parse(payload);
    } catch (err) {
      return res.status(400).json({ error: 'invalid json body' });
    }
    next();
  };
}

module.exports = { verifyWebhook, computeSignature, constantTimeEqual };
2 files · javascript Explain with highlit

Webhooks are inbound HTTP requests from a third party, which means anyone who learns the endpoint URL could forge a payload. The standard defense is a shared secret plus an HMAC signature: the sender computes HMAC-SHA256(secret, timestamp + '.' + body) and puts it in a header, and the receiver recomputes the same value and compares. This snippet shows that verification wired into an Express app the way Stripe and GitHub actually do it.

The key subtlety appears in verifyWebhook middleware: the raw request bytes must be hashed, not a re-serialized object. Once express.json() parses and re-stringifies the body, key ordering and whitespace change and the signature no longer matches. That is why webhookServer mounts express.raw({ type: 'application/json' }) on the webhook route so req.body stays a Buffer. The middleware parses the header into a timestamp and signature, calls computeSignature to rebuild the expected digest over ${timestamp}.${payload}, and only then parses JSON.

Comparison is done with crypto.timingSafeEqual, not ===. A naive string compare returns early on the first mismatching byte, leaking timing information that can let an attacker recover a valid signature byte by byte. timingSafeEqual always compares the full length, and the code guards against a length mismatch first since the function throws on unequal buffers.

The timestamp check in verifyWebhook middleware rejects anything older than toleranceSeconds (five minutes) to blunt replay attacks, where a captured-but-valid request is resent later. Signature verification alone cannot stop replays, so the timestamp is folded into the signed payload and also range-checked.

Finally webhookServer demonstrates the practical follow-through: verification is necessary but not sufficient. Networks retry, so the same event can arrive twice; the handler treats event.id as a dedupe key via alreadyProcessed before doing real work, making processing idempotent. constantTimeEqual returns false rather than throwing on bad input, keeping the middleware defensive against malformed headers. The trade-off is that raw-body handling only applies to the webhook path, while normal JSON parsing still works everywhere else.


Related snips

Share this code

Here's the card — post it anywhere.

Verify Stripe-Style Webhook Signatures With HMAC in Express Before Processing — share card
Link copied