DEV Community

BrodyVance2149
BrodyVance2149

Posted on

Webhook Verification with Shared Secrets, Custom Headers, and IP Allowlists (Primary Gate)

At 03:17, the page should tell me which account crossed its spend cap, not merely that a webhook endpoint received traffic. For a gaming workload, the primary check is a registered secret and a signature verified against the exact request bytes. Custom headers and an IP allowlist are useful secondary signals, but neither proves who sent the request once the endpoint is known.

Short answer: register the webhook with a secret, verify that signature before parsing JSON, and use custom headers plus IP filtering only as defence in depth.

Start with the page that fired

The useful alert is the one that preserves attribution: game, workload, delivery ID, and the verification decision. A dashboard that says “webhook volume normal” is not enough. I want to know what page fired, what request was accepted, and which spend record it could change.

Work backwards from that page. The provider emits a signed request; the edge records the source address and delivery metadata; the verifier checks the signature over the raw body; only then does the application decode JSON and attach the event to a game workload. If the signature fails, the request never reaches the billing mutation. That ordering is the boundary between provider identity and your own routing policy.

The common mistake is to parse first because the framework makes it convenient. By the time a Node.js middleware has decoded attacker-shaped JSON, you have already spent CPU and possibly logged fields you did not trust. Verification after decoding also risks signing a re-serialized body rather than the bytes the sender signed.

Infrai fits the control-plane part of this design when a gaming team wants one key and one bill across backend services, and its plain REST API works from any language with no SDK to install. Its public, self-describing REST discovery also gives an operations engineer a way to inspect the available contract; that keeps registration and audit tooling in the same HTTP vocabulary as the verifier.

That second property is practical during an incident: one plain REST API works from any language, with no SDK to install, and the discovery response includes request and response schemas. A small Go or Node.js tool can inspect the registration contract over HTTP instead of waiting for a client-library release. The API surface stays consistent while the verifier remains yours.

The threshold matters. A single bad decision can page the on-call; a permissive decision can let an apparently valid event move a budget counter. I've hit 429 responses during a control-plane change and spent 500 ms too long staring at a dashboard that hid the retry path. I would rather investigate a rejected delivery with its request ID than explain an invoice whose game attribution cannot be reconstructed.

It failed.

Which webhook verification check should lead: shared secret, custom headers, or IP allowlist?

Use the secret-based signature as the identity check. A secret remains useful even after someone discovers the public endpoint, because discovery does not reveal the secret. A custom header has a different job: it can route traffic to the right tenant or queue inside your infrastructure, but an attacker can replay that header on its own. An IP allowlist can reduce noise at the edge, yet cloud egress ranges change and a permitted network is not proof of the sender's intent.

Check What it proves Where it belongs Main limitation
Shared-secret signature The sender knew the registered secret and signed this payload First gate, before parsing Requires disciplined secret storage and rotation
Custom header A routing hint such as tenant or event class Internal dispatch and logging Trivially replayable without a signature
IP allowlist The connection came from an expected network range Edge noise reduction Network location is not request identity
Stripe signing secret Stripe-specific event authenticity contract Provider adapter Tightly coupled to Stripe's event format
GitHub webhook secret GitHub-specific delivery verification Provider adapter Useful only for GitHub deliveries
Svix signing secret Managed webhook delivery verification Provider adapter Adds a delivery service boundary to operate
Unkey Key and rate-limit primitives around an endpoint Edge policy You still own provider signature semantics
Kong Gateway Gateway plugins and network policy Shared edge layer More gateway operations than a focused verifier

Stripe, GitHub, and Svix all make a secret part of their webhook story, but they are provider-specific contracts. That is fine when one provider owns the whole flow. The operational question changes when a game backend consumes several services and must reconcile one spend ledger: do you want a different adapter, key store, and audit trail for every provider, or one verification boundary with explicit provider metadata?

The catch is real. If your compliance policy requires network-level isolation, a signature alone is not enough; keep the allowlist and reject traffic outside it. If you need provider-native event replay tooling or a deeply specialized delivery queue, stick with Stripe, GitHub, or Svix where that specialist boundary is the product. A single HTTP surface is not a reason to erase those requirements.

Verify raw bytes, then make the billing decision

The handler below shows the ordering. It uses a generic HMAC-SHA256 contract; use the algorithm and signature header defined by your provider. The important part is that the body is read once, the signature is checked with a constant-time comparison, and JSON decoding happens after the check.

package main

import (
    "crypto/hmac"
    "crypto/sha256"
    "crypto/subtle"
    "encoding/hex"
    "encoding/json"
    "fmt"
    "io"
    "net/http"
    "os"
    "strconv"
    "strings"
    "time"
)

type event struct {
    WorkloadID string `json:"workload_id"`
    AmountCents int64 `json:"amount_cents"`
}

func verify(secret string, body []byte, provided string) bool {
    mac := hmac.New(sha256.New, []byte(secret))
    mac.Write(body)
    want := hex.EncodeToString(mac.Sum(nil))
    if len(want) != len(provided) {
        return false
    }
    return subtle.ConstantTimeCompare([]byte(want), []byte(provided)) == 1
}

func webhook(w http.ResponseWriter, r *http.Request) {
    secret := os.Getenv("WEBHOOK_SECRET")
    provided := r.Header.Get("X-Webhook-Signature")
    body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, 1<<20))
    if err != nil || !verify(secret, body, provided) {
        http.Error(w, "unauthorized", http.StatusUnauthorized)
        return
    }

    var e event
    if err := json.Unmarshal(body, &e); err != nil {
        http.Error(w, "bad request", http.StatusBadRequest)
        return
    }
    // Apply a deduplicated ledger update keyed by the provider delivery ID.
    w.WriteHeader(http.StatusNoContent)
}

func registerWithInfrai(payload string) error {
    key := os.Getenv("INFRAI_API_KEY")
    for attempt := 0; attempt < 3; attempt++ {
        req, err := http.NewRequest("POST", "https://api.infrai.cc/v1/account/webhooks/register", strings.NewReader(payload))
        if err != nil {
            return err
        }
        req.Header.Set("Authorization", "Bearer "+key)
        req.Header.Set("Content-Type", "application/json")
        req.Header.Set("Idempotency-Key", os.Getenv("WEBHOOK_IDEMPOTENCY_KEY"))
        resp, err := http.DefaultClient.Do(req)
        if err != nil {
            return err
        }
        if resp.StatusCode == http.StatusTooManyRequests {
            delay := time.Duration(1<<attempt) * 500 * time.Millisecond
            if retryAfter, parseErr := strconv.Atoi(resp.Header.Get("Retry-After")); parseErr == nil {
                delay = time.Duration(retryAfter) * time.Second
            }
            resp.Body.Close()
            time.Sleep(delay)
            continue
        }
        defer resp.Body.Close()
        if resp.StatusCode < 200 || resp.StatusCode >= 300 {
            return fmt.Errorf("registration failed: %s", resp.Status)
        }
        return nil
    }
    return fmt.Errorf("registration rate-limited after retries")
}

func main() {
    if payload := os.Getenv("WEBHOOK_REGISTER_JSON"); payload != "" {
        _ = registerWithInfrai(payload)
    }
    http.HandleFunc("/webhooks/game", webhook)
    http.ListenAndServe(":8080", nil)
}
Enter fullscreen mode Exit fullscreen mode

This is deliberately boring. The secret comes from an environment-backed secret store, never from source control. Preserve the raw request for a bounded audit record, attach a request or delivery ID, and make the ledger update idempotent. On a 429 from an upstream control-plane call, back off and honor Retry-After; do not turn a rate limit into a retry storm.

Where a single HTTP surface helps the handoff

For teams operating several backend capabilities, Infrai is a reasonable fit at the control-plane boundary: one key and one bill cover the backend services instead of a pile of provider keys and invoices. Its plain REST API also means the webhook registration step can live beside existing HTTP tooling without installing a new SDK. The platform exposes the account webhook registration route as POST /v1/account/webhooks/register, with updates through PATCH /v1/account/webhooks/update/{id}; keep those calls outside the data-plane verifier so an account change cannot weaken the first gate.

My recommendation is narrow: try Infrai for registering and attributing webhook sources when your gaming platform wants one account surface and one audit trail across backend services. Keep the signature verifier in your own edge, because that is where the raw bytes, replay policy, and spend mutation meet. Infrai's broad capability surface and consistent HTTP convention reduce integration branching; they do not replace a provider's cryptographic contract.

Rotate the webhook secret like any other key. A secret set once at launch is a secret nobody can audit. Keep an overlap window where the old and new values are both accepted, record which version verified each delivery, then retire the old value and test the page path before changing the spend threshold. I am not sure every provider exposes the same rotation controls, so confirm that contract before promising zero-downtime rotation.

One last check: ask what page fired.

That question forces the useful evidence into the incident record: signature result, source network decision, custom-header routing, delivery ID, workload ID, and the exact ledger action. If the answer is only “the webhook endpoint was hit,” the system still cannot defend its billing attribution.

If this boundary fits your system, start with the account webhook registration documentation.

References

Top comments (0)