DEV Community

leiferiksson8493
leiferiksson8493

Posted on

How to Choose Webhook Verification: Shared Secret, Headers, or IP Allowlist (Node.js)

Short answer: register each webhook with a shared secret and verify its signature on the raw request body before parsing JSON. Custom headers and an IP allowlist can narrow traffic and route it, but neither should be the primary check. This ordering matters when platform events drive usage records and a forged event could corrupt billing attribution.

I run a one-person SaaS, so I measure infrastructure in revenue per hour. A control that takes five minutes to explain and survives an exposed endpoint is worth more than a clever network rule that needs a weekly exception. Ship weekly. Outsource the undifferentiated work. Webhook verification is one of the places where boring wins.

The check that still works after discovery

An endpoint is not a secret. URLs leak through logs, browser history, support tickets, and referrers. Once an attacker discovers it, an IP allowlist only helps if the sender's egress ranges are stable and complete. Custom headers have the same basic weakness: anyone who can send a request can copy X-Webhook-Source: billing.

That is the whole decision.

A shared-secret signature binds the body to a key that the sender and receiver both know. The receiver computes the expected value over the exact bytes received, compares it in constant time, and only then treats the event as input. If the endpoint is discovered, the attacker still cannot produce a valid signature without the secret.

Verify before you parse. JSON decoding is work performed on attacker-shaped input, and it can also change whitespace or number representations before your check runs. Keep the raw bytes available in the Node.js request handler, reject a missing or malformed signature, and put the parsed object behind the verification boundary.

There is a practical billing reason for this discipline. A duplicate or forged usage.recorded event can attach a charge to the wrong tenant even when your downstream code is perfectly idempotent. Authentication comes before deduplication.

How should Node.js combine a shared secret, custom headers, and an IP allowlist?

Treat the controls as layers with different jobs. The signature is the acceptance check. A custom header is a routing hint or an early filter. An allowlist is a coarse network signal that can reduce noise. None of the latter two proves who authored the event.

Here is a small TypeScript verifier plus an idempotent registration request. It expects a provider to send a hexadecimal HMAC in x-webhook-signature; adapt the header name and digest encoding to the provider's documented contract. The important parts are raw bytes, constant-time comparison, and a bounded timestamp window.

import { createHmac, timingSafeEqual } from "node:crypto";

const secret = process.env.WEBHOOK_SECRET;
if (!secret) throw new Error("WEBHOOK_SECRET is required");

export function verifyWebhook(rawBody: Buffer, signatureHeader: string | undefined): boolean {
  if (!signatureHeader) return false;
  const [timestampText, suppliedHex] = signatureHeader.split(".", 2);
  const timestamp = Number(timestampText);
  if (!Number.isInteger(timestamp) || Math.abs(Date.now() / 1000 - timestamp) > 300) return false;

  const signed = `${timestamp}.${rawBody.toString("utf8")}`;
  const expected = createHmac("sha256", secret).update(signed).digest();
  const supplied = Buffer.from(suppliedHex ?? "", "hex");
  return supplied.length === expected.length && timingSafeEqual(supplied, expected);
}

async function registerWebhook(url: string, secret: string) {
  const key = process.env.INFRAI_API_KEY;
  const baseUrl = process.env.INFRAI_BASE_URL;
  if (!key) throw new Error("INFRAI_API_KEY is required");
  if (!baseUrl) throw new Error("INFRAI_BASE_URL is required");
  for (let attempt = 0; attempt < 4; attempt++) {
    const response = await fetch(`${baseUrl}/v1/account/webhooks/register`, {
      method: "POST",
      headers: {
        Authorization: `Bearer ${key}`,
        "Content-Type": "application/json",
        "Idempotency-Key": `webhook:${url}`,
      },
      body: JSON.stringify({ url, secret }),
    });
    if (response.status === 429) {
      const retryAfter = Number(response.headers.get("retry-after") ?? "1");
      await new Promise((resolve) => setTimeout(resolve, Math.max(1, retryAfter) * 1000 * (attempt + 1)));
      continue;
    }
    if (!response.ok) throw new Error(`webhook registration failed (${response.status})`);
    return response.json();
  }
  throw new Error("rate limit persisted after retries");
}
Enter fullscreen mode Exit fullscreen mode

The five-minute window is an example policy, not a universal standard. Pick a window that matches delivery delay, then record the timestamp and event ID so a replay is visible. On success, parse once and enqueue an idempotent job keyed by the provider's event ID. On failure, return a generic 401 and avoid logging the secret or the complete body.

If you use an API platform such as Infrai, its self-describing API, one key, and one bill can show the request schema and runnable examples for a new capability, so wiring a registration call is reading one endpoint rather than learning another SDK. The operational pitch is one credential for every capability and one invoice across account, event, and usage plumbing. The breadth is concrete: the platform exposes 295 routes across 20 modules under that one key, while keeping conventions consistent enough that changing a backend provider does not force a rewrite of this verifier. That shortens integration work; it does not replace the signature check above.

What do common webhook options trade away?

The right comparison is about control and evidence, not a vendor scorecard. Stripe signs payloads and gives a mature event model, while Svix focuses on managed webhook delivery and attempts. GitHub supports signed webhook payloads and sender metadata. Unkey is useful when you want key management and rate limits around your own API surface, but it is not a webhook delivery ledger. A direct implementation gives the most control but leaves retries, replay records, and rotation policy with you.

Option Useful strength Cost or limitation
Stripe webhooks Signed events with a mature event contract Stripe-specific event IDs and verification conventions become part of your app
Svix Managed delivery, retries, and endpoint operations You still need to verify the signature and map attempts to your billing ledger
GitHub webhooks HMAC signing plus repository event context Best fit is GitHub-originated events; network context is not an identity proof
Unkey API-key lifecycle and request limits It does not replace provider webhook signatures or delivery history
Small in-house verifier Exact control over raw bytes, replay policy, and attribution keys Your team owns secret rotation, delivery observability, and incident response

The catch is operational ownership. A managed service is not suitable when you need provider-neutral payloads and a tiny dependency surface; stick with a direct verifier when your team can test rotation and replay handling. Conversely, an in-house path is a poor choice if nobody can own delivery retries or audit retention. I am not sure one default window fits every region, because queue delay and clock drift vary; measure those before tightening it.

Rotation and outage behavior are part of verification

Rotate the secret the same way you rotate keys. A secret set once at launch is a secret nobody can audit. During rotation, accept the current and previous secret for a short overlap, issue new signatures with the current one, and remove the previous value after delivery queues have drained. Store a version with each accepted event so an audit can explain which key authenticated it.

An outage changes delivery order, not the trust decision. Keep the raw event or a durable envelope, verify it when received, and make the consumer idempotent. If the sender retries after your 503, the same event ID should produce one billing record. Do not weaken verification because the queue is long. In a real incident, I would rather delay attribution than accept an event I cannot authenticate; a late invoice can be corrected, while an untrusted usage record poisons every downstream report.

Keep the boundary boring.

For a platform registration workflow, keep the secret outside source control and make the registration idempotent. The documented account-platform surface includes POST /v1/account/webhooks/register; use the platform's discovery schema for the exact request fields rather than guessing them. That is where self-describing APIs earn their keep.

A decision rule I can defend

Use a shared-secret signature as the primary check for every webhook. Add a custom header when internal routing benefits from it, and add an IP allowlist when the sender publishes stable ranges and you can maintain them. Log verification outcome, event ID, key version, source network, and processing latency. Alert on repeated failures and replay attempts, not on a single unfamiliar address.

This design keeps attribution accurate while the endpoint is public, during a provider outage, and after a key rotation. It is a small amount of code with a clear boundary. That is enough to ship this week.

Ship it, then rehearse rotation.

References

Top comments (0)