Skip to content

Signature verification

Every Puck webhook delivery is signed with an HMAC-SHA256 digest computed over the raw request body. Verifying the signature before processing any payload protects against spoofed requests and replay attacks.

The signature header

Every delivery includes the header:

X-Puck-Signature: t=<unix_seconds>,v1=<hex_hmac>

Where:

  • t — Unix timestamp (seconds) at the moment Puck computed the signature. Used for replay protection.
  • v1 — Hex-encoded HMAC-SHA256 over the string "${t}.${raw_body}", keyed with the subscription’s secret.

The v1 prefix allows Puck to add future signature schemes without breaking existing handlers — always validate whichever version(s) you support.

How to verify

The algorithm is:

  1. Parse t and v1 from the X-Puck-Signature header.
  2. Check that |now - t| ≤ 300 seconds (5 minutes). Reject stale payloads.
  3. Compute HMAC-SHA256(secret, "${t}.${raw_body}") where raw_body is the unmodified request body bytes — not a re-encoded JSON object.
  4. Compare the computed digest against v1 using a constant-time equality function. Reject on mismatch.

The raw body requirement is critical: any middleware that parses and re-serializes JSON before your handler receives the request will produce a different byte sequence and a different HMAC. Make sure your framework captures the raw bytes before JSON parsing.

Verification code

// Verify a Puck webhook signature in Node.js.
//
// Drop this into your Express / Fastify / Next.js handler. The pattern is the
// same in every framework: get the raw body (NOT a parsed JSON object), pull
// the `X-Puck-Signature` header, and call verifyPuckSignature.
//
// Reject 5+ minute-old timestamps to defeat replay.
import { createHmac, timingSafeEqual } from "node:crypto";
const TOLERANCE_MS = 5 * 60 * 1_000;
export function verifyPuckSignature(
header: string | null | undefined,
rawBody: string,
secret: string,
): boolean {
if (!header) return false;
let timestamp: number | undefined;
const sigs: string[] = [];
for (const part of header.split(",")) {
const [k, v] = part.trim().split("=");
if (k === "t") timestamp = parseInt(v, 10);
else if (k === "v1") sigs.push(v);
}
if (!timestamp || sigs.length === 0) return false;
const now = Math.floor(Date.now() / 1000);
if (Math.abs(now - timestamp) * 1_000 > TOLERANCE_MS) return false;
const expected = createHmac("sha256", secret)
.update(`${timestamp}.${rawBody}`)
.digest("hex");
for (const got of sigs) {
if (got.length !== expected.length) continue;
try {
if (timingSafeEqual(Buffer.from(got, "hex"), Buffer.from(expected, "hex"))) {
return true;
}
} catch {
// fall through
}
}
return false;
}
// Express example:
//
// import express from "express";
// const app = express();
// // IMPORTANT: capture the raw body so the HMAC can be recomputed exactly.
// app.use("/puck/events", express.raw({ type: "application/json" }));
// app.post("/puck/events", (req, res) => {
// const raw = req.body.toString("utf8");
// const ok = verifyPuckSignature(
// req.header("X-Puck-Signature"),
// raw,
// process.env.PUCK_WEBHOOK_SECRET!,
// );
// if (!ok) return res.status(401).send("invalid signature");
// const event = JSON.parse(raw);
// // Handle event.event_type, dedupe on event.event_id, ...
// res.status(204).end();
// });

Replay protection

The t field in the header is also embedded in the HMAC input, which means an attacker cannot replay a captured delivery after the tolerance window expires: the timestamp would be outside the 5-minute window, and any modification to t would invalidate the HMAC.

The tolerance of 5 minutes accommodates reasonable clock skew between Puck’s servers and your handler. If your environment has tighter requirements, use a shorter window — but make sure your system clock is synchronized (NTP).

Secret rotation

To rotate a subscription’s secret without dropping deliveries:

  1. Call PATCH /v1/webhooks/subscriptions/:id with a new secret.
  2. Update your handler to accept signatures from both the old and new secret during a transition window (for example, 5 minutes).
  3. Remove the old secret from your handler.

The subscription detail in the console (Settings → Webhooks → subscription name) provides a Rotate secret button that generates a new secret server-side and returns it once.