Signature Verification
Verify webhook requests originate from First Leeward.
Every webhook is signed with HMAC-SHA256 using your project's signing secret. Verify the signature before processing any event.
Signature format
The X-Leeward-Webhook-Signature header looks like this:
t=1709312400,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8f9
t is a Unix timestamp (seconds). v1 is the hex-encoded HMAC-SHA256 signature.
How to verify
Parse the header
Split X-Leeward-Webhook-Signature on ,. Extract the t= timestamp and v1= signature.
Build the signed payload
Concatenate timestamp and raw request body with a . separator: {timestamp}.{raw_body}
> Use the raw request body, not a re-serialized version. Re-stringifying JSON changes whitespace and key order, which breaks verification.
Compare signatures
Compute HMAC-SHA256 of the signed payload using your secret. Compare the hex digest to v1 using constant-time comparison.
Check timestamp freshness
Reject requests where the timestamp is more than 5 minutes old to prevent replay attacks.
JavaScript example
import { createHmac, timingSafeEqual } from "crypto";
const WEBHOOK_SECRET = process.env.LEEWARD_WEBHOOK_SECRET;
const TOLERANCE_SECONDS = 300;
function verifyWebhook(rawBody, signatureHeader) {
const parts = signatureHeader.split(",");
let timestamp;
let signature;
for (const part of parts) {
const [key, value] = part.split("=", 2);
if (key === "t") timestamp = parseInt(value, 10);
if (key === "v1") signature = value;
}
if (!timestamp || !signature) {
throw new Error("Invalid signature header");
}
const now = Math.floor(Date.now() / 1000);
if (Math.abs(now - timestamp) > TOLERANCE_SECONDS) {
throw new Error("Timestamp outside tolerance");
}
const signedPayload = `${timestamp}.${rawBody}`;
const expected = createHmac("sha256", WEBHOOK_SECRET)
.update(signedPayload)
.digest("hex");
const expectedBuf = Buffer.from(expected, "hex");
const signatureBuf = Buffer.from(signature, "hex");
if (
expectedBuf.length !== signatureBuf.length ||
!timingSafeEqual(expectedBuf, signatureBuf)
) {
throw new Error("Invalid signature");
}
return JSON.parse(rawBody);
}
Express.js example
Capture the raw body before JSON parsing:
import express from "express";
const app = express();
app.use("/webhooks", express.raw({ type: "application/json" }));
app.post("/webhooks", (req, res) => {
try {
const event = verifyWebhook(
req.body.toString("utf-8"),
req.headers["x-leeward-webhook-signature"]
);
// process event
res.sendStatus(200);
} catch (err) {
res.status(400).json({ error: err.message });
}
});
Security notes
- Always use constant-time comparison (
timingSafeEqual). String equality leaks timing information. - 5 minutes is the recommended timestamp tolerance.
- Keep your signing secret safe. If it leaks, rotate it via Rotate the signing secret. The previous value is invalidated immediately.
