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
mkdir webhook-signatures
cd webhook-signatures
npm init -y
npm install express
npm install -D nodemonThis 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
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
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
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
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
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_SECRETinstead 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-Timestampto 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; useexpress.raw()for HMAC. - Naive string comparison:
===leaks via timing attacks; always usetimingSafeEqual. - Forgetting the prefix: Signature must match exactly 'sha256=...'.
- Weak secret: At least 32 random chars, regenerate periodically.
Next Steps
- Read the Stripe Webhooks docs for real-world cases.
- Implement multiple signatures (v1,v0) with
crypto.timingSafeEqualin a loop. - Explore tRPC or Hono for more modern webhooks.
- Learni Trainings: Master Node.js security in depth.