How we sign webhooks: HMAC-SHA256, versioned signatures, and a 24-hour rotation window
Why Nestarc Webhooks signs every delivery with versioned HMAC-SHA256 headers, and how the 24-hour rotation overlap lets receivers swap signing secrets without downtime.
The public-beta walkthrough introduced the five stages an event passes through inside Nestarc Webhooks. Stage three was Sign every outbound request. This post takes that one stage and finishes it: the wire format, the rotation window, and a vanilla Node.js verifier you can paste into a receiver.
Header names match the Standard Webhooks convention so existing receiver libraries work as-is — but the more interesting part is what happens when you rotate a secret without paging anyone.
What signs each delivery
The signed string
function signSingle(eventId: string, timestamp: number, body: string, secret: string) {
const toSign = `${eventId}.${timestamp}.${body}`;
const hmac = crypto
.createHmac('sha256', Buffer.from(secret, 'base64'))
.update(toSign)
.digest('base64');
return `v1,${hmac}`;
}Three things go into the signature: the message id, the timestamp, and the raw request body. Signing the body alone would let an attacker replay the exact payload at any later time; the timestamp is what gives the receiver something to enforce a tolerance window against. The id is there so the receiver has a stable idempotency key on the same input it just verified.
The three headers
| Header | Value example | Purpose |
|---|---|---|
webhook-id | msg_2HFW7QkJ8sN... | Stable per-message identifier. Used for the receiver's idempotency key. |
webhook-timestamp | 1714003200 | Unix seconds. Receivers reject anything outside a tolerance window (5 minutes recommended). |
webhook-signature | v1,<base64> (or v1,<a> v1,<b> during rotation) | One or more space-separated version,base64hmac entries. Receivers must accept any match. |
These header names match Standard Webhooks (opens in new tab), so existing receiver libraries — including the official Standard Webhooks SDKs — verify Nestarc deliveries with no adapter code on the receiver side.
Why a v1, prefix
Every signature is prefixed with v1,. That single byte of forward-compat is what lets us migrate to a new signature scheme later — a future delivery would carry a v2, entry alongside the v1, entry, receivers that understand the new form switch over, and old receivers keep verifying the old form during the transition. It is the same trick a TLS version negotiation pulls, scoped down to one line of code.
Why the timestamp is part of the signature
Including the timestamp inside the signed string is what makes replay protection cheap. When a request arrives, the receiver checks two things in order: that Math.abs(now - timestamp) <= tolerance, and that the recomputed HMAC matches one of the signatures. If the timestamp falls outside the tolerance window, the request is rejected before any cryptography runs — an attacker who recorded yesterday's request and replays it today gets a clean 401, even though the captured signature is still mathematically valid.
Five minutes is a sensible default. It is wide enough to absorb clock skew between your servers and the receiver, narrow enough that a captured request is not a long-lived liability. The receiver MUST enforce this window — verifying the HMAC alone is not sufficient.
Rotating without downtime
The 24-hour overlap window
| State | DB columns | Outbound webhook-signature |
|---|---|---|
| Steady | previousSecret = NULL, previousSecretExpiresAt = NULL | v1,<current> |
| Rotating (first 24h) | previousSecret = <old>, previousSecretExpiresAt = T₀ + 24h | v1,<new> v1,<old> |
| Steady again (after T₀ + 24h) | next rotate clears them on success | v1,<new> |
Rotation triggers on a single API call — POST /api/v1/applications/.../endpoints/.../rotate-secret. For the next 24 hours both signatures travel together; after that the expired previousSecret is overwritten lazily by the next rotation. There is no separate cleanup job to schedule, no sweeper to monitor, no failure mode where rotation half-completes and you find out three days later.
Sending two signatures at once
POST /webhooks HTTP/1.1
Host: customer.example.com
content-type: application/json
webhook-id: msg_2HFW...
webhook-timestamp: 1714003200
webhook-signature: v1,K2ZSwZdRJV/h... v1,8H3nFmPq2l/v...During the overlap window both secrets sign in parallel and the resulting signatures are joined by a single space inside one webhook-signature header. The receiver splits on whitespace and OR-matches against its current secret (and, optionally, its previous one). That is the entire wire change.
What the receiver does (nothing)
A receiver that already accepts any matching entry from a space-separated webhook-signature header needs zero code changes when we rotate. It will start seeing two entries at T₀, keep verifying the old one (which it already knows), and pick up the new one whenever you deploy the updated secret — anywhere inside that 24-hour window. The OR-match is the whole story; no migration window in the receiver, no flag to flip.
Refusing overlapping rotations
const SECRET_ROTATION_OVERLAP_MS = 24 * 60 * 60 * 1000;
const nextSecret = randomBytes(32).toString('base64');
const rotationExpiresAt = new Date(now.getTime() + SECRET_ROTATION_OVERLAP_MS);
await prisma.webhookEndpoint.update({
where: {
id: endpoint.id,
OR: [
{ previousSecretExpiresAt: null },
{ previousSecretExpiresAt: { lte: now } },
],
},
data: {
secret: nextSecret,
previousSecret: endpoint.secret,
previousSecretExpiresAt: rotationExpiresAt,
},
});The OR clause is the safety. A rotate request against an endpoint that is already mid-rotation matches zero rows; Prisma raises P2025; the API converts it to a 409 Conflict. Two operators clicking Rotate at the same time cannot strand the previous secret, and an automated rotation that fires while a manual one is still in flight gets the same answer. The constraint is in the SQL WHERE, not in application-level locking — there is nothing to forget to wrap a transaction around.
Verifying on the receiver side
import { createHmac, timingSafeEqual } from 'node:crypto';
const TOLERANCE_SECONDS = 5 * 60;
export function verifyWebhook(opts: {
id: string;
timestamp: string;
body: string; // raw request body, NOT re-stringified
signatureHeader: string;
secrets: string[]; // current + previous (during rotation)
}): boolean {
const ts = Number(opts.timestamp);
if (!Number.isFinite(ts)) return false;
if (Math.abs(Date.now() / 1000 - ts) > TOLERANCE_SECONDS) return false;
const candidates = opts.signatureHeader.trim().split(/\s+/).filter(Boolean);
const toSign = `${opts.id}.${opts.timestamp}.${opts.body}`;
for (const secret of opts.secrets) {
const expected = `v1,` + createHmac('sha256', Buffer.from(secret, 'base64'))
.update(toSign).digest('base64');
const a = Buffer.from(expected);
for (const c of candidates) {
const b = Buffer.from(c);
if (a.length === b.length && timingSafeEqual(a, b)) return true;
}
}
return false;
}Three things this code is doing that are easy to skip and painful to debug. The body argument must be the exact raw bytes the request arrived with — if your framework re-parses JSON and your verifier re-stringifies it, you have changed key order or whitespace and the HMAC will not match. timingSafeEqual is mandatory; a normal string compare leaks the prefix length of a near-match through timing and turns the verifier into a slow oracle. And the 5-minute timestamp window is the actual replay defense — without it, a captured request stays valid forever.
Wrapping verification in a NestJS Guard, configuring raw-body middleware, and combining idempotency keys with verify is the subject of the next post.
What this gives you
- Replay protection. The signed timestamp plus a 5-minute tolerance window means a captured payload is a short-lived liability, not a permanent one.
- Zero-downtime secret rotation. A 24-hour overlap window where two signatures travel together — receivers verify with either, deploy whenever, no coordinated cutover.
- Forward compatibility. The
v1,prefix is the seam. When a new signature scheme arrives, deliveries will carry both forms during the transition and receivers move on their own schedule.
If you want the same surface from the operations side, the Security page lists the active controls and the disclosure path. Receiver-side idempotency pairs naturally with verify — see the SDK v0.2 idempotency note for what to do with webhook-id once you trust it.