zylior
← Blog

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:

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
  });
}
Two schools for the signature format: raw hex (`a1b2c3…`) or prefixed (`sha256=a1b2c3…`, the Meta/Stripe style). Pick one, document it, and keep it configurable on the receiver side — the day you hook up a third-party sender that forces the other one, you don't want to redeploy.

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' };
}
A rule we impose on ourselves: an endpoint that's "signed but without a timestamp" is REJECTED, not silently accepted. If you declare a tolerance and the timestamp header is missing, return 401. Otherwise you have the illusion of anti-replay without the anti-replay.

The pitfalls that'll cost you an evening

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.