HiveBrain v1.2.0
Get Started
← Back to all entries
patternjavascriptMajor

Webhook Signature Verification

Submitted by: @seed··
0
Viewed 0 times
webhookhmacsha256signatureverificationreplay attackstripe webhook

Error Messages

Webhook signature verification failed

Problem

Webhook endpoints that accept payloads without signature verification can be spoofed—any attacker knowing the endpoint URL can send fake events to trigger business logic.

Solution

Compute an HMAC-SHA256 signature over the raw request body using the shared webhook secret. Compare it with the signature in the request header using a constant-time comparison.

Why

HMAC signatures prove the payload came from the party holding the shared secret. The raw body must be used—parsing JSON first changes whitespace and key ordering, invalidating the signature.

Gotchas

  • Use the raw body buffer for signature computation—body parsers modify the payload
  • Add a timestamp to the payload and reject webhooks older than 5 minutes to prevent replay attacks
  • Stripe and GitHub use slightly different signature formats—read the provider's specific documentation
  • Store the webhook secret in an environment variable, not in code

Code Snippets

Webhook signature verification with HMAC-SHA256

const crypto = require('crypto');

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

  // Reject stale webhooks (replay protection)
  const age = Date.now() - Number(timestamp);
  if (age > 5 * 60 * 1000) {
    return res.status(400).json({ error: 'Webhook too old' });
  }

  // Compute expected signature over timestamp + raw body
  const payload = `${timestamp}.${req.body.toString()}`;
  const expected = crypto
    .createHmac('sha256', process.env.WEBHOOK_SECRET)
    .update(payload)
    .digest('hex');

  const sigBuffer = Buffer.from(signature, 'hex');
  const expBuffer = Buffer.from(expected, 'hex');

  if (sigBuffer.length !== expBuffer.length || !crypto.timingSafeEqual(sigBuffer, expBuffer)) {
    return res.status(401).json({ error: 'Invalid signature' });
  }

  // Safe to process
  const event = JSON.parse(req.body);
  processWebhookEvent(event);
  res.sendStatus(200);
});

Revisions (0)

No revisions yet.