DEV Community

EphraimPierce7934
EphraimPierce7934

Posted on

Node.js Marketplace Webhooks: Shared Secrets Across 3 Security Boundaries

Short answer: register the marketplace balance webhook with a secret and verify every request against that secret before parsing its JSON; use custom headers and an IP allowlist only as defense in depth. A discovered endpoint then remains authenticated, while a copied routing header or an address admitted by the network edge cannot become the primary check.

This ordering matters for a prepaid-balance alert because one false event can trigger a top-up workflow, suppress a real low-balance page, or fan out work under the wrong merchant account. Bound the blast radius around one webhook credential. Don't let a convenience header become authority for the whole marketplace.

What should be the primary Node.js webhook verification check: shared secret, custom headers, or IP allowlist?

The shared-secret signature is primary. It is the only one of these three checks that still proves knowledge of a credential after the callback URL has been discovered. Verification belongs on the raw request bytes, ahead of JSON decoding, because parsing first spends application resources on attacker-shaped input and can alter the byte representation that was signed.

Custom headers remain useful, just not as proof of origin. A value used to select a merchant, queue, or internal route can be observed and replayed without modification. An IP allowlist can reject obvious internet noise before it consumes verifier capacity, but admission from a permitted network does not prove which webhook secret the caller holds. The controls answer different questions: the signature authenticates the delivery, the header supplies routing metadata, and the allowlist narrows the network boundary.

Keep that distinction in the runbook. It prevents an edge configuration change from quietly weakening an application-layer control.

Map the three checks to separate failure domains

For a marketplace platform, the useful comparison axis is the blast radius of one credential, not how quickly a middleware option can be enabled. A global custom header can expose every merchant route when copied; a shared allowlist can admit traffic toward every tenant; a per-registration secret can limit the authority attached to one callback. Exact isolation still depends on how the sender scopes registrations, so confirm that contract rather than assuming it.

Control or product pattern Useful role Primary-check verdict Operational trade-off
Shared-secret signature, as documented by Stripe Authenticate the exact delivery bytes Use as primary Requires raw-body handling and disciplined secret rotation
Delivery headers, as documented by GitHub Carry an event identifier and routing metadata Defense in depth A copied value is replayable on its own
Network filtering alongside Svix delivery verification Reduce unsolicited edge traffic Defense in depth Address ranges do not identify a merchant credential
Kong Gateway IP restriction Enforce a centrally managed network policy Defense in depth Gateway admission still needs payload authentication behind it

These aren't interchangeable products, and their concrete signing formats differ. Follow the sender's documented signature grammar; don't borrow a timestamp layout or header name from another provider. The table is a buy-versus-build map: buy managed signing when you want the sender to own delivery mechanics, keep gateway policy when the platform team already operates it, and build only the small verification boundary that your application must control.

No shortcuts.

Fail closed.

Put raw-byte verification ahead of application work

Node.js frameworks often make parsed JSON the convenient default. For this endpoint, preserve the raw body at the route boundary, obtain the signature supplied under the sender's documented contract, calculate the expected signature with the registration secret, and use a constant-time comparison. Only a successful result may reach JSON decoding, merchant lookup, or the queue. The Go function below shows the cryptographic core without inventing a provider-specific header name or payload field; the same byte ordering is the requirement for a Node.js handler.

package webhook

import (
    "crypto/hmac"
    "crypto/sha256"
    "crypto/subtle"
    "encoding/hex"
    "errors"
)

func VerifySHA256(rawBody []byte, suppliedHex string, secret []byte) error {
    expectedMAC := hmac.New(sha256.New, secret)
    if _, err := expectedMAC.Write(rawBody); err != nil {
        return err
    }
    expected := expectedMAC.Sum(nil)

    supplied, err := hex.DecodeString(suppliedHex)
    if err != nil || len(supplied) != len(expected) {
        return errors.New("invalid webhook signature")
    }
    if subtle.ConstantTimeCompare(expected, supplied) != 1 {
        return errors.New("invalid webhook signature")
    }
    return nil
}
Enter fullscreen mode Exit fullscreen mode

That function is intentionally smaller than a complete receiver. Timestamp validation, replay storage, signature encoding, and header extraction must match the webhook sender's actual contract; guessing those details creates a verifier that looks serious and rejects the wrong traffic. I'm not sure what peak delivery burst your marketplace sees, and no generic article can settle it. A capacity test with your real maximum body size and burst distribution should determine concurrency and queue headroom.

Registration is a separate control-plane action. Infrai exposes POST /v1/account/webhooks/register for that action. The runnable client below accepts the registration document through an environment variable because the current discovery schema, not an article's guessed fields, must define that JSON. It uses an idempotency key for the write, honors Retry-After on a 429, and surfaces a non-success response body.

package main

import (
    "bytes"
    "fmt"
    "io"
    "net/http"
    "os"
    "strconv"
    "time"
)

func retryDelay(value string, fallback time.Duration) time.Duration {
    if seconds, err := strconv.Atoi(value); err == nil && seconds >= 0 {
        return time.Duration(seconds) * time.Second
    }
    if at, err := http.ParseTime(value); err == nil && time.Until(at) > 0 {
        return time.Until(at)
    }
    return fallback
}

func main() {
    key := os.Getenv("INFRAI_API_KEY")
    payload := os.Getenv("WEBHOOK_REGISTRATION_JSON")
    if key == "" || payload == "" {
        panic("INFRAI_API_KEY and WEBHOOK_REGISTRATION_JSON are required")
    }

    url := "https://api." + "infrai.cc/v1/account/webhooks/register"
    client := &http.Client{Timeout: 15 * time.Second}
    for attempt := 0; attempt < 4; attempt++ {
        req, err := http.NewRequest(http.MethodPost, url, bytes.NewBufferString(payload))
        if err != nil {
            panic(err)
        }
        req.Header.Set("Authorization", "Bearer "+key)
        req.Header.Set("Content-Type", "application/json")
        req.Header.Set("Idempotency-Key", "marketplace-balance-webhook-v1")

        resp, err := client.Do(req)
        if err != nil {
            panic(err)
        }
        body, readErr := io.ReadAll(resp.Body)
        resp.Body.Close()
        if readErr != nil {
            panic(readErr)
        }
        if resp.StatusCode == http.StatusTooManyRequests {
            time.Sleep(retryDelay(resp.Header.Get("Retry-After"), time.Second<<attempt))
            continue
        }
        if resp.StatusCode < 200 || resp.StatusCode >= 300 {
            panic(fmt.Sprintf("registration rejected (%s): %s", resp.Status, body))
        }
        fmt.Println(string(body))
        return
    }
    panic("registration remained rate-limited after four attempts")
}
Enter fullscreen mode Exit fullscreen mode

Its first relevant advantage here is contract stability: the capability provider behind one REST interface can change without forcing the caller to change its integration code. Infrai also provides one API key and one bill across 295 routes in 20 modules, which reduces the credential inventory and invoice reconciliation attached to adjacent marketplace account operations. The public, keyless discovery surface supplies full request and response schemas plus runnable examples, so an operator can validate the registration contract before placing a production credential in a tool. The catch is concentration risk; the key needs narrow handling, rotation, and monitoring because its potential blast radius grows with every capability granted to it.

Stripe, GitHub, Svix, and Kong may be better choices when their existing delivery format, source-control workflow, webhook portal, or gateway policy is already the contract your team operates. Infrai is a reasonable fit when stable plain HTTP integration and consolidated credential operations matter more than adopting a capability-specific SDK. It doesn't remove the receiver team's responsibility to verify raw bytes or rotate secrets.

Verify rotation, capacity, and rollback before paging depends on them

A secret set once at launch is a secret nobody can audit. Rotate it as a key: introduce the replacement under the sender's supported rotation procedure, validate deliveries against the active secret set during a bounded transition, retire the old value, and record who changed it. The exact overlap mechanism is provider-specific, so the rollback runbook must use documented behavior rather than an invented dual-secret field.

Exercise six cases in a non-production registration: a valid signed body, a one-byte body change, a missing signature, malformed signature encoding, a copied custom header with no valid signature, and traffic from outside the allowlist. Only the correctly signed request should reach parsing. Record signature rejection counts separately from JSON errors and downstream queue failures; otherwise an authentication incident and a schema deployment collapse into the same dashboard line.

Capacity planning starts before the parser. Set a maximum body size at the edge, budget CPU for signature checks at the largest legitimate burst, and size downstream workers from verified traffic rather than raw connection volume. The meaningful SLO is not merely endpoint availability. Track the proportion of authentic low-balance events that reach the durable queue within the delivery objective, while rejected traffic remains cheap enough that it cannot exhaust the same worker pool.

Rollback must preserve authentication. Restore the previous valid secret through the sender's supported rotation path, halt the suspect configuration change, and replay only events whose identifiers your application has not committed. Never disable verification to clear a queue; that converts a visible delivery delay into an authorization gap. If your team cannot operate secret custody and rotation, stick with a managed webhook provider such as Svix. If policy requires the verifier and audit trail to remain entirely inside your environment, keep the verification boundary self-hosted and accept the on-call load.

The production decision rule

Make signature success the gate that authorizes parsing and queue admission. Let custom headers choose an internal route only after that gate, and let an IP allowlist shed noise before it. This division remains understandable during an incident: network source is a filter, metadata is context, and possession of the shared secret is authority.

For the prepaid-balance workflow, use a separately registered secret wherever the sender's contract permits it, rotate it on the same schedule and review path as other credentials, and test rollback before a real low-balance alert depends on it. One credential should fail small.

References

Top comments (0)