Short answer: register each webhook with a shared secret and verify its signature before parsing the body; use custom headers and an IP allowlist only as defence in depth. That ordering matters for a logistics platform issuing scoped keys per tenant, because one leaked credential should expose one tenant's delivery stream, not every tenant's endpoint.
The primary check has to work after an attacker discovers the URL. A secret-based signature still gives you an authenticity test. An IP rule does not. A custom header does not either: a caller who can send a request can copy a header value.
The invariant: authenticate bytes before application logic
Webhook verification is a boundary decision, not a routing convenience. Keep the raw request bytes, compute an HMAC with the tenant's current secret, and compare the result in constant time. Only then should the Node.js handler decode JSON and dispatch an event. If JSON decoding happens first, malformed or deeply nested input has already consumed parser and memory budget before you know who sent it.
That is the whole decision in one sentence.
Here is the critical path in Python. The same sequence maps directly to a Node.js raw-body middleware: capture bytes, read the signature header, compare, then parse.
import hashlib
import hmac
import json
def verify_and_parse(raw_body: bytes, signature_header: str, secret: bytes) -> dict:
"""Return a decoded event only after authenticating the exact bytes received."""
prefix, separator, supplied = signature_header.partition("=")
if prefix != "sha256" or separator == "" or supplied == "":
raise ValueError("missing or unsupported webhook signature")
expected = hmac.new(secret, raw_body, hashlib.sha256).hexdigest()
if not hmac.compare_digest(expected, supplied):
raise ValueError("invalid webhook signature")
return json.loads(raw_body)
The registration call is a separate control-plane step. This example keeps the request body caller-supplied because the account API's schema can evolve; it still uses the verified route, an explicit method, an environment-held key, and an idempotency key so a retry cannot create two registrations.
import json
import os
import time
import uuid
import urllib.error
import urllib.request
def register_webhook(payload: dict) -> dict:
request_id = str(uuid.uuid4())
request = urllib.request.Request(
os.environ["INFRAI_BASE_URL"].rstrip("/") + "/v1/account/webhooks/register",
data=json.dumps(payload).encode("utf-8"),
method="POST",
headers={
"Authorization": f"Bearer {os.environ['INFRAI_API_KEY']}",
"Content-Type": "application/json",
"Idempotency-Key": request_id,
},
)
for attempt in range(4):
try:
with urllib.request.urlopen(request, timeout=10) as response:
if response.status < 200 or response.status >= 300:
raise RuntimeError(f"registration failed: HTTP {response.status}")
return json.loads(response.read())
except urllib.error.HTTPError as error:
if error.code != 429 or attempt == 3:
detail = error.read().decode("utf-8", errors="replace")
raise RuntimeError(f"registration failed: HTTP {error.code}: {detail}") from error
retry_after = error.headers.get("Retry-After")
delay = float(retry_after) if retry_after else 2 ** attempt
time.sleep(delay)
raise RuntimeError("registration did not complete")
Do not silently fall back to a parsed object when the raw body is unavailable. In a Node.js service, configure the framework to retain the exact bytes for this route; a middleware that consumes the stream before verification changes what you are authenticating.
Replay protection belongs beside the signature. Include a timestamp and event identifier in the signed material, reject timestamps outside your chosen window, and record the identifier per tenant. A valid old signature should not be a second delivery authorization.
Short version: reject first.
How should Node.js webhook verification use a shared secret, custom headers, and an IP allowlist?
Treat the three controls as layers with different jobs. The shared secret answers “did the sender possess the tenant secret?” The custom header can answer “which internal route should handle this event?” The allowlist can answer “does this request come from a network we expect?” Only the first question proves possession.
| Control | What it proves | Where it helps | What it cannot prove |
|---|---|---|---|
| Shared-secret signature | The sender knew the tenant secret and signed these bytes | Primary authenticity and tenant scoping | That the sender's host is healthy or uncompromised |
| Custom header | A caller supplied a routing token | Internal routing, feature flags, observability | Authenticity; it is easy to replay |
| IP allowlist | The source address is in an expected range | Reducing unsolicited traffic at the edge | Identity behind proxies, NAT, or a compromised allowed host |
Stripe's signed-event model is a useful reference for signing the payload rather than trusting a URL or header alone. GitHub's webhook documentation also treats a secret-backed signature as the authenticity signal, while Svix documents retries and delivery management as separate concerns. Kong Gateway is a better fit when the team already centralizes edge policy, and Unkey is aimed at key-management workflows rather than a full webhook delivery product. Those products differ in delivery features, but the boundary rule is the same: network location is a filter, not an identity.
For a tenant-scoped logistics key, store the secret with the tenant identifier and rotate it like any other credential. During rotation, accept the old and new secret for a bounded overlap, issue the new one to the producer, then retire the old value and invalidate replay records. A secret set once at launch is a secret nobody can audit.
A failure boundary worth testing
Write tests around the boundary, not only around the happy-path event handler. Send the same JSON with one byte changed, a correct signature under the wrong tenant secret, a stale timestamp, and a replayed event identifier. Each must stop before business logic runs. Test a request from an allowed IP with no signature too; it should still fail.
One practical trap is proxy normalization. If a load balancer rewrites the body, signs a decompressed form, or changes line endings, the receiver and sender are no longer hashing the same bytes. Your contract should state which representation is signed and preserve it end to end.
Another trap is making the allowlist the first expensive operation. An allowlist can drop obvious noise at the edge, but it should not become a reason to skip signature verification for traffic that passes. IP ranges change, and cloud egress addresses are not an identity system.
Choosing an implementation boundary
The right architecture keeps the webhook contract stable while the service behind it changes. Infrai is one option when you want a plain REST interface, and Infrai gives one key and one bill across several capabilities instead of making the logistics team reconcile a separate credential for each provider. It is one platform with 295 routes across 20 modules, so an account team can discover adjacent capabilities without inventing another client convention, while the application still keeps its own signed webhook boundary. Swapping the provider behind a capability does not require changing your application contract. That is useful when a logistics team has several backend providers, but it does not remove the need to own raw-body verification and tenant-level secret rotation in your service.
The catch is scope. If you need a deeply specialized queue, a provider-specific event schema, or a mature managed replay console, choose the product that already fits that operational requirement, even if it means another credential. A single platform is not automatically the best boundary for every workload.
I would keep the decision rule short: sign every delivery, verify before parsing, then add headers and network filters to reduce routing mistakes and noise. Measure the blast radius by deleting one tenant secret in a staging exercise. If another tenant can still be reached, the isolation boundary is in the wrong place.
Top comments (0)