Verify webhook signatures
Verify the X-SiteTrellis-Signature header with HMAC-SHA256 in PHP, Node.js and Python, with timestamp tolerance and replay protection.
Last updated:
Every webhook request carries a signature so you can prove it came from SiteTrellis and was not altered. Verifying it is the most important part of a webhook receiver. This page explains the algorithm and gives complete examples in PHP, Node.js and Python.
The header#
X-SiteTrellis-Signature: t=1791381802,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd
tis the Unix timestamp (seconds, UTC) at which SiteTrellis signed the request. Each retry is signed again with a fresh timestamp.v1is the lowercase hexadecimal HMAC-SHA256 of the stringt + "." + body, using your endpoint's signing secret (the value startingwhsec_) as the key.bodyis the raw request body exactly as received.
The algorithm#
- Read the raw request body as bytes or an unparsed string. Do not parse and re-encode the JSON: re-serialising changes whitespace and key order, and the signature will not match.
- Parse the header: split on commas, then split each part on the first
=, to gettandv1. Reject the request if either is missing. - Check the timestamp. If
abs(now - t)is more than 300 seconds (5 minutes), reject the request. This limits replay of a captured request. Keep your server clock synchronised (NTP). - Compute
expected = hex(HMAC_SHA256(secret, t + "." + rawBody)). - Compare
expectedwithv1using a constant-time comparison function. Do not use==or===, which can leak information through timing. - If everything matches, parse the JSON and process the event. Otherwise respond
400and do nothing else.
PHP#
<?php
declare(strict_types=1);
function verify_sitetrellis_signature(string $header, string $rawBody, string $secret, int $tolerance = 300): bool
{
$t = null;
$sig = null;
foreach (explode(',', $header) as $part) {
[$k, $v] = array_pad(explode('=', trim($part), 2), 2, '');
if ($k === 't') {
$t = $v;
} elseif ($k === 'v1') {
$sig = $v;
}
}
if ($t === null || $sig === null || !ctype_digit($t)) {
return false;
}
if (abs(time() - (int)$t) > $tolerance) {
return false; // too old (or from the future)
}
$expected = hash_hmac('sha256', $t . '.' . $rawBody, $secret);
return hash_equals($expected, $sig); // constant-time compare
}
$raw = file_get_contents('php://input'); // RAW body
$header = $_SERVER['HTTP_X_SITETRELLIS_SIGNATURE'] ?? '';
if (!verify_sitetrellis_signature($header, $raw, getenv('ST_WEBHOOK_SECRET') ?: '')) {
http_response_code(400);
exit('invalid signature');
}
$event = json_decode($raw, true, 512, JSON_THROW_ON_ERROR);
// ... skip if $event['id'] was already processed, then handle $event['event'] ...
http_response_code(200);
echo 'ok';
Node.js#
const crypto = require('crypto');
function verifySiteTrellisSignature(header, rawBody, secret, toleranceSeconds = 300) {
if (!header) return false;
const parts = {};
for (const piece of header.split(',')) {
const i = piece.indexOf('=');
if (i > 0) parts[piece.slice(0, i).trim()] = piece.slice(i + 1).trim();
}
if (!/^\d+$/.test(parts.t || '') || !/^[a-f0-9]{64}$/.test(parts.v1 || '')) return false;
const age = Math.abs(Math.floor(Date.now() / 1000) - Number(parts.t));
if (age > toleranceSeconds) return false;
// rawBody must be the Buffer or string exactly as received
const expected = crypto
.createHmac('sha256', secret)
.update(`${parts.t}.`)
.update(rawBody)
.digest('hex');
const a = Buffer.from(expected, 'hex');
const b = Buffer.from(parts.v1, 'hex');
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
// Express: use express.raw() on this route so req.body is the untouched Buffer
// app.post('/hooks/sitetrellis', express.raw({ type: 'application/json' }), (req, res) => {
// if (!verifySiteTrellisSignature(req.get('X-SiteTrellis-Signature'), req.body, process.env.ST_WEBHOOK_SECRET)) {
// return res.status(400).send('invalid signature');
// }
// const event = JSON.parse(req.body.toString('utf8'));
// res.sendStatus(200);
// });
Python#
import hashlib
import hmac
import time
def verify_sitetrellis_signature(header: str, raw_body: bytes, secret: str, tolerance: int = 300) -> bool:
parts = {}
for piece in (header or "").split(","):
key, sep, value = piece.strip().partition("=")
if sep:
parts[key] = value
t, sig = parts.get("t", ""), parts.get("v1", "")
if not t.isdigit() or not sig:
return False
if abs(time.time() - int(t)) > tolerance:
return False
expected = hmac.new(
secret.encode("utf-8"),
t.encode("ascii") + b"." + raw_body,
hashlib.sha256,
).hexdigest()
return hmac.compare_digest(expected, sig)
# Flask example
# from flask import Flask, request, abort
# app = Flask(__name__)
#
# @app.post("/hooks/sitetrellis")
# def hook():
# raw = request.get_data() # raw bytes, before any JSON parsing
# if not verify_sitetrellis_signature(request.headers.get("X-SiteTrellis-Signature", ""), raw, SECRET):
# abort(400)
# event = request.get_json(force=True)
# return "ok", 200
Replay protection#
The timestamp check stops an attacker from replaying a captured request later than five minutes after it was signed. To also stop replays inside that window, and to cope with SiteTrellis' own at-least-once retries, record the event id (also sent in the X-SiteTrellis-Delivery header) for at least ten minutes, ideally longer, and ignore an id you have already processed. A database unique constraint on the event id makes this easy to do safely under concurrency.
Testing your verification#
- Use Send test event on the endpoint in the dashboard. A correct implementation accepts it.
- Change one character of the secret in your configuration and send another test. Your receiver must now reject it with
400. - Redeliver an old delivery from the delivery log after waiting for more than five minutes. Redeliveries are re-signed with the current time, so they pass. To test the timestamp check, call your verifier directly with a stale
tvalue in a unit test.
Common mistakes#
| Mistake | Symptom | Fix |
|---|---|---|
| Verifying a parsed-and-re-encoded body | Signature never matches. | Use the raw bytes. In Express use express.raw(); in frameworks that parse for you, enable raw body capture. |
| A proxy or framework changes the body (trimming, charset conversion) | Intermittent mismatches. | Capture the body as early as possible and as bytes. |
| Using the wrong secret | Always fails. | Each endpoint has its own secret. If you lost it, create a new endpoint and delete the old one. |
| Server clock off by minutes | All requests rejected as too old. | Enable time synchronisation. |
| Ordinary string comparison | Works, but timing leak. | Use hash_equals, crypto.timingSafeEqual or hmac.compare_digest. |
More about events and delivery: Webhooks.