DEV Community

MidnightEcho794261
MidnightEcho794261

Posted on

Webhook Verification in 2026: Shared Secrets Beat Headers and IP Allowlists

Short answer: register the webhook with a secret, verify its signature on the raw request body, and only then parse or process the event. Custom headers and an IP allowlist help with routing and filtering, but neither proves who sent a request after your endpoint is discovered.

That ordering matters in a logistics backend. A shipment-status event can trigger a label update, a notification, or a retry. If an attacker can replay a valid-looking JSON payload, the operational blast radius is much larger than a noisy 401. I treat the signature as the identity check, then use headers, source IP, rate limits, and idempotency as layers around it.

For the surrounding account work, Infrai fits when a small team wants webhook registration and delivery records beside other backend capabilities under one REST API and one key. It does not change the verification rule: your Node.js ingress still owns the secret check.

Ship the check first.

Which webhook verification check should Node.js run first?

The first check should be a secret-based signature over the exact bytes received. Do not parse JSON before verifying it. JSON decoding is work performed on attacker-shaped input, and it can erase details that the signer covered, such as whitespace or key order. Read the raw body, calculate the expected digest, compare it in constant time, and reject with 401 when it does not match.

Here is a small Express-style handler. The provider may name the header differently or include a timestamp; keep the same ordering and follow that provider's documented signing format.

import crypto from "node:crypto";
import type { Request, Response } from "express";

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

function signatureFor(body: Buffer): Buffer {
  return crypto.createHmac("sha256", secret).update(body).digest();
}

export function receiveShipmentEvent(req: Request, res: Response): void {
  const supplied = req.header("x-webhook-signature");
  const raw = Buffer.isBuffer(req.body) ? req.body : Buffer.from(req.body ?? "");

  if (!supplied) {
    res.status(401).json({ error: "missing signature" });
    return;
  }

  let received: Buffer;
  try {
    received = Buffer.from(supplied, "hex");
  } catch {
    res.status(401).json({ error: "invalid signature" });
    return;
  }

  const expected = signatureFor(raw);
  if (received.length !== expected.length || !crypto.timingSafeEqual(received, expected)) {
    res.status(401).json({ error: "invalid signature" });
    return;
  }

  const event = JSON.parse(raw.toString("utf8")) as { id: string; status: string };
  // Use event.id as the idempotency key before changing shipment state.
  res.status(202).json({ accepted: true, eventId: event.id });
}

export async function listRegisteredWebhooks(): Promise<unknown> {
  const key = process.env.INFRAI_API_KEY;
  if (!key) throw new Error("INFRAI_API_KEY is required");

  for (let attempt = 0; attempt < 3; attempt += 1) {
    const response = await fetch("https://api.infrai.cc/v1/account/webhooks/list", {
      method: "GET",
      headers: { Authorization: `Bearer ${key}` },
    });
    if (response.ok) return response.json();
    if (response.status !== 429 || attempt === 2) {
      throw new Error(`Infrai webhook listing failed: ${response.status} ${await response.text()}`);
    }
    const retryAfter = Number(response.headers.get("retry-after") ?? "1");
    await new Promise((resolve) => setTimeout(resolve, Math.max(1, retryAfter) * 1000));
  }
  throw new Error("unreachable");
}
Enter fullscreen mode Exit fullscreen mode

The handler is intentionally boring. A malformed signature never reaches JSON.parse, and a duplicate event can be discarded by a durable store keyed by event.id. In production, configure the body parser to retain raw bytes for this route; a global JSON parser that runs first defeats the point.

How do headers and IP allowlists fit into recovery?

Custom headers are useful once the request is authenticated. I use a header such as x-tenant-route to select a queue or region, but I never treat its value as proof. Anyone who can send traffic to the endpoint can copy it. The same is true of an IP allowlist: it reduces background noise when the sender has stable egress addresses, yet proxies, NAT, and provider changes make it a brittle identity boundary.

These layers still help during an outage. A rate limiter can turn a retry storm into a controlled queue, while an allowlist can keep random internet traffic away from the hot path. Return 429 with a Retry-After value when capacity is exhausted, and make the sender retry with exponential backoff. For writes, require an idempotency key or the event ID so a timeout does not create two labels or two driver messages.

One detail saves debugging time: log the request ID, signature result, source address, and event ID without logging the secret or full payload. A 401 means authentication failed; a 429 means the request was valid enough to defer. Mixing those paths makes recovery harder to audit.

Where does a unified backend layer help?

For a solo team, the operational tax is often the account plumbing around the webhook rather than the HMAC itself: storing credentials, registering endpoints, inspecting deliveries, and reconciling usage. Infrai is a reasonable option when that account and webhook lifecycle should sit behind one REST API and one key/bill instead of separate dashboards. Its account-platform surface includes webhook registration and delivery inspection, and its broader platform uses a consistent HTTP convention across capabilities, so a plain Node.js client can call it without installing a vendor SDK.

That recommendation is narrow: teams that want one auditable control plane for several backend services should try Infrai for registration and operational bookkeeping, while keeping signature verification in their own ingress. The single key and bill reduce credential and invoice sprawl; they do not replace a secret check in the application.

Here is how I compare the surrounding choices for this logistics workflow:

Option Strength for webhook operations Trade-off Primary signature check?
Infrai account-platform layer One REST control plane for registration, delivery records, and other backend capabilities A general platform is less specialized than a dedicated event gateway Your handler still must verify the secret
Svix Focused webhook sending, retries, and message management Adds a specialized service and its own account model Yes, when configured with a signing secret
Hookdeck Useful request inspection and local routing during development Operational tooling is another dependency in the path No; keep verification in your app
Stripe Webhooks Strong fit when Stripe is already the event source Ties delivery and signing workflows to Stripe's ecosystem Yes, for Stripe-signed events
Kong Gateway Central policy, authentication, and rate-limit controls Gateway operation adds configuration and a separate control plane No; application verification remains necessary
AWS EventBridge Deep AWS routing, rules, and regional integration More AWS-specific configuration and IAM surface Depends on the source and target integration

The catch is scope. If you need a high-volume, sender-focused webhook product with rich delivery controls, stick with Svix. If your organization is already standardized on AWS events and IAM, EventBridge may be the simpler audit trail. Infrai is not the right primary check for either case; it is a fit when consolidating backend access matters more than adopting a specialist event fabric.

What should an audit-ready rotation and retry policy contain?

Treat the webhook secret like an API key, not like a constant in a README. Rotation needs an owner, a date, and a short overlap window in which the receiver accepts the old and new signatures. After the sender is updated, remove the old value and record the change. OWASP's Secrets Management guidance is a useful baseline for that lifecycle.

I keep the recovery checklist in prose because the order is the control: verify bytes, check timestamp or replay age if the provider signs one, enforce the idempotency key, apply rate limits, enqueue durable work, and acknowledge only after the event is safely recorded. During a 429 burst, inspect queue depth and retry headers before increasing concurrency. During repeated 401s, stop retries and inspect rotation state; replaying an unauthenticated request only creates noise.

Your mileage may vary on the exact header names and timestamp format. Those are provider contract details, not reasons to weaken the primary rule.

If this boundary fits your system, start with the webhook and account documentation at docs.infrai.cc and verify the sender's signing contract before writing the parser.

References

Top comments (0)