Hi, I'm Kaleb. I build sites and automations for small businesses out of Pompano Beach, Florida, through Dream Futures Tech. Recently, I wired my own Netlify-hosted site so its chat widget can reach the server that runs my business automations at home. To route traffic safely without opening up random inbound ports, I used a Cloudflare Tunnel. But exposing an HTTP endpoint—even securely tunneled—means anyone on the internet can spam your server if they guess the URL.
To lock this down, I implemented request signing using HMAC-SHA256 and a strict timestamp check. If you are pushing webhooks from a static host or serverless environment like Netlify to an on-premise or home server, here is how I set it up without overcomplicating it.
The Problem with Unsigned Webhooks
When a Netlify function triggers an external webhook, it is just an HTTP POST request. Without verification, two bad things can happen:
- Spoofing: Anyone who finds your webhook URL can send fake payloads.
- Replay Attacks: An attacker can capture a legitimate payload in transit and replay it a hundred times to mess with your database or trigger duplicate automations.
To fix this, the sender (Netlify) needs to sign the payload using a shared secret key, and the receiver (your home server) needs to verify that signature. Adding a timestamp prevents old requests from being processed.
Signing the Payload on Netlify
Inside my Netlify serverless function (Node.js), I generate a current timestamp, build a message string combining the body and the timestamp, and hash it with a shared secret stored in Netlify's environment variables.
const crypto = require('crypto');
exports.handler = async function(event) {
if (event.httpMethod !== 'POST') {
return { statusCode: 405, body: 'Method Not Allowed' };
}
const secret = process.env.WEBHOOK_SECRET;
const timestamp = Math.floor(Date.now() / 1000);
const body = event.body;
// Construct the payload message to sign
const message = `${timestamp}.${body}`;
const signature = crypto
.createHmac('sha256', secret)
.update(message)
.digest('hex');
try {
const response = await fetch('https://your-home-server.duckdns.org/webhook', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Signature': signature,
'X-Timestamp': timestamp.toString()
},
body: body
});
if (!response.ok) {
throw new Error(`Server responded with ${response.status}`);
}
return { statusCode: 200, body: JSON.stringify({ success: true }) };
} catch (error) {
return { statusCode: 500, body: JSON.stringify({ error: error.message }) };
}
};
I prefer prepending the timestamp directly to the body string inside the hash. That way, an attacker cannot swap out the timestamp header while keeping an old body, because the signature won't match.
Verifying the Signature on the Home Server
On the receiving end—in my case, a small Express service on my home machine—I intercept the request, check the timestamp window, and re-compute the HMAC to compare against the incoming signature.
I set a strict tolerance window of 300 seconds (5 minutes). If the timestamp is older than that, the request gets dropped.
const express = require('express');
const crypto = require('crypto');
const app = express();
// We need raw body to accurately verify the HMAC signature
app.use(express.json({
verify: (req, res, buf) => {
req.rawBody = buf;
}
}));
const WEBHOOK_SECRET = process.env.WEBHOOK_SECRET;
const TOLERANCE_SECONDS = 300;
app.post('/webhook', (req, res) => {
const signature = req.headers['x-signature'];
const timestamp = req.headers['x-timestamp'];
if (!signature || !timestamp) {
return res.status(401).send('Missing security headers');
}
// Check timestamp to prevent replay attacks
const now = Math.floor(Date.now() / 1000);
if (Math.abs(now - parseInt(timestamp, 10)) > TOLERANCE_SECONDS) {
return res.status(401).send('Timestamp outside acceptable window');
}
// Re-create the signature using the raw body
const message = `${timestamp}.${req.rawBody.toString('utf8')}`;
const expectedSignature = crypto
.createHmac('sha256', WEBHOOK_SECRET)
.update(message)
.digest('hex');
// Use timing-safe comparison to prevent timing attacks
const signatureBuffer = Buffer.from(signature, 'hex');
const expectedBuffer = Buffer.from(expectedSignature, 'hex');
if (
signatureBuffer.length !== expectedBuffer.length ||
!crypto.timingSafeEqual(signatureBuffer, expectedBuffer)
)
{
return res.status(403).send('Invalid signature');
}
// Process your webhook payload safely here
console.log('Valid webhook received:', req.body);
res.status(200).send('Received');
});
app.listen(3000, () => console.log('Webhook listener up on port 3000'));
A quick gotcha: make sure you use express.json with a verify function to capture the raw buffer. If Express parses and re-serializes the JSON body, whitespace changes can break the HMAC calculation, resulting in signature mismatches.
Edge Cases and Local Testing
When testing this locally before pushing to production, use netlify dev to run your serverless functions locally, and point your home server or a local script at it. If your home server is behind a dynamic IP, using a tunnel provider eliminates DNS propagation delays during testing.
In my experience, this setup strikes a great balance. It requires zero expensive third-party queue services, keeps your home network hidden behind a secure tunnel, and takes less than an hour to write.
I build sites and automations for small businesses at dreamfuturestech.com.
Top comments (0)