SALAF EMSDevelopers
salafems.com

Rotating a secret

The dual-signing window, and what a receiver does during it.

Rotating a signing secret replaces it without dropping a single delivery. The old secret keeps co-signing for 24 hours, so you deploy the new one whenever your own release process allows.

What happens when you rotate

In the dashboard: Settings → Developer → Webhooks, open the endpoint, and choose Rotate signing secret.

  1. A new whsec_ secret is generated and shown to you once.

  2. The old secret keeps signing alongside it for 24 hours.

  3. Every delivery in that window carries two v1= values — one from each secret:

    X-Salaf-Signature: t=1754899200,v1=<signed with the NEW secret>,v1=<signed with the OLD one>
  4. When the window closes, the old secret stops signing and deliveries carry one v1= again.

A receiver that checks the incoming signature against its secret and accepts any match therefore keeps working throughout — before the deploy it matches the second value, after the deploy the first. Nothing has to be swapped atomically on two machines at once.

The rotation you should be doing

  1. Rotate in the dashboard and copy the new secret.
  2. Deploy it to your receiver — any time in the next 24 hours. No coordination, no maintenance window.
  3. Confirm with a test event: a salaf.ping that verifies with your new secret proves the deploy landed.

There is nothing to do at the end of the window. The old secret expires on its own; you never have to come back and turn something off.

Rotating immediately

The dialog offers an immediate option, which drops the old secret at once instead of co-signing.

Use it for exactly one situation: the secret leaked. Anything else — a routine rotation, a policy schedule, a departing contractor whose access you have already removed — should use the window, because immediate rotation means every delivery fails from the moment you click until the moment your new secret is live. Those failures are retried, so nothing is lost, but you have chosen an outage you did not need.

Verifying during a rotation

Nothing about verification changes — you still hash "{timestamp}.{rawBody}" with the one secret you hold and compare against each v1= in constant time. The only requirement is that you compare against all of them:

import crypto from "node:crypto";

const TOLERANCE_SECONDS = 300; // 5 minutes

/**
 * @param rawBody the EXACT bytes we sent, as a string or Buffer — never a
 *                re-serialised object.
 */
export function verifySalafSignature(rawBody, header, secret) {
  if (!header) return false;

  let timestamp = null;
  const signatures = [];
  for (const part of header.split(",")) {
    const [key, value] = part.split("=");
    if (key?.trim() === "t") timestamp = Number(value?.trim());
    if (key?.trim() === "v1") signatures.push(value?.trim());
  }
  if (!Number.isFinite(timestamp) || signatures.length === 0) return false;

  // Reject anything captured and replayed later. The timestamp is INSIDE the
  // signed string, so an attacker cannot simply put a fresh one in the header.
  const now = Math.floor(Date.now() / 1000);
  if (Math.abs(now - timestamp) > TOLERANCE_SECONDS) return false;

  const expected = crypto
    .createHmac("sha256", secret)
    .update(`${timestamp}.${rawBody}`, "utf8")
    .digest("hex");

  // During a secret rotation there are TWO v1= values; either may match.
  return signatures.some((candidate) => timingSafeEqual(expected, candidate));
}

// Never use === on a signature: the early exit leaks, through timing, how
// many leading characters an attacker guessed right.
function timingSafeEqual(a, b) {
  if (a.length !== b.length) return false;
  return crypto.timingSafeEqual(Buffer.from(a, "utf8"), Buffer.from(b, "utf8"));
}

What rotation does not change

  • api_version is pinned at creation and never changes, not by rotation and not by editing the endpoint. Your payload shape is stable for the life of the endpoint.
  • The endpoint's URL, subscriptions and delivery history are untouched.
  • In-flight retries are re-signed at each attempt, so a delivery that was queued before the rotation and retried after it is signed with whatever secrets are active at the moment it is sent — never with a stale one.
  • The endpoint id stays the same, so anything you have keyed on it is fine.

If you lose the secret

There is no way to display it again — it is stored encrypted so the delivery worker can sign with it, but no screen and no support process prints it back. Rotate, and treat it as an ordinary rotation with the 24-hour window: the old secret you have lost is still the one that works, so nothing breaks while you deploy the new one.