Why Local Service Sites Need Secure Webhooks
When a homeowner in Palm Beach County has an air conditioning failure in the middle of August, they want a fast response. On my own sites, when someone fills out an emergency dispatch or quote request form hosted statically on Netlify, it immediately fires a webhook to the automation server I run out of my office in Pompano Beach. That server handles routing the lead, triggering local alerts, and logging details.
However, exposing an HTTP endpoint on your home network means anyone scanning your IP can try to hammer it with fake submissions. If your webhook receiver blindly accepts incoming JSON payloads, malicious actors can flood your system with spam or spoofed data. To prevent this, every webhook sent from your Netlify site to your home server must be cryptographically signed using HMAC (Hash-based Message Authentication Code) and paired with a strict timestamp.
In my experience building internal routing tools, relying on basic shared secrets sent in query parameters or custom headers is a mistake. If someone sniffs that header once, they can replay requests indefinitely. Adding a timestamp ensures that even if a payload is intercepted, it expires within a few seconds.
Step 1: Signing the Payload on Netlify
When a user submits a form on the static frontend, I route the submission through a Netlify Function. This serverless function acts as the secure bridge between the public internet and my home network.
Inside the Netlify Function, we take the request body, generate a current Unix timestamp, and create an HMAC-SHA256 hash using a shared secret key known only to the Netlify environment variables and our home server.
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 payload = event.body;
// Create a signature using the timestamp and body
const message = `${timestamp}.${payload}`;
const signature = crypto
.createHmac('sha256', secret)
.update(message)
.digest('hex');
try {
// Forward to home server via dynamic DNS or static IP
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: payload
});
if (!response.ok) throw new Error('Forwarding failed');
return { statusCode: 200, body: JSON.stringify({ success: true }) };
} catch (err) {
return { statusCode: 500, body: 'Internal Error' };
}
};
In my judgment, keeping this logic inside a serverless function rather than exposing client-side JavaScript keys is essential. If you sign from the browser, your secret key leaks instantly.
Step 2: Verifying the Signature on the Home Server
On the receiving end—running inside an Express application on my home automation server—we must intercept the raw body, check the timestamp window, and recalculate the HMAC signature to verify authenticity.
It is critical to use the raw request body string for verification. If your Express server parses the JSON body into an object before verification, key ordering differences can break the cryptographic hash match.
const express = require('express');
const crypto = require('crypto');
const app = express();
// Capture raw body for HMAC verification
app.use(express.json({
verify: (req, res, buf) => {
req.rawBody = buf.toString();
}
}));
const SECRET = process.env.WEBHOOK_SECRET;
const ALLOWED_DRIFT = 300; // 5 minutes in seconds
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)) > ALLOWED_DRIFT) {
return res.status(403).send('Timestamp outside acceptable window');
}
// Recreate the HMAC using the raw body
const message = `${timestamp}.${req.rawBody}`;
const expectedSignature = crypto
.createHmac('sha256', SECRET)
.update(message)
.digest('hex');
// Constant-time comparison to prevent timing attacks
const sigBuffer = Buffer.from(signature, 'hex');
const expectedBuffer = Buffer.from(expectedSignature, 'hex');
if (sigBuffer.length !== expectedBuffer.length || !crypto.timingSafeEqual(sigBuffer, expectedBuffer)) {
return res.status(403).send('Invalid signature');
}
// Process the verified payload
const data = JSON.parse(req.rawBody);
console.log('Valid lead received:', data.name);
res.status(200).send('Received');
});
app.listen(3000, () => console.log('Secure webhook listener active.'));
Practical Operational Realities
Running local infrastructure means dealing with occasional network drops or dynamic IP changes. I use a dynamic DNS provider to map my home connection, and having this cryptographic layer ensures that even if an unauthorized script hits my public port, it gets dropped immediately before any internal dispatch scripts execute.
Using crypto.timingSafeEqual is also a judgment call worth noting. Standard string comparisons can leak timing information, allowing attackers to guess signatures character by character. Using buffers prevents this vulnerability entirely.
By combining Netlify's global edge network with a signed webhook pipeline pointing back to home operations, you get the reliability of modern static hosting without sacrificing secure local control.
I build sites and automations for small businesses at dreamfuturestech.com.
Top comments (1)
Good pattern. Two small things that make it hold up: sign the timestamp together with the raw body (timestamp + "." + body), so an attacker can't pair an old body with a fresh time. And compare signatures with crypto.timingSafeEqual instead of ===. A short window still allows a replay inside those seconds, so storing recent signatures for the window closes that last gap.