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.
