Verifying webhook signatures
Every webhook Pushrail delivers is signed with HMAC-SHA256. Verify the signature on every request so that a spoofer cannot impersonate Pushrail against your endpoint. Verification takes a few lines of code on your side and rules out entire classes of attacks, replay, tampering, and origin forgery.
Header shape
Pushrail supports two signing schemes, selectable per destination. Pick the one that fits your existing tooling.
| Scheme | Default header | Value format | Timestamped? |
| --- | --- | --- | --- |
| pushrail_v1 | X-Pushrail-Signature | t=<unix-ts>,v1=<hex> | Yes, enables replay protection |
| github_v1 | X-Hub-Signature-256 | sha256=<hex> | No, GitHub-compatible shape |
pushrail_v1 is the default and the right choice for new destinations. github_v1 exists so receivers that already handle GitHub webhooks can point at Pushrail without code changes.
Verify pushrail_v1 in Node.js
import crypto from "crypto";
export function verify(rawBody, headers, secret, toleranceSec = 300) {
const sig = headers["x-pushrail-signature"] ?? "";
const m = /^t=(\d+),v1=([0-9a-f]{64})$/.exec(sig);
if (!m) return false;
const [, ts, mac] = m;
const age = Math.abs(Math.floor(Date.now() / 1000) - Number(ts));
if (age > toleranceSec) return false;
const expected = crypto
.createHmac("sha256", secret)
.update(ts + "." + rawBody)
.digest("hex");
return crypto.timingSafeEqual(Buffer.from(mac), Buffer.from(expected));
}
The timestamp check (default 5-minute tolerance) is what makes pushrail_v1 replay-resistant. An attacker who captures a signed request cannot resend it an hour later because the timestamp will fail the freshness check.
Verify pushrail_v1 in Python
import hmac, hashlib, time, re
def verify(raw_body: bytes, headers: dict, secret: str, tolerance_sec: int = 300) -> bool:
sig = headers.get("X-Pushrail-Signature", "")
m = re.match(r"^t=(\d+),v1=([0-9a-f]{64})$", sig)
if not m:
return False
ts, mac = m.group(1), m.group(2)
if abs(int(time.time()) - int(ts)) > tolerance_sec:
return False
expected = hmac.new(
secret.encode(),
(ts + ".").encode() + raw_body,
hashlib.sha256,
).hexdigest()
return hmac.compare_digest(mac, expected)
Use hmac.compare_digest (or crypto.timingSafeEqual in Node), a plain == comparison leaks timing information that can let an attacker bit-by-bit recover the expected MAC.
GitHub-compatible variant (github_v1)
Matches GitHub's webhook signature format exactly, so you can point existing GitHub-webhook-handling code at Pushrail with no changes.
import crypto from "crypto";
export function verify(rawBody, headers, secret) {
const sig = headers["x-hub-signature-256"] ?? "";
const expected = "sha256=" + crypto
.createHmac("sha256", secret)
.update(rawBody)
.digest("hex");
return sig.length === expected.length
&& crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected));
}
The trade-off vs pushrail_v1 is that github_v1 has no timestamp, so it does not protect against replays. Combine it with receiver-side dedup on event id if replay protection matters.
Rotation and dual-verify
When you rotate a signing secret, Pushrail keeps the previous secret valid for 24 hours so receivers can update without downtime. During that window, verify against either the new or the old secret:
function verifyDuringRotation(rawBody, headers, current, previous) {
return verify(rawBody, headers, current)
|| (previous && verify(rawBody, headers, previous));
}
After the 24-hour window the previous secret stops signing and stops verifying. Plan the rotation so your deploy of the new secret lands well inside the window.
Raw-body gotchas
The signature is computed over the literal HTTP body bytes. If your framework re-serializes the JSON before your handler sees it, signatures will fail even when everything else is correct.
- Express: use
express.raw({ type: "application/json" })soreq.bodyis aBuffer, signed bytes must equal the bytes your framework parsed. - FastAPI: read the body with
await request.body()beforerequest.json(), and pass those bytes to the verifier. - Any framework: if middleware has already re-serialized the JSON, signatures will fail, always sign / verify against the literal HTTP body bytes.
Migrating from the legacy header
Destinations that predate v1 signing carry an older X-Pushrail-Signature: sha256=<hex> header derived from the auth secret. Before running the opt-in migration script, switch your receiver to parse the new t=<ts>,v1=<hex> format. Once migrated, rotate the legacy auth secret to ensure signing and auth are fully decoupled.