Verifying OpenFence webhook signatures

OpenFence signs every webhook delivery with HMAC-SHA256 over the timestamp and raw request body. Receivers MUST verify the signature against the body bytes BEFORE processing the event, and reject deliveries outside the 5-minute freshness window. This page shows the wire format, the 8-step verification algorithm, and runnable verifier samples in Node.js, Go, and Python.

Wire format

OpenFence delivers webhooks with these headers:

HeaderValue
X-OpenFence-Signaturet=<unix_seconds>,v1=<hex_hmac> — comma-separated tuple. t is the unix timestamp the signature was computed at; v1 is the HMAC-SHA256 hex digest of "<t>.<body_bytes>" keyed by the subscription's signing secret.
X-OpenFence-TimestampUnix seconds — sibling of the t= segment. The two values MUST agree; receivers should reject deliveries where they disagree.
X-OpenFence-Webhook-IdUUID of the subscription.
X-OpenFence-Delivery-IdUUID of this delivery attempt (stable across retries).

Verification algorithm

  1. Read X-OpenFence-Signature. Parse the comma-separated tuple into key=value segments. Reject if t= or v1= is missing.
  2. Reject if any key in the structured header appears more than once (duplicates indicate header tampering or proxy misconfig).
  3. (Recommended) Read X-OpenFence-Timestamp and compare to the parsed t. Reject on mismatch.
  4. Compute now - t. Reject if abs(now - t) > 5 * 60 seconds. The window MUST be applied symmetrically — reject too-far-future as well as too-far-past.
  5. Validate that the v1= value matches ^[0-9a-f]{64}$ (lowercase hex, exactly 64 chars — SHA-256 digest length).
  6. Recompute expected = hmac_sha256(signing_secret, "<t>.<body_bytes>") over the RAW request body bytes captured BEFORE any HTTP framework parsing.
  7. Compare expected against the parsed v1 using a CONSTANT-TIME comparator (hmac.compare_digest in Python, crypto.timingSafeEqual in Node, hmac.Equal in Go).
  8. Reject = return false at every step. A correctly-implemented verifier MUST convert all malformed-input exceptions into a false return; it MUST NOT raise to the caller.

Code samples

All three samples below are byte-equal to the runnable files at /samples/<lang>/. Each ships with a fixture-driven test that exercises 11 cases against your verifier; download the fixture from openfence.ai/fixtures/openfence-webhook-test-vectors.json.

import crypto from 'node:crypto';

const OPENFENCE_MAX_TOLERANCE_SECONDS = 300;
const V1_DIGEST_RE = /^[0-9a-f]{64}$/;

/**
 * Verify an OpenFence webhook signature.
 *
 * Returns true iff the signature matches AND the timestamp is within
 * `toleranceSeconds` of `nowUnix` (or wall-clock if nowUnix is null).
 * Returns false on ANY malformed input or mismatch.  This function
 * NEVER throws — the caller is responsible for translating false into
 * a 401 / drop.
 *
 * @param {Object} params
 * @param {string} params.signingSecret
 * @param {Buffer|Uint8Array} params.body - raw HTTP body bytes
 * @param {string} params.signatureHeader - X-OpenFence-Signature value
 * @param {string} params.timestampHeader - X-OpenFence-Timestamp value
 * @param {number} [params.toleranceSeconds=300] - freshness window
 * @param {number|null} [params.nowUnix=null] - fixed timestamp, null = wall clock
 * @returns {boolean}
 */
export function verifyOpenfenceSignature({
    signingSecret,
    body,
    signatureHeader,
    timestampHeader,
    toleranceSeconds = OPENFENCE_MAX_TOLERANCE_SECONDS,
    nowUnix = null,
}) {
    if (toleranceSeconds > OPENFENCE_MAX_TOLERANCE_SECONDS) {
        return false;
    }
    try {
        // Object.create(null) produces a bare dict with no prototype, so
        // `'toString' in parts` etc. don't false-positive the duplicate-key
        // check.  Aligns Node semantics with Python's dict.
        const parts = Object.create(null);
        for (const raw of String(signatureHeader).split(',')) {
            const seg = raw.trim();
            const eq = seg.indexOf('=');
            if (eq === -1) return false;
            const k = seg.slice(0, eq);
            const v = seg.slice(eq + 1);
            if (k in parts) return false; // duplicate key — header tampering
            parts[k] = v;
        }
        if (!('t' in parts) || !('v1' in parts)) return false;

        const t = parseInt(parts.t, 10);
        if (!Number.isFinite(t) || String(t) !== parts.t) return false;
        const v1 = parts.v1;

        if (!V1_DIGEST_RE.test(v1)) return false;

        const tHeader = parseInt(timestampHeader, 10);
        if (!Number.isFinite(tHeader) || tHeader !== t) return false;

        const now = nowUnix !== null ? nowUnix : Math.floor(Date.now() / 1000);
        if (Math.abs(now - t) > toleranceSeconds) return false;

        const signedInput = Buffer.concat([
            Buffer.from(`${t}.`, 'ascii'),
            Buffer.isBuffer(body) ? body : Buffer.from(body),
        ]);
        const expected = crypto
            .createHmac('sha256', signingSecret)
            .update(signedInput)
            .digest('hex');

        const expectedBuf = Buffer.from(expected, 'utf-8');
        const v1Buf = Buffer.from(v1, 'utf-8');
        if (expectedBuf.length !== v1Buf.length) return false;
        return crypto.timingSafeEqual(expectedBuf, v1Buf);
    } catch {
        return false;
    }
}
// Package verifier implements OpenFence webhook signature verification.
//
// See https://openfence.ai/docs/webhooks/verify for the wire format
// and the byte-exact test fixture.
package verifier

import (
	"crypto/hmac"
	"crypto/sha256"
	"encoding/hex"
	"regexp"
	"strconv"
	"strings"
	"time"
)

const openFenceMaxToleranceSeconds = 300

var v1DigestRE = regexp.MustCompile(`^[0-9a-f]{64}$`)

// Params bundles the verifier inputs.  ToleranceSeconds defaults to
// 300 when zero; NowUnix defaults to wall clock when zero.
type Params struct {
	SigningSecret    string
	Body             []byte
	SignatureHeader  string
	TimestampHeader  string
	ToleranceSeconds int
	NowUnix          int64
}

// Verify returns true iff the signature is valid and within the
// freshness window.  Returns false on ANY malformed input or
// mismatch.  Never returns an error — the caller is responsible for
// translating false into a 401 / drop.
func Verify(p Params) bool {
	tolerance := p.ToleranceSeconds
	if tolerance == 0 {
		tolerance = openFenceMaxToleranceSeconds
	}
	if tolerance > openFenceMaxToleranceSeconds {
		return false
	}

	parts := map[string]string{}
	for _, raw := range strings.Split(p.SignatureHeader, ",") {
		seg := strings.TrimSpace(raw)
		idx := strings.Index(seg, "=")
		if idx == -1 {
			return false
		}
		k := seg[:idx]
		v := seg[idx+1:]
		if _, dup := parts[k]; dup {
			return false
		}
		parts[k] = v
	}

	tStr, ok := parts["t"]
	if !ok {
		return false
	}
	v1, ok := parts["v1"]
	if !ok {
		return false
	}

	t, err := strconv.ParseInt(tStr, 10, 64)
	if err != nil {
		return false
	}

	if !v1DigestRE.MatchString(v1) {
		return false
	}

	tHeader, err := strconv.ParseInt(p.TimestampHeader, 10, 64)
	if err != nil || tHeader != t {
		return false
	}

	now := p.NowUnix
	if now == 0 {
		now = time.Now().Unix()
	}

	diff := now - t
	if diff < 0 {
		diff = -diff
	}
	if diff > int64(tolerance) {
		return false
	}

	signedInput := append([]byte(strconv.FormatInt(t, 10)+"."), p.Body...)
	mac := hmac.New(sha256.New, []byte(p.SigningSecret))
	mac.Write(signedInput)
	expected := hex.EncodeToString(mac.Sum(nil))

	return hmac.Equal([]byte(expected), []byte(v1))
}
import hmac
import hashlib
import re
import time

# OpenFence MANDATES a 5-minute receiver freshness window.  The kwarg
# below exists for testability; production receivers must not pass a
# value above this floor.
_OPENFENCE_MAX_TOLERANCE_SECONDS = 300

# v1 signatures are 64 lowercase-hex chars (SHA-256 digest).
_V1_DIGEST_RE = re.compile(r"^[0-9a-f]{64}$")


def verify_openfence_signature(
    *,
    signing_secret: str,
    body: bytes,
    signature_header: str,
    timestamp_header: str,
    tolerance_seconds: int = _OPENFENCE_MAX_TOLERANCE_SECONDS,
    now_unix: int | None = None,
) -> bool:
    """Verify an OpenFence webhook signature.

    Returns True iff the signature matches AND the timestamp is within
    ``tolerance_seconds`` of now.  Returns False on any malformed input
    or mismatch.  This function NEVER raises to the caller — the caller
    is responsible for translating a False return into a 401 / drop.

    Arguments:
        signing_secret: the subscription's signing secret (the same
            value you registered with OpenFence).
        body: the RAW request body bytes — captured BEFORE any HTTP
            framework parses, decodes, or otherwise transforms the
            body.  Do not JSON-parse-and-re-serialize; whitespace and
            key-ordering differences will break verification.
        signature_header: the value of the X-OpenFence-Signature header,
            e.g. "t=1714780000,v1=abc123...".
        timestamp_header: the value of the X-OpenFence-Timestamp header.
        tolerance_seconds: how far the timestamp may be from now()
            before the delivery is treated as stale.  Defaults to 300s
            (5 minutes) — OpenFence's MUST.  Values ABOVE 300 are a
            spec violation and are rejected at call time; values below
            300 are permitted (a tighter window is always safe).
        now_unix: optional fixed wall-clock timestamp (seconds) to use
            for the freshness check.  Production callers leave this
            None; tests pass a fixed value to exercise the freshness
            window deterministically without monkey-patching
            ``time.time()``.
    """
    if tolerance_seconds > _OPENFENCE_MAX_TOLERANCE_SECONDS:
        raise ValueError(
            "tolerance_seconds exceeds OpenFence's 5-minute MUST; "
            "tighter windows are permitted, looser are not."
        )

    try:
        # Parse all `key=value` segments; reject duplicates and
        # malformed segments.  We tolerate unknown segments (e.g. a
        # future v2=...) and pick v1 explicitly — when OpenFence
        # ships v2, this sample needs updating to prefer the highest
        # version present.
        parts: dict[str, str] = {}
        for raw in signature_header.split(","):
            seg = raw.strip()
            if "=" not in seg:
                return False
            k, v = seg.split("=", 1)
            if k in parts:
                return False  # duplicate key — header tampering
            parts[k] = v

        if "t" not in parts or "v1" not in parts:
            return False

        t = int(parts["t"])  # ValueError caught below
        v1 = parts["v1"]

        # Pin the v1 hex shape — defense-in-depth around compare_digest.
        if not _V1_DIGEST_RE.fullmatch(v1):
            return False

        # Sibling header sanity check (the t= segment above is the
        # authoritative timestamp; this is a tamper-detection cross-check).
        if int(timestamp_header) != t:
            return False

        # Freshness window — symmetric: reject too-future as well as
        # too-past (clock-skew defense).
        now = now_unix if now_unix is not None else int(time.time())
        if abs(now - t) > tolerance_seconds:
            return False

        # Recompute over BYTES, not over a re-decoded string — the
        # producer signs `f"{t}.".encode("ascii") + body_bytes`.
        signed_input = f"{t}.".encode("ascii") + body
        expected = hmac.new(
            signing_secret.encode("utf-8"),
            signed_input,
            hashlib.sha256,
        ).hexdigest()
        return hmac.compare_digest(expected, v1)
    except (ValueError, TypeError, UnicodeDecodeError, AttributeError):
        # Any malformed input (non-integer t, missing/None timestamp
        # header, undecodable bytes anywhere) becomes a False return.
        # Per the docstring, this function does not raise to the caller.
        return False

Test vectors

Pin your verifier against the byte-exact fixture:

curl https://openfence.ai/fixtures/openfence-webhook-test-vectors.json

The fixture has 11 cases — accepts, stale/future timestamps, tampered signatures, tampered bodies, duplicate v1= segments, sibling timestamp mismatches, malformed segments, non-UTF-8 bodies. Each case carries an expected_outcome (accepted or rejected:<reason>); your verifier should match all 11 outcomes.

Common mistakes

See also