Short answer: Use a shared-secret signature as the primary check in Node.js; treat custom headers as metadata and an IP allowlist as a coarse outer filter.
When a customer-support platform is recovering from an outage, the first webhook check should prove that the sender knows a shared secret. A custom header can help route and observe the request, and an IP allowlist can discard obvious noise, but neither one proves message authenticity. For billing attribution, verify a signature over the exact request bytes before you parse or enqueue anything.
That ordering is the useful answer: cryptographic proof first, metadata second, network filtering third. Keep all three when they fit your provider, but give them different jobs.
The before-and-after model for a support event
Imagine a ticket system sending invoice.paid and refund.created events to a Node.js service. The service attaches each event to an account, records the billing action, and acknowledges quickly so retries do not multiply charges. During an outage, events may arrive late, out of order, or more than once. A forged event that passes a weak check is worse than a delayed one: it can assign a refund to the wrong account while every dashboard says the request was accepted.
The fragile model is a single gate: “the request has our special header” or “the source address is on our list.” Those are useful signals, not proof. Headers can be copied. Source addresses can be shared by a proxy, NAT, or compromised worker.
The safer model has three layers:
- Shared secret signature: compute and compare a MAC over the raw body, timestamp, and provider-defined context.
- Custom headers: carry an event id, schema version, and delivery timestamp for deduplication and diagnostics.
- IP allowlist: reject traffic from networks you do not expect, as an inexpensive outer filter.
The first layer decides authenticity. The next two reduce abuse and operational confusion.
Should Node.js use a shared secret before custom headers or an IP allowlist?
Yes. Make the shared-secret check the primary decision, then apply freshness and replay rules, and only then hand the event to business code. The exact order of the outer filters can vary, but none should replace signature verification.
Here is a compact receiver. It deliberately reads the raw body first; parsing JSON and then re-serializing it can change whitespace or key order and invalidate a legitimate signature. The example assumes a provider sends x-hook-signature as sha256=<hex> and x-hook-timestamp as Unix seconds. Confirm those conventions in the provider contract before shipping.
import { createHmac, timingSafeEqual } from "node:crypto";
import type { IncomingMessage, ServerResponse } from "node:http";
const MAX_SKEW_SECONDS = 300;
function readRaw(req: IncomingMessage): Promise<Buffer> {
return new Promise((resolve, reject) => {
const chunks: Buffer[] = [];
req.on("data", (chunk: Buffer) => chunks.push(chunk));
req.on("end", () => resolve(Buffer.concat(chunks)));
req.on("error", reject);
});
}
function equalHex(expected: string, received: string): boolean {
const left = Buffer.from(expected, "hex");
const right = Buffer.from(received, "hex");
return left.length === right.length && timingSafeEqual(left, right);
}
export async function handleWebhook(
req: IncomingMessage,
res: ServerResponse,
secret: string,
): Promise<void> {
const raw = await readRaw(req);
const signature = req.headers["x-hook-signature"];
const timestamp = req.headers["x-hook-timestamp"];
const eventId = req.headers["x-hook-id"];
if (typeof signature !== "string" || typeof timestamp !== "string" || typeof eventId !== "string") {
res.writeHead(400).end("missing webhook headers");
return;
}
const seconds = Number(timestamp);
if (!Number.isFinite(seconds) || Math.abs(Date.now() / 1000 - seconds) > MAX_SKEW_SECONDS) {
res.writeHead(401).end("stale webhook");
return;
}
const signed = `${timestamp}.${raw.toString("utf8")}`;
const digest = createHmac("sha256", secret).update(signed).digest("hex");
const supplied = signature.replace(/^sha256=/, "");
if (!/^[0-9a-f]+$/i.test(supplied) || !equalHex(digest, supplied)) {
res.writeHead(401).end("invalid webhook signature");
return;
}
// The event id is now trusted enough to use as an idempotency key.
// Persist it before applying a billing mutation.
const event = JSON.parse(raw.toString("utf8")) as { type: string; accountId: string };
await enqueueOnce(eventId, event);
res.writeHead(202).end();
}
async function enqueueOnce(id: string, event: unknown): Promise<void> {
// Replace with a durable insert whose unique key is id.
void id;
void event;
}
The important details are easy to miss. Compare fixed-length digests with a constant-time function. Bind the timestamp into the signed message, then reject old deliveries. Store the event id under a unique constraint before changing an invoice. A valid signature authenticates the sender; it does not make the event new, ordered, or semantically correct.
What do custom headers and IP rules actually prove?
Headers are excellent operational metadata. An event id lets the consumer deduplicate a retry. A schema version lets it choose a decoder. A delivery timestamp gives an alert something concrete to print. None of those values is secret, so an attacker who can reach the endpoint can copy them unless they are covered by the signature.
An IP allowlist is a coarse network boundary. It can lower background noise and reduce the work your application does during a flood. It also has a maintenance cost: providers may add egress ranges, route through a CDN, or publish IPv6 addresses. Behind a reverse proxy, the TCP peer is the proxy, not the sender, so trusting an arbitrary x-forwarded-for header turns the filter into user input. Only a proxy you control should rewrite and authenticate that header.
This is why an allowlist belongs outside the primary decision, and why a header-only design is not a substitute for a MAC. The checks answer different questions: “can this network reach me?”, “which delivery is this?”, and “does the sender know the secret for this body?”
A failure drill for billing attribution
Run the receiver through a small matrix before an outage makes the decision for you. Send the same payload twice with the same id, alter one byte without changing the signature, move the timestamp beyond the five-minute window, and replay a valid request from an unapproved test address. The expected outcomes should be boring: one durable enqueue, one duplicate acknowledgement, one signature rejection, one stale rejection, and one network rejection.
The logs should expose the reason without exposing the secret or full customer payload. Record the event id, account id after validation, signature result, timestamp skew, source classification, and a correlation id. Count accepted, duplicate, stale, invalid, and blocked deliveries separately. During recovery, those counters tell you whether missing billing records come from delivery delay, replay protection, or a bad account mapping.
A test harness can report “signature mismatch” because middleware has already consumed and reformatted the JSON. The fix isn't a new algorithm. Move verification ahead of parsing and keep the original bytes. That small ordering mistake is exactly the kind of issue a pre-outage drill catches.
Three minutes of testing can save a week of reconciling invoices.
Where this pattern is not suitable
The catch is secret distribution. If every tenant needs an independent signing key, a single global secret is not suitable; use a key id in the signed header and rotate per tenant. If your provider cannot sign the body at all, an allowlist plus mTLS or a private link may be the stronger available boundary, but document that it proves channel access rather than payload origin.
This pattern also does not solve authorization inside the event. A correctly signed refund.created message can still refer to a closed account or an amount outside policy. Keep schema validation, account lookup, and business invariants after authentication. Stick with a queue and a reconciliation job when your system cannot apply billing changes idempotently; acknowledging a webhook before durable storage only hides loss.
Top comments (0)