Exposing a public HTTP endpoint for webhooks is basically leaving your front door unlocked. Anyone on the internet can POST fake data to it, spoof a successful payment, and mess up your database. To fix this, we need to verify incoming webhook signatures using HMAC. We’ll build a secure Node.js Express server to handle this using nothing but Node’s native crypto module.

The Problem with Unsecured Webhooks
When you set up a webhook receiver, you’re opening a public gateway straight into your backend. If Stripe or Shopify sends a POST request, your app processes it. But if an attacker guesses that URL? They can send a crafted JSON payload pretending to be a $1,000 payment. Whitelisting IP addresses sounds like an easy fix, but it’s a maintenance nightmare. Webhook providers change their IP ranges all the time, and managing firewalls for every integration gets old fast. That’s why major platforms use Hash-based Message Authentication Codes (HMAC) to sign their payloads. The provider signs the request body with a shared secret. When it hits your server, you recalculate the hash. If your hash matches their signature header, you’re good. If not, kick them out.
How Webhook Signatures Work
The verification process relies on a shared secret key known only to you and the webhook sender. Here is how the handshake works in the wild:
- The sender builds the payload (usually a JSON string).
- They hash this payload using the shared secret and an algorithm like SHA-256.
- They slap this hash into an HTTP header (like
x-signatureorstripe-signature). - Your server gets the request, grabs the raw body, and hashes it using your copy of the secret.
- You compare your hash with theirs. If they match, you process the request.
To see how this fits into a real-world integration, check out how to build a secure GoHighLevel webhook listener, which uses these exact principles to protect incoming data.
Setting Up the Express Project
Let’s spin up a basic Express app. We don’t need any heavy external crypto libraries—Node’s built-in crypto module handles everything we need. Here’s what I ran in my terminal to get started:
mkdir webhook-verifier
cd webhook-verifier
npm init -y
npm install express dotenv
Next, create a .env file in your root directory to store the signing secret. We’ll use a placeholder string for now:
PORT=3000
WEBHOOK_SECRET=my_super_secret_signing_key
Why Express bodyParser Breaks Signature Verification
This is the exact gotcha that trips up almost every developer writing their first webhook receiver. Express defaults to parsing JSON payloads using express.json(). This turns the raw incoming stream into a neat JavaScript object. If you try to run an HMAC check on JSON.stringify(req.body), your verification will fail. Why? Because JSON.stringify() doesn't guarantee key order, spacing, or line breaks. A single missing space changes the SHA-256 hash completely. You must verify the signature against the exact, byte-for-byte raw request body that came over the wire. Fortunately, Express body-parser options let us capture the raw body buffer before it gets parsed. We can intercept the stream and attach it to the request object. Here's how I set up my Express app to preserve that raw buffer:
const express = require('express');
const app = express(); // Capture the raw body buffer for signature verification
app.use(express.json({ verify: (req, res, buf) => { req.rawBody = buf; }
}));
With this setup, we still get the convenient req.body for our business logic, but we also keep req.rawBody as a Buffer for our crypto checks.
Writing the HMAC Verification Middleware
Now let's build the verification middleware. We'll grab the signature header, generate our own hash using the native Node.js crypto API, and compare them. But there's a catch: never use standard string comparison operators like === here. Standard comparisons return false the millisecond they find a mismatched character. This leaves you wide open to timing attacks on MDN, where an attacker guesses your signature character-by-character by measuring how many milliseconds your server takes to reject the request. To prevent this, we use crypto.timingSafeEqual(). It compares buffers in constant time, meaning it takes the exact same amount of time regardless of where a mismatch occurs. Create a server.js file and add this code:
require('dotenv').config();
const express = require('express');
const crypto = require('crypto'); const app = express();
const PORT = process.env.PORT || 3000;
const WEBHOOK_SECRET = process.env.WEBHOOK_SECRET; if (!WEBHOOK_SECRET) { console.error('Error: WEBHOOK_SECRET is not defined in environment variables.'); process.exit(1);
} // Capture raw body buffer
app.use(express.json({ verify: (req, res, buf) => { req.rawBody = buf; }
})); // Middleware to verify HMAC signatures
function verifyWebhookSignature(req, res, next) { const signatureHeader = req.headers['x-signature']; if (!signatureHeader) { return res.status(401).json({ error: 'Missing x-signature header' }); } if (!req.rawBody) { return res.status(400).json({ error: 'Missing raw request body' }); } try { // Create HMAC using SHA-256 and our shared secret const hmac = crypto.createHmac('sha256', WEBHOOK_SECRET); hmac.update(req.rawBody); const calculatedSignature = hmac.digest('hex'); // Convert both signatures to buffers for timingSafeEqual const trustedBuffer = Buffer.from(calculatedSignature, 'hex'); const receivedBuffer = Buffer.from(signatureHeader, 'hex'); // Check length first to prevent buffer mismatch errors if (trustedBuffer.length !== receivedBuffer.length) { return res.status(401).json({ error: 'Invalid signature' }); } // Use timing-safe comparison to prevent timing attacks const isValid = crypto.timingSafeEqual(trustedBuffer, receivedBuffer); if (!isValid) { return res.status(401).json({ error: 'Invalid signature' }); } next(); } catch (err) { console.error('Signature verification failed:', err.message); return res.status(500).json({ error: 'Internal verification error' }); }
} // Protected webhook route
app.post('/webhook', verifyWebhookSignature, (req, res) => { console.log('Verified Webhook Received:', req.body); res.status(200).json({ received: true });
}); app.listen(PORT, () => { console.log(`Server running on port ${PORT}`);
});
Testing with a Mock Webhook Sender
To test this, we can write a quick script that acts as the webhook provider. It will sign a payload with our secret and POST it to our local server. (If you want to test this with actual external services, you can test webhooks locally using Cloudflare Tunnels to expose your port safely). Create a file named test-sender.js:
const crypto = require('crypto'); const WEBHOOK_SECRET = 'my_super_secret_signing_key';
const url = 'http://localhost:3000/webhook'; const payload = JSON.stringify({ event: 'order.completed', data: { orderId: 'order_987654321', amount: 99.99, currency: 'USD' }
}); // Generate signature using the same secret and algorithm
const hmac = crypto.createHmac('sha256', WEBHOOK_SECRET);
hmac.update(payload);
const signature = hmac.digest('hex'); async function sendWebhook(sendValidSignature = true) { const headers = { 'Content-Type': 'application/json', 'x-signature': sendValidSignature ? signature : 'fake_signature_to_test_security' }; try { const response = await fetch(url, { method: 'POST', headers: headers, body: payload }); const data = await response.json(); console.log(`Status: ${response.status}`, data); } catch (error) { console.error('Error sending webhook:', error.message); }
} (async () => { console.log('Testing with valid signature...'); await sendWebhook(true); console.log('nTesting with invalid signature...'); await sendWebhook(false);
})();
Run your server in one terminal window:
node server.js
Then run the test script in another:
node test-sender.js
Your server terminal should log the verified webhook content for the first request, and reject the second one with a 401 Unauthorized status.
Handling Replay Attacks with Timestamps
HMAC ensures the payload wasn’t tampered with, but it doesn’t stop replay attacks. If an attacker intercepts a valid signed request, they can just resend the exact same payload and signature, and your server will happily accept it again. To stop this, production APIs like Stripe send a timestamp in the signature header. The header looks something like this:
t=1672531199,v1=6a8b9c...
To handle this, you parse the timestamp (t) and the signature (v1). You calculate the signature using the raw payload concatenated with the timestamp string, and then verify that the difference between your server’s current time and the header’s timestamp is within an acceptable window (usually 5 minutes). Here is how you can update your middleware to include a timestamp check:
function verifyWebhookWithTimestamp(req, res, next) { const header = req.headers['x-signature']; // Format: t=1672531199,v1=hash if (!header) return res.status(401).json({ error: 'Missing signature' }); const parts = header.split(','); const timestampPart = parts.find(p => p.startsWith('t=')); const signaturePart = parts.find(p => p.startsWith('v1=')); if (!timestampPart || !signaturePart) { return res.status(401).json({ error: 'Invalid header format' }); } const timestamp = parseInt(timestampPart.split('=')[1], 10); const signature = signaturePart.split('=')[1]; // Check if request is older than 5 minutes (300 seconds) const now = Math.floor(Date.now() / 1000); if (Math.abs(now - timestamp) > 300) { return res.status(401).json({ error: 'Request expired' }); } // Recreate the signature using payload and timestamp const hmac = crypto.createHmac('sha256', WEBHOOK_SECRET); hmac.update(`${timestamp}.${req.rawBody}`); const calculatedSignature = hmac.digest('hex'); const trustedBuffer = Buffer.from(calculatedSignature, 'hex'); const receivedBuffer = Buffer.from(signature, 'hex'); if (trustedBuffer.length !== receivedBuffer.length) { return res.status(401).json({ error: 'Invalid signature' }); } if (!crypto.timingSafeEqual(trustedBuffer, receivedBuffer)) { return res.status(401).json({ error: 'Invalid signature' }); } next();
}
This pattern is straight out of Stripe’s webhook signature docs, which is pretty much the gold standard for secure webhook design.
Frequently Asked Questions
Can I verify signatures using third-party SDKs?
Yes, and you probably should if they’re available. If you’re using Stripe, Shopify, or Clerk, their official SDKs handle this under the hood. For example, Stripe’s SDK gives you stripe.webhooks.constructEvent(), which handles the raw body parsing, timestamp extraction, and timing-safe comparisons for you.
What should I do if the signature verification fails?
Fail fast and return a 401 Unauthorized or 403 Forbidden status code immediately. Do not run any database queries or trigger background jobs. Log the failure internally so you can debug it, but don’t return detailed error messages to the client—you don’t want to help an attacker debug their exploits.
Why does my local verification pass but fail in production?
This is almost always caused by reverse proxies, API gateways, or serverless platforms (like AWS Lambda, Vercel, or Heroku) modifying the raw request body before it hits your Express app. If your hosting environment strips headers, alters line endings, or auto-parses JSON, the raw buffer won’t match, and verification will fail.
Next Steps
Now that your endpoints are secure, you’ll want to make sure your app handles bad payloads gracefully. Check out how to validate webhook payloads with Zod to ensure incoming data matches your schema before you touch the database. If you’re building a system that processes a high volume of events, you’ll also want to set up a robust webhook retry system to handle temporary failures without losing critical transaction data.

