v1 · preview

Try webhook signature, alert.created, location_withheld or 429.

↑ ↓ moveEnter openEsc close

Real time

Verifying a webhook signature

How to check that a delivery comes from SafeOnuba: HMAC-SHA256 over the raw body, constant-time comparison, a 5-minute window and secret rotation.

On this page

Your receiver is at a public URL, so anyone can send it a POST. The signature is what proves a delivery comes from SafeOnuba and that nobody has tampered with it along the way. Don’t process any event without verifying it.

How it’s signed

Each delivery carries two headers:

  • X-SafeOnuba-Timestamp: the moment of signing, in Unix seconds.
  • X-SafeOnuba-Signature: v1= followed by the signature in hexadecimal.

The signature is an HMAC-SHA256, with the destination’s secret (the whsec_… the API gave you when you created it) as the key, over the timestamp, a full stop and the body exactly as it arrived:

Signature
v1=hex( HMAC_SHA256( secret, timestamp + "." + raw_body ) )

What your receiver has to do

  1. Read the raw body, as bytes, before any framework turns it into JSON. If you parse it and serialise it again, the whitespace or the order of the keys changes and the signature no longer matches.
  2. Reject it if the timestamp is more than 5 minutes off your clock, in either direction. This stops someone replaying an old event they captured. Keep your server’s clock synchronised by NTP.
  3. Calculate the signature with your secret over timestamp + "." + body.
  4. Compare it in constant time against each signature in the header. An ordinary comparison (==) takes a different time depending on how many characters match, and that leaks information.
  5. Accept it if any of the signatures in the header matches. During a secret rotation two arrive, separated by a comma.

If the signature isn’t valid, respond 401 and discard the body.

A complete receiver

The three examples do the same thing: they verify, put the event on your queue (enqueue, which you provide) and respond 204 straight away.

import { createHmac, timingSafeEqual } from 'node:crypto';
import { createServer } from 'node:http';

const SECRET = process.env.SAFEONUBA_WEBHOOK_SECRET; // whsec_…
const TOLERANCE_S = 5 * 60;

export function isValidSignature(body, timestamp, header, secret = SECRET) {
  if (!body || !timestamp || !header) return false;

  // 1. The timestamp, within 5 minutes of our clock.
  const t = Number(timestamp);
  if (!Number.isInteger(t) || Math.abs(Date.now() / 1000 - t) > TOLERANCE_S) return false;

  // 2. The expected signature, over the raw body (a Buffer).
  const expected = createHmac('sha256', secret).update(`${timestamp}.`).update(body).digest();

  // 3. Any of the signatures in the header will do, compared in constant time.
  return header.split(',').some((part) => {
    const [version, hex] = part.trim().split('=');
    if (version !== 'v1' || !/^[0-9a-f]{64}$/i.test(hex ?? '')) return false;
    return timingSafeEqual(Buffer.from(hex, 'hex'), expected);
  });
}

createServer((req, res) => {
  if (req.method !== 'POST' || req.url !== '/safeonuba') {
    res.writeHead(404).end();
    return;
  }
  const chunks = [];
  req.on('data', (chunk) => chunks.push(chunk));
  req.on('end', () => {
    const body = Buffer.concat(chunks); // raw: no JSON.parse before verifying
    const valid = isValidSignature(
      body,
      req.headers['x-safeonuba-timestamp'],
      req.headers['x-safeonuba-signature'],
    );
    if (!valid) {
      res.writeHead(401).end();
      return;
    }
    enqueue(JSON.parse(body.toString('utf8'))); // processed later, outside the request
    res.writeHead(204).end();
  });
}).listen(8080);

Rotating the secret

Rotate the secret if you think it has leaked, when someone who knew it leaves, or simply every so often. Nothing is interrupted:

  1. Call POST /v1/webhooks/{id}/rotate-secret. The response carries the new secret (secret, this once only) and how long the previous one keeps signing (previous_valid_until).

  2. For the next 24 hours, each delivery carries two signatures in X-SafeOnuba-Signature, one with each secret, separated by a comma:

    X-SafeOnuba-Signature
    v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd,v1=6ffbb59b2300aae63f272406069a9788598b792a944a07aba816edb039989a39
  3. Switch your receiver’s secret to the new one at any point in those 24 hours. Since the code above accepts the delivery if any one of the signatures matches, it works the same before and after the switch.

  4. After the 24 hours, the previous secret stops signing.

A second rotation retires the first secret: there are never more than two at once.

Common mistakes

  • Verifying over the re-serialised JSON. The signature is calculated over the exact bytes that arrived.
  • Comparing with ==. Use timingSafeEqual, hmac.compare_digest or CryptographicOperations.FixedTimeEquals.
  • Ignoring the timestamp. Without the 5-minute window, a captured event can be replayed whenever someone likes.
  • Keeping only the first signature. During a rotation two arrive, and the one that matches your secret may be the second.
  • Responding after processing. If your control room takes more than 5 seconds, the delivery counts as failed and is retried. Verify, enqueue and respond.