HMAC signature verification
Make sure every webhook really came from Aly before your system acts on it.
The X-Aly-Signature header format and constant-time verification — Node, Python examples.
Every webhook delivery carries an HMAC-SHA256 signature in the X-Aly-Signatureheader. Verifying it ensures the payload came from Aly, hasn't been tampered with, and isn't a replay older than your tolerance window.
Header format
http
X-Aly-Signature: t=1748112900,v1=4f3c9e...d2t is the unix timestamp (seconds) Aly built the signature.v1 is the hex-encoded HMAC-SHA256 over t + "." + raw_body.
Verification algorithm
- Parse
tand thev1hex digest from the header. - Compute
expected = HMAC_SHA256(secret, "${t}.${raw_body}"). - Compare
v1tohex(expected)with a constant-time compare. - Reject if
|now - t| > 5 minutes.
Use the raw body
Verify against the raw request body bytes, before any JSON parsing or middleware re-serialization. Most signature-mismatch bugs come from a framework that reformats the body before you see it.
Node + Express
webhook.tstypescript
import { createHmac, timingSafeEqual } from "node:crypto";import express from "express"; const SECRET = process.env.ALY_WEBHOOK_SECRET!;const TOLERANCE_SECONDS = 5 * 60; const app = express(); // Capture raw body — Aly verifies against bytes, not re-parsed JSONapp.use( express.raw({ type: "application/json", limit: "1mb" })); app.post("/aly/webhooks", (req, res) => { const header = req.get("x-aly-signature") ?? ""; const parts = Object.fromEntries( header.split(",").map((kv) => kv.split("=")) ); const t = Number(parts.t); const v1 = parts.v1; if (!t || !v1) return res.status(400).end(); // Reject stale or future signatures if (Math.abs(Math.floor(Date.now() / 1000) - t) > TOLERANCE_SECONDS) { return res.status(400).end(); } const raw = (req.body as Buffer).toString("utf8"); const expected = createHmac("sha256", SECRET) .update(`${t}.${raw}`) .digest("hex"); const ok = expected.length === v1.length && timingSafeEqual(Buffer.from(expected), Buffer.from(v1)); if (!ok) return res.status(400).end(); const event = JSON.parse(raw); // ... enqueue event.id for processing ... res.status(200).end();});Python + Flask
webhook.pypython
import hmac, hashlib, timefrom flask import Flask, request, abort SECRET = "whsec_..." # from registrationTOLERANCE = 5 * 60 app = Flask(__name__) @app.post("/aly/webhooks")def webhook(): header = request.headers.get("X-Aly-Signature", "") parts = dict(p.split("=", 1) for p in header.split(",") if "=" in p) try: t = int(parts["t"]) v1 = parts["v1"] except (KeyError, ValueError): abort(400) if abs(int(time.time()) - t) > TOLERANCE: abort(400) raw = request.get_data() mac = hmac.new(SECRET.encode(), f"{t}.".encode() + raw, hashlib.sha256) if not hmac.compare_digest(mac.hexdigest(), v1): abort(400) event = request.get_json(force=True) # ... enqueue event["id"] for processing ... return "", 200Common failure modes
- Body mutated by a middleware — biggest cause. Capture raw bytes before JSON parsing.
- Wrong secret — rotated, or pulled from a different env. Rotate again to confirm.
- Clock skew — receiver behind NTP. Sync, or widen the tolerance.
- Encoding — read body as bytes, hash as bytes. UTF-8 surrogates in product names trip naive string handling.
Replay protection
The timestamp window prevents replays older than 5 minutes. To defend against in-window replay (e.g. an attacker who saw a delivery seconds ago), track event.id in a short-lived set and reject duplicates.
Was this page helpful?