Skip to content

Verify webhook signatures

Verify that a webhook came from Fajita using HMAC-SHA256, a timestamp window, and constant-time comparison.

Core

Every generic webhook can be signed. Verify the signature before you trust a payload.

Signature headers

HeaderContains
Fajita-Signaturet=<timestamp>,kid=<key_id>,v1=<hex signature>
Fajita-Event-IDThe event id, used in the signed input and for idempotency
Fajita-Event-TypeThe event type
Fajita-TimestampThe signing timestamp (also in the signature header)
Fajita-Schema-VersionThe payload schema version

The signed input

The signature is computed over a single string built from the key id, timestamp, event id, and the raw request body:

Signed input
<key_id>.<timestamp>.<event_id>.<raw_body>
Signature
signature = hex( HMAC_SHA256(signing_secret, signed_input) )

Verification steps

  1. Read the raw request body.
  2. Parse the Fajita-Signature header into t, kid, and v1.
  3. Reject the request if the timestamp is outside your allowed window (for example, five minutes).
  4. Build the signed input string.
  5. Compute the HMAC-SHA256 with the signing secret for the given kid.
  6. Compare your value to v1 using a constant-time comparison.
  7. Record the event id and process each event only once.

Key rotation

The kid in the header tells you which signing key produced the signature. During rotation, keep the previous secret active until you stop receiving its kid.

Examples

Each example uses the raw body, validates the timestamp, and compares in constant time.

verify.js
import crypto from "node:crypto";

// Header: Fajita-Signature: t=<timestamp>,kid=<key_id>,v1=<hex>
function parseSignature(header) {
  return Object.fromEntries(
    header.split(",").map((part) => {
      const [k, v] = part.split("=");
      return [k, v];
    }),
  );
}

// Call with the RAW request body string, not a re-serialized object.
export function verify(rawBody, headers, secret, { toleranceSeconds = 300 } = {}) {
  const sig = parseSignature(headers["fajita-signature"]);
  const eventId = headers["fajita-event-id"];
  const timestamp = Number(sig.t);

  // 1. Reject requests outside the allowed time window.
  const now = Math.floor(Date.now() / 1000);
  if (!Number.isFinite(timestamp) || Math.abs(now - timestamp) > toleranceSeconds) {
    return false;
  }

  // 2. Recompute over the exact signed input.
  const signedInput = `${sig.kid}.${timestamp}.${eventId}.${rawBody}`;
  const expected = crypto
    .createHmac("sha256", secret)
    .update(signedInput)
    .digest("hex");

  // 3. Constant-time comparison.
  const a = Buffer.from(expected);
  const b = Buffer.from(sig.v1 ?? "");
  if (a.length !== b.length) return false;
  return crypto.timingSafeEqual(a, b);
}

Was this page helpful?