Why I Signed My Netlify Webhooks
When I moved my personal site to Netlify I also wanted the same deployment to trigger a few home‑automation scripts that run on a Raspberry Pi in my garage. A simple HTTP POST would work, but anyone who discovers the endpoint could fire my scripts at will. The solution I settled on is a classic HMAC signature plus a short‑lived timestamp. It’s cheap, stateless, and works well with the serverless functions Netlify provides.
The Core Idea
- Shared secret – a random string known only to the Netlify site and my home server.
- HMAC‑SHA256 – the payload (or a deterministic string) is signed with the secret.
- Timestamp – added to the request and validated to prevent replay attacks.
When the request arrives, the server recomputes the HMAC and compares it to the header. If they match and the timestamp is within an acceptable window (I use 5 minutes) the request is accepted.
Generating a Secure Secret
I keep the secret in Netlify’s UI under Site Settings → Build & Deploy → Environment. The same value lives in a .env file on my home server.
# Generate a 32‑byte base64 secret
openssl rand -base64 32
Copy the output (e.g. k3J9vZc2Yb7R9uW8e1Lh5QpX0sTgZ1aM) to both places. Never commit it to source control.
Netlify Function: Sending the Signed Webhook
Netlify lets you write a small function that runs after a successful build. I placed this in netlify/functions/trigger‑automation.js.
// netlify/functions/trigger-automation.js
const crypto = require('crypto');
exports.handler = async (event, context) => {
const secret = process.env.HOOK_SECRET; // set in Netlify UI
const payload = JSON.stringify({
site: context.site.name,
version: context.deploy.id,
timestamp: Date.now()
});
// Create HMAC signature
const hmac = crypto.createHmac('sha256', secret)
.update(payload)
.digest('hex');
const response = await fetch('https://my‑home‑server.local/webhook', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Signature': hmac,
'X-Timestamp': Date.now().toString()
},
body: payload
});
if (!response.ok) {
console.error('Webhook failed', await response.text());
return { statusCode: 500, body: 'Webhook error' };
}
return { statusCode: 200, body: 'Webhook sent' };
};
A few notes:
- I include the timestamp both in the JSON body and as a header. The header is what the server checks first because it’s cheap to read.
- Netlify automatically bundles the function with the rest of the site, so no extra deployment step is needed.
Home Server: Verifying the Request
On the Raspberry Pi I run a tiny Express app that listens for /webhook. The verification logic is deliberately simple.
// server/webhook.js
require('dotenv').config();
const express = require('express');
const crypto = require('crypto');
const app = express();
app.use(express.json());
const SECRET = process.env.HOOK_SECRET; // same value as Netlify
const MAX_AGE_MS = 5 * 60 * 1000; // 5 minutes
function verifySignature(req) {
const signature = req.header('X-Signature');
const timestamp = req.header('X-Timestamp');
if (!signature || !timestamp) return false;
const now = Date.now();
const ts = Number(timestamp);
if (isNaN(ts) || Math.abs(now - ts) > MAX_AGE_MS) return false; // replay protection
const payload = JSON.stringify(req.body);
const expected = crypto.createHmac('sha256', SECRET)
.update(payload)
.digest('hex');
// Use timing‑safe compare to avoid side‑channel attacks
return crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected));
}
app.post('/webhook', (req, res) => {
if (!verifySignature(req)) {
return res.status(401).send('Invalid signature or timestamp');
}
// At this point the request is trusted – run my automation
console.log(' Verified webhook from Netlify', req.body);
// Example: restart a Docker container, pull a repo, etc.
// execSync('docker restart my‑service');
res.send('OK');
});
const PORT = process.env.PORT || 3000;
app.listen(PORT, () => console.log(`Webhook listener on ${PORT}`));
Why the Checks Matter
- Timestamp window – Without it an attacker could capture a valid request and replay it later. Five minutes is a balance between network latency and security for my use case.
-
Timing‑safe compare –
crypto.timingSafeEqualprevents subtle timing attacks that could leak the secret. - JSON stringification – Both sides must sign the exact same string. I deliberately stringify the body after Express parses it, ensuring whitespace differences don’t break the signature.
Deploying the Home Server
I keep the server code in a private Git repo on the Pi. A simple systemd service starts the Node process on boot:
# /etc/systemd/system/webhook.service
[Unit]
Description=Netlify webhook listener
After=network.target
[Service]
EnvironmentFile=/home/pi/.env
WorkingDirectory=/home/pi/webhook
ExecStart=/usr/bin/node webhook.js
Restart=on-failure
[Install]
WantedBy=multi-user.target
After sudo systemctl daemon-reload && sudo systemctl enable --now webhook, the endpoint is reachable at https://my‑home‑server.local/webhook (I terminate TLS with a self‑signed cert and let Netlify ignore the verification because the request is outbound).
Testing the Flow
I wrote a tiny script to simulate a request and verify that the server rejects malformed signatures.
node - <<'EOF'
const fetch = require('node-fetch');
const crypto = require('crypto');
const secret = 'k3J9vZc2Yb7R9uW8e1Lh5QpX0sTgZ1aM';
const payload = JSON.stringify({test: true, timestamp: Date.now()});
const sig = crypto.createHmac('sha256', secret).update(payload).digest('hex');
fetch('https://my-home-server.local/webhook', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Signature': sig,
'X-Timestamp': Date.now().toString()
},
body: payload,
rejectUnauthorized: false // because of self‑signed cert
}).then(r => r.text().then(t => console.log(r.status, t)));
EOF
If the server logs Verified webhook from Netlify and returns 200 OK, the pipeline is good.
Things I Learned Along the Way
- Never rely on IP filtering alone – My home network’s public IP changes occasionally; HMAC is far more reliable.
-
Keep the secret truly secret – I store it in Netlify’s encrypted environment and on the Pi’s
.env. Accidentally committing it would break the whole model. - Replay windows are a trade‑off – Shorter windows increase security but can cause false negatives on very slow connections. Five minutes works for my broadband.
- Self‑signed TLS is fine for outbound calls – Netlify’s function doesn’t validate the cert, but the server still encrypts the payload on the wire.
Wrapping Up
Signing webhooks with HMAC and a timestamp gave me a lightweight, auditable way to let my Netlify site trigger home‑automation tasks without exposing an open endpoint. The code is only a few dozen lines, runs on any Node‑compatible host, and can be adapted to other platforms (Python, Go, etc.) with the same principles.
I build sites and automations for small businesses at dreamfuturestech.com.
Top comments (1)
The timestamp window is doing as much work as the HMAC here — a valid signature on a captured request would otherwise be replayable forever, and five minutes is a sensible trade for a home server whose clock can drift. One habit worth adding is logging rejected attempts with the reason separated out (bad signature versus stale timestamp), because the two failures mean very different things: one suggests someone found the endpoint, the other usually just means clock skew after a Pi reboot. Keeping the secret only in the two environment stores, never in source, is also exactly right for a setup like this.