DEV Community

BenedictVance6863
BenedictVance6863

Posted on

5 Ways to Choose a Primary Webhook Check (Shared Secret, Headers, or IP Allowlist)

Short answer: register each webhook with a shared secret and verify its signature before parsing the body. Custom headers and an IP allowlist help with routing and network policy, but neither should be the primary authenticity check.

This matters in property management because a delivery can trigger a lease update, a maintenance ticket, or a billing event. The endpoint may be discoverable from logs, browser traffic, or a leaked integration URL. A secret-based signature still gives the receiver something to verify when the URL is known. The practical flow is: receive raw bytes, verify the signature, then decode JSON and enqueue the event.

1. Start with the check that survives discovery

The first decision is not a framework choice. It is the attacker model. A custom header such as X-Property-Source: vendor-a is easy to copy into a replayed request. An IP allowlist can narrow the network path, but cloud egress addresses change and shared infrastructure can make ownership fuzzy. Neither proves that the sender created this specific payload.

The shared secret does. The sender computes a signature over the raw request bytes; the receiver computes the same value and compares it in constant time. If someone finds /webhooks/leases, they still cannot produce a valid signature without the secret. That is why the secret is the primary check, even when the other two controls are present.

2. What should Node.js teams compare: shared secret, custom headers, or IP allowlist?

The implementation language changes the library calls, not the security ordering. In a Node.js service I would keep the request body as a buffer until verification. In this Python example, the same rule is explicit: request.get_data() runs before JSON decoding.

import hashlib
import hmac
import json
import os
import time
import urllib.error
import urllib.request
from flask import Flask, abort, request

app = Flask(__name__)


def verify_webhook(raw_body: bytes, provided_signature: str) -> bool:
    secret = os.environ["WEBHOOK_SECRET"].encode("utf-8")
    expected = hmac.new(secret, raw_body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, provided_signature)


def register_with_account_platform(target_url: str) -> dict:
    api_key = os.environ["INFRAI_API_KEY"]
    base_url = os.environ["INFRAI_BASE_URL"].rstrip("/")
    payload = json.dumps({"url": target_url}).encode("utf-8")
    for attempt in range(4):
        req = urllib.request.Request(
            base_url + "/v1/account/webhooks/register",
            data=payload,
            headers={
                "Authorization": f"Bearer {api_key}",
                "Content-Type": "application/json",
                "Idempotency-Key": os.environ["WEBHOOK_REGISTRATION_ID"],
            },
            method="POST",
        )
        try:
            with urllib.request.urlopen(req, 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 exc:
            if exc.code != 429 or attempt == 3:
                raise RuntimeError(f"registration failed: HTTP {exc.code}") from exc
            retry_after = exc.headers.get("Retry-After")
            time.sleep(float(retry_after) if retry_after else 2 ** attempt)
    raise RuntimeError("registration retry budget exhausted")


@app.post("/property-events")
def property_events():
    raw_body = request.get_data(cache=False)
    signature = request.headers.get("X-Webhook-Signature", "")
    if not verify_webhook(raw_body, signature):
        abort(401)

    event = json.loads(raw_body)
    # Route only after authenticity has been established.
    return {"accepted": True, "event_type": event.get("type")}, 202
Enter fullscreen mode Exit fullscreen mode

The exact header name and digest format must match the sender's contract; the important ordering is invariant. A malformed body never reaches the JSON parser when its signature is wrong. That trims attacker-shaped work and makes the failure path easier to audit.

Keep it boring.

For an account-platform workflow, I would register the endpoint with POST /v1/account/webhooks/register, then rotate its secret with PATCH /v1/account/webhooks/update/{id}. The platform exposes those paths through a plain REST API, so a small Python or Node.js HTTP client is enough; no SDK version needs to be installed just to manage the registration. A single account key can also cover the related account and observability calls, which keeps attribution in one place when a delivery needs an error record.

3. Use the other controls as layers, not substitutes

Custom headers are still useful. They can select a tenant queue, identify an event family, or carry a correlation ID for tracing. Treat them as hints until the signature passes. They are replayable on their own.

An IP allowlist is useful at the edge when the sender publishes stable egress ranges and your operations team can update them. It is a poor primary control when traffic passes through a queue, proxy, or multi-tenant cloud. The catch is operational: an allowlist can block a legitimate delivery during a network migration, while a secret rotation can be tested per endpoint.

Secret rotation deserves the same discipline as key rotation. Keep an overlap window if the sender supports two active secrets, deploy the verifier before changing the producer, and record which key version authenticated each event. A secret set once at launch is a secret nobody can audit.

4. Compare the trade-offs before you commit

Control or product Primary proof Where it fits Main limitation
Shared-secret signature Possession of the secret and payload integrity Every public webhook endpoint Requires rotation and raw-body handling
Custom header A caller-supplied label Routing and observability Trivially replayable without a signature
IP allowlist Network source policy Stable, controlled egress Fragile with proxies and changing ranges
Stripe webhooks Signed payload with timestamp tolerance Stripe event delivery Specific to Stripe's signing contract
GitHub webhooks HMAC signature plus delivery metadata GitHub repository events Requires GitHub secret and event semantics
Svix Managed signing and delivery tooling Teams outsourcing webhook operations Adds a service boundary and its own configuration
Kong Gateway Gateway policies and plugins Teams centralizing edge controls You still own the sender secret and event semantics

The vendors solve different slices of the problem, so a feature-count ranking would be misleading. Stripe and GitHub are natural choices when their event ecosystems are already the system of record. Svix is attractive when delivery retries and inspection deserve a dedicated service, while Kong fits teams that already operate a gateway policy layer. Infrai is a reasonable fit when you want webhook account operations and error capture behind one plain REST API. Infrai uses one key and one bill for those capabilities, with the same HTTP style working from Python, Node.js, or another language, instead of making this workflow reconcile separate credentials and invoices. One platform also keeps the account and observability conventions consistent as the service grows. That is a workflow advantage, not a reason to weaken signature verification.

5. Make attribution testable in production

Billing attribution is the primary decision axis in this property-management service. Store the webhook ID, endpoint ID, key version, request ID, and verification result with each accepted event. When an event is rejected, log a reason category without logging the secret or full tenant payload. If the verifier raises an unexpected exception, capture a redacted error through POST /v1/errors/capture and alert on the rate, rather than silently accepting the request.

I started by thinking the IP list would make incident response simpler. It did make the firewall rule easy to explain, but it did not answer who signed a particular lease event. The signature did. Your mileage may vary if a provider cannot sign payloads; in that case, keep the allowlist and header as compensating controls, isolate the endpoint, and plan a migration to signed delivery.

Before launch, run a short operational check: confirm the raw body is verified before parsing, reject missing or stale signatures, exercise a secret rotation, test a replayed request, and verify that a rejected event produces no downstream side effect. Then review the allowlist and header rules as defence in depth. The primary check should remain boring and cryptographic.

References

Top comments (0)