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:
| Header | Value |
|---|---|
X-OpenFence-Signature | t=<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-Timestamp | Unix seconds — sibling of the t= segment. The two values MUST agree; receivers should reject deliveries where they disagree. |
X-OpenFence-Webhook-Id | UUID of the subscription. |
X-OpenFence-Delivery-Id | UUID of this delivery attempt (stable across retries). |
Verification algorithm
- Read
X-OpenFence-Signature. Parse the comma-separated tuple intokey=valuesegments. Reject ift=orv1=is missing. - Reject if any key in the structured header appears more than once (duplicates indicate header tampering or proxy misconfig).
- (Recommended) Read
X-OpenFence-Timestampand compare to the parsedt. Reject on mismatch. - Compute
now - t. Reject ifabs(now - t) > 5 * 60seconds. The window MUST be applied symmetrically — reject too-far-future as well as too-far-past. - Validate that the
v1=value matches^[0-9a-f]{64}$(lowercase hex, exactly 64 chars — SHA-256 digest length). - Recompute
expected = hmac_sha256(signing_secret, "<t>.<body_bytes>")over the RAW request body bytes captured BEFORE any HTTP framework parsing. - Compare
expectedagainst the parsedv1using a CONSTANT-TIME comparator (hmac.compare_digestin Python,crypto.timingSafeEqualin Node,hmac.Equalin Go). - 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
- Decoding the body to a string before verifying. The HMAC is over raw bytes. If your HTTP framework auto-decodes JSON and stringifies, capture the bytes BEFORE that step.
- Using
==to compare signatures. Use a constant-time comparator to avoid timing-side-channel attacks. - Skipping the freshness window. Even a valid signature is replayable if the timestamp isn't bounded. The window is 5 minutes, applied symmetrically.
- Not checking
X-OpenFence-Timestampagainst thet=segment. They MUST agree; mismatch is a tampering signal. - Silently accepting duplicate
v1=segments. A header liket=...,v1=A,v1=Bwith dict-overwrite parsing would accept whichever segment is parsed last. Reject duplicates explicitly. - Raising on malformed input. The verifier is a pure function: malformed signature header → return false, NOT raise. The caller translates false into a 401.
See also
- Wire format spec: §5.5 of
consumer-integration.md(linked from the openfence.ai landing footer). - Fixture: openfence.ai/fixtures/openfence-webhook-test-vectors.json
- Sample sources: /samples/node/ · /samples/go/ · /samples/python/