Skip to content
Learni
View all tutorials
Backend

How to Validate Webhook Signatures in 2026

Lire en français

Introduction

Webhooks are HTTP POST notifications sent by third-party services (like Stripe, GitHub, or Twilio) to your server to report real-time events. Without protection, anyone can spoof them, risking malicious data injections or resource abuse.

Signature validation solves this: the sender signs the payload with a shared secret key using HMAC-SHA256, and your server verifies it. This beginner tutorial guides you step by step to implement it in Node.js with Express.

Why it's crucial in 2026? With the explosion of automated APIs and zero-day attacks, a valid signature protects against 99% of spoofing attempts. By the end, you'll have a complete, curl-testable server. Time: 15 minutes.

Prerequisites

  • Node.js 20+ installed
  • Basic JavaScript knowledge (variables, functions)
  • A code editor (VS Code recommended)
  • Terminal to run commands
  • No Express experience required: everything explained

Initialize the Project

terminal
mkdir webhook-signatures
cd webhook-signatures
npm init -y
npm install express
npm install -D nodemon

This command creates a project folder, initializes package.json, installs Express for the HTTP server, and Nodemon for auto-restarts on changes. Run it in your terminal for a ready setup in 30 seconds.

Understanding Basic Webhooks

A webhook is a POST to /webhook with a JSON payload. Without a signature, your server accepts it blindly. Imagine a fake payload triggering a bank transfer: disaster!

The signature arrives in the X-Webhook-Signature header (format: sha256=hex_hash). It's calculated as crypto.createHmac('sha256', secret).update(payload).digest('hex'). If it matches, the webhook is authentic.

Basic Webhook Server

server-basic.js
const express = require('express');
const app = express();
app.use(express.raw({ type: 'application/json' }));

app.post('/webhook', (req, res) => {
  console.log('Payload reçu:', req.body.toString());
  res.status(200).send('OK');
});

app.listen(3000, () => {
  console.log('Serveur sur http://localhost:3000');
});

This server listens for raw JSON POSTs on /webhook, logs the payload, and responds OK. express.raw() is key: it keeps the body as a Buffer for later hashing. Test with curl to see an unsecured webhook work.

Generate a Signature for Testing

To simulate a third-party service, we'll create a function that generates the signature. Use a secret key (store it as an env var in production). This mimics Stripe or GitHub.

Generate Signature Function

generate-signature.js
const crypto = require('crypto');

const secret = 'ma-cle-secrete-super-securisee';
const payload = JSON.stringify({ event: 'user.created', userId: 123 });

const signature = 'sha256=' + crypto.createHmac('sha256', secret).update(payload).digest('hex');

console.log('Payload:', payload);
console.log('Signature:', signature);

This function creates a JSON payload, hashes it with HMAC-SHA256, and prefixes it with 'sha256='. Run it with node generate-signature.js to get a test signature. Copy them for the next curl: this is your simulated 'sender'.

Validate the Signature on the Server Side

The magic: compare the received signature with the recalculated one. Use crypto.timingSafeEqual() to avoid timing attacks (where an attacker measures validation time).

Verify Signature Function

verify-signature.js
const crypto = require('crypto');

function verifySignature(payload, receivedSignature, secret) {
  const expectedSignature = 'sha256=' + crypto.createHmac('sha256', secret).update(payload).digest('hex');
  return crypto.timingSafeEqual(
    Buffer.from(expectedSignature),
    Buffer.from(receivedSignature)
  );
}

// Test
const payload = Buffer.from('{"event":"test"}');
const sig = 'sha256=computed_hash_here';
const secret = 'ma-cle-secrete-super-securisee';
console.log(verifySignature(payload, sig, secret));

This function recalculates the HMAC of the received payload and compares byte-by-byte with timingSafeEqual for security. Replace computed_hash_here with your generated signature. Returns true if valid, false otherwise: tamper-proof guaranteed.

Complete Server with Validation

server.js
const express = require('express');
const crypto = require('crypto');

const app = express();
app.use(express.raw({ type: 'application/json' }));

const WEBHOOK_SECRET = 'ma-cle-secrete-super-securisee';

function verifySignature(payload, receivedSignature) {
  const expected = 'sha256=' + crypto.createHmac('sha256', WEBHOOK_SECRET).update(payload).digest('hex');
  return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(receivedSignature));
}

app.post('/webhook', (req, res) => {
  const signature = req.headers['x-webhook-signature'];
  if (!signature) {
    return res.status(401).send('Signature manquante');
  }
  if (!verifySignature(req.body, signature)) {
    return res.status(401).send('Signature invalide');
  }
  console.log('Webhook valide:', req.body.toString());
  res.status(200).send('OK');
});

app.listen(3000, () => {
  console.log('Serveur sécurisé sur http://localhost:3000');
});

Final server: extracts the signature from the header, verifies with the function, and processes only if valid. Add your secret at the top. Launch with nodemon server.js. Without a valid signature, it returns 401: security active!

Test with Valid Curl

test-valid.sh
curl -X POST http://localhost:3000/webhook \
  -H "X-Webhook-Signature: sha256=5e884898da28047151d0e56f8dc6292773603d0d6aabbdd62a11ef721d1542d8" \
  -H "Content-Type: application/json" \
  -d '{"event":"payment.succeeded","id":123}'

This curl simulates a valid webhook (adapt the signature to your payload/secret via generate-signature.js). Server logs 'valid' and responds OK. Test with a fake signature to see the 401: proof it works.

Best Practices

  • Store the secret in an environment variable: process.env.WEBHOOK_SECRET instead of hardcoding.
  • Log without exposing sensitive payload: use a structured logger like Winston.
  • Limit body size: express.json({ limit: '1mb' }) against DoS.
  • Add a timestamp: Verify X-Webhook-Timestamp to reject old ones (>5min).
  • Use HTTPS only: Enforce with a reverse proxy like Nginx.

Common Errors to Avoid

  • Parsing the body too early: express.json() modifies the Buffer; use express.raw() for HMAC.
  • Naive string comparison: === leaks via timing attacks; always use timingSafeEqual.
  • Forgetting the prefix: Signature must match exactly 'sha256=...'.
  • Weak secret: At least 32 random chars, regenerate periodically.

Next Steps

How to Validate Webhook Signatures in Node.js 2026 | Learni