# 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:

   ```http
   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.

> **Warning — This only works if you loop over the v1 values**
>
> A verifier that reads the *first* `v1=` and stops will break the moment a
> rotation starts, because during the window the first value is signed with the
> secret you have not deployed yet.
>
> Every sample on the
> [signature page](https://www.salafems.com/developers/webhook-signatures.md#verifying) loops. If yours
> does not, fix it before you rotate, not during.

## 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](https://www.salafems.com/developers/webhook-testing.md): 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.

> **Danger — A leaked signing secret is a forgery risk, not a data leak**
>
> The secret does not grant access to anything. What it does is let whoever
> holds it produce requests your receiver will believe came from Salaf —
> invented orders, fabricated payment events, stock levels that never existed.
>
> If a secret is exposed, rotate immediately and then check what your receiver
> processed in the interim.

## 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:

**Node.js**

```javascript
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"));
}
```

**Python**

```python
import hashlib
import hmac
import time

TOLERANCE_SECONDS = 300  # 5 minutes


def verify_salaf_signature(raw_body: bytes, header: str, secret: str) -> bool:
    """raw_body is the exact request body — bytes, not a parsed dict."""
    if not header:
        return False

    timestamp = None
    signatures = []
    for part in header.split(","):
        key, _, value = part.partition("=")
        if key.strip() == "t":
            timestamp = value.strip()
        elif key.strip() == "v1":
            signatures.append(value.strip())

    if timestamp is None or not signatures:
        return False

    # Replay window. The timestamp is signed, so it cannot be swapped.
    if abs(int(time.time()) - int(timestamp)) > TOLERANCE_SECONDS:
        return False

    signed_payload = timestamp.encode() + b"." + raw_body
    expected = hmac.new(
        secret.encode(), signed_payload, hashlib.sha256
    ).hexdigest()

    # compare_digest, never ==: a plain comparison leaks the match length.
    # Two v1= values appear during a rotation; either may match.
    return any(hmac.compare_digest(expected, s) for s in signatures)
```

**PHP**

```php
<?php

const SALAF_TOLERANCE_SECONDS = 300; // 5 minutes

/** $rawBody must be the exact request body, not a re-encoded array. */
function verify_salaf_signature(string $rawBody, ?string $header, string $secret): bool
{
    if ($header === null || $header === '') {
        return false;
    }

    $timestamp = null;
    $signatures = [];
    foreach (explode(',', $header) as $part) {
        [$key, $value] = array_pad(explode('=', $part, 2), 2, null);
        $key = trim((string) $key);
        if ($key === 't') {
            $timestamp = trim((string) $value);
        } elseif ($key === 'v1') {
            $signatures[] = trim((string) $value);
        }
    }

    if ($timestamp === null || $signatures === []) {
        return false;
    }

    // Replay window — the timestamp is part of what was signed.
    if (abs(time() - (int) $timestamp) > SALAF_TOLERANCE_SECONDS) {
        return false;
    }

    $expected = hash_hmac('sha256', $timestamp . '.' . $rawBody, $secret);

    // hash_equals, never ===. Two signatures ride along during a rotation.
    foreach ($signatures as $candidate) {
        if (hash_equals($expected, $candidate)) {
            return true;
        }
    }

    return false;
}
```

## 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.
