HMAC-signed webhook: wire up your leads cleanly, no hacked-together form
You've got a new SaaS spitting out leads, and the temptation is always the same: a public endpoint that accepts any JSON POST, we'll sort out security later. Three weeks in, you're wondering why you have 400 fake leads and a competitor who knows your signup volume. The right primitive exists, it fits in 30 lines, and it's the HMAC-signed webhook: raw body, SHA-256 signature, timestamp anti-replay. Here's how we wired it up on Zylior for inbound leads across all our SaaS products.
Why a hacked-together form doesn't hold up
An `/api/ingest` endpoint that trusts the request body has three holes, and they're all exploitable without being a skilled attacker:
- Anyone can post. The URL leaks into a log, a Slack, a browser network tab, and you take bogus leads straight into your database. A secret in a query param (`?key=...`) saves nothing: it ends up in the reverse proxy's access logs and the browser history.
- You don't know if the body was modified. Without signing the content, a misconfigured proxy or a MITM can change an email, an amount, an affiliate `ref`, and you'd have no idea.
- Replay is free. Even with a secret, if I capture a valid request once, I resend it 500 times and trash your CRM — unless you also sign a timestamp and reject requests that are too old.
HMAC solves all three at once: the sender computes `HMAC-SHA256(body, shared_secret)` and sends it to you in a header. You recompute it with the same secret. If they match, the body hasn't changed and the sender knows the secret. Add the timestamp to the computation and you close the door on replay.
Sender side: sign the raw body
The point that breaks 80% of integrations: you must sign the exact bytes sent over the wire, not a re-serialized object. `JSON.stringify` isn't deterministic across runtimes (key order, whitespace, Unicode escaping). If the sender signs a string and the receiver re-stringifies the parsed JSON, the signatures will diverge on one payload in ten — the one with an accent or an emoji. You sign the string once, you send it as-is.
import { createHmac } from 'node:crypto';
function pushLead(payload, secret, url) {
// 1. Le corps BRUT, figé une seule fois.
const body = JSON.stringify(payload);
const ts = Math.floor(Date.now() / 1000).toString();
// 2. On signe timestamp + corps (le ts entre dans le HMAC : il ne
// peut pas être bidouillé sans casser la signature).
const mac = createHmac('sha256', secret)
.update(ts + '.' + body)
.digest('hex');
return fetch(url, {
method: 'POST',
headers: {
'content-type': 'application/json',
'x-zylior-signature': mac, // hex brut
'x-zylior-timestamp': ts,
},
body, // EXACTEMENT la string signée
});
}
Receiver side: verify in constant time + anti-replay
Verification has two non-negotiable pitfalls. First, grab the raw body BEFORE your framework parses it into JSON: `express.json()` consumes the stream, and by the time it reaches your handler the original body is gone. Capture the bytes in the middleware via the `verify` option and stash them on `req.rawBody`. Second, never `computedSig === receivedSig`: string equality returns at the first differing byte, which leaks the expected signature through timing measurement. Use `timingSafeEqual` — but it throws if the buffers aren't the same length, so test the length first.
// Capter le corps brut AU PARSE, sinon il est perdu :
app.use(express.json({
limit: '2mb',
verify: (req, _res, buf) => { req.rawBody = buf; },
}));
import { createHmac, timingSafeEqual } from 'node:crypto';
function verifyHmac({ secret, rawBody, signature, timestamp, toleranceSeconds }) {
if (!signature) return { ok: false, reason: 'signature manquante' };
// Anti-replay : dès qu'une fenêtre est configurée, le timestamp est
// OBLIGATOIRE (un endpoint signé sans ts = rejeu illimité).
if (toleranceSeconds > 0) {
const ts = Number(timestamp);
if (!Number.isFinite(ts)) return { ok: false, reason: 'timestamp invalide' };
if (Math.abs(Date.now() / 1000 - ts) > toleranceSeconds) {
return { ok: false, reason: 'hors tolerance (replay ?)' };
}
}
const expected = createHmac('sha256', secret)
.update(timestamp + '.' + rawBody.toString())
.digest('hex');
const a = Buffer.from(expected);
const b = Buffer.from(signature.trim());
if (a.length !== b.length) return { ok: false, reason: 'non concordante' };
return timingSafeEqual(a, b) ? { ok: true } : { ok: false, reason: 'non concordante' };
}
The pitfalls that'll cost you an evening
- Time zone and clock. The timestamp is signed and compared in UTC epoch (seconds), never in local time. A sender on a badly set server, or one that sends milliseconds where you expect seconds, and everything comes back "out of tolerance." A tolerance of ±300 s absorbs normal clock drift without opening a useful replay window.
- Body modified in transit. A proxy that "prettifies" the JSON (reformatting, BOM, encoding conversion) changes the bytes and breaks the signature. That's good news: the signature did its job. Log the rejection `reason` to tell this apart from a wrong secret.
- Idempotency. Anti-replay blocks malicious replay, but a legitimate fire-and-forget sender will resend the same lead on a network timeout. Set a stable `dedup_key` (the business id, otherwise a hash of the raw body) with an `ON CONFLICT DO NOTHING`.
- Validation AFTER the signature. Once the HMAC is validated, the body is still public input: validate it (zod). Nasty trap: `z.string().url()` accepts `javascript:` and `data:` — if a field is a URL, enforce `.regex(/^https?:\/\//)`.
- Respond fast. Return 200 in under 50 ms, even on a duplicate. Fire-and-forget senders retry on slowness, which creates false replay for you. Do the heavy work after acknowledging receipt.
Setting it up without getting burned
Generate one secret per endpoint (32+ random bytes), encrypt it at rest in the database, expose it to the client only once along with the URL and a ready-to-copy snippet. Configure the signature scheme, the header, and the tolerance per data — no hardcoded `if (slug === 'renfort')`: adding a SaaS should be an INSERT, not a deployment. And write the four tests that matter: a valid signed payload passes, the same one replayed out of window is rejected, a single modified byte is rejected, a signature of the right length but wrong is rejected. Until those four cases are green, your webhook isn't wired up cleanly — it's just open.
The newsletter
By subscribing you agree to receive the Zylior newsletter. One-click unsubscribe in every email.