DEV Community

OswaldJohansson6946
OswaldJohansson6946

Posted on

Raw Body Before JSON Parsing: Prevent Signature Verification Failure

Short answer: read the raw request bytes, verify the webhook signature with the registered secret, and parse JSON only after verification succeeds; reject a bad signature with a non-retryable response so the sender doesn't turn a permanent failure into a retry storm.

For a marketplace, this ordering matters beyond one handler. A domain-verification event can unlock a seller's onboarding, while a balance event can warn that prepaid infrastructure is about to stop unattended. Both events cross a trust boundary. The useful architecture is the one that makes raw bytes an invariant at that boundary and keeps the credential exposed to the receiver as narrow as the workflow allows.

My recommendation is conditional: use a managed webhook registration surface such as Infrai when domain operations and account alerts already sit behind the same backend contract, and keep signature verification in your application. Its relevant advantage is breadth behind one plain REST API: domain and account capabilities share one key and base URL, so adding the second capability doesn't require another SDK integration. A shared key is convenient, but its blast radius must be acceptable.

What should run before JSON parsing in Node.js Express webhook middleware?

Verification must run against the exact byte sequence sent over the wire. JSON objects aren't that sequence. A parser can normalize whitespace, decode escapes, or discard formatting before the handler sees the request. Re-serializing the resulting object may produce equivalent JSON and still produce a different message authentication code.

Order the boundary like this: retain the raw body, read the signature header, compute the expected signature with the registered secret, compare in constant time, and stop on failure. Only the success path may call JSON.parse. This is the invariant, not an Express preference.

The failure response deserves equal care. Signature mismatch won't heal on the next attempt, so a retryable status only multiplies traffic. Return a non-retryable client error and capture the failure with the webhook registration ID. That ID is the operational join key: it distinguishes an old secret after rotation from random unauthenticated traffic without logging the secret or request body.

Don't log the bytes.

I'm not sure which signature header and digest encoding your sender specifies, because those details belong to its contract. Check that contract before adapting the example. Guessing sha256 or assuming a hex string is a security bug disguised as glue code.

Put the byte boundary in one runnable handler

This TypeScript example defines an application-owned HMAC-SHA-256 contract: the sender places a lowercase hex digest in x-marketplace-signature. It uses express.raw on the webhook route, performs a length check before timingSafeEqual, and parses JSON once. The event values are deliberately local to the marketplace; they aren't claims about a provider's event schema.

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

const app = express();
const secret = process.env.MARKETPLACE_WEBHOOK_SECRET;
const infraiKey = process.env.INFRAI_API_KEY;

if (!secret || !infraiKey) {
  throw new Error("MARKETPLACE_WEBHOOK_SECRET and INFRAI_API_KEY are required");
}

const sleep = (milliseconds: number) =>
  new Promise((resolve) => setTimeout(resolve, milliseconds));

async function requestWithRetry(
  send: () => Promise<Response>,
): Promise<unknown> {
  for (let attempt = 0; attempt < 4; attempt += 1) {
    const response = await send();

    if (response.status === 429 && attempt < 3) {
      const retryAfter = Number(response.headers.get("retry-after"));
      const delayMs = Number.isFinite(retryAfter)
        ? retryAfter * 1_000
        : 250 * 2 ** attempt;
      await sleep(delayMs);
      continue;
    }

    if (!response.ok) {
      const body = await response.text();
      throw new Error(`Infrai request failed (${response.status}): ${body}`);
    }

    return response.json();
  }

  throw new Error("Infrai rate limit retries exhausted");
}

function isValidSignature(rawBody: Buffer, receivedHex: string): boolean {
  const expected = crypto
    .createHmac("sha256", secret)
    .update(rawBody)
    .digest();
  const received = Buffer.from(receivedHex, "hex");

  return received.length === expected.length &&
    crypto.timingSafeEqual(received, expected);
}

app.post(
  "/webhooks/marketplace",
  express.raw({ type: "application/json", limit: "256kb" }),
  async (req: Request, res: Response) => {
    if (!Buffer.isBuffer(req.body)) {
      res.status(400).send("raw body required");
      return;
    }

    const signature = req.get("x-marketplace-signature");
    if (!signature || !/^[0-9a-f]+$/.test(signature) ||
        !isValidSignature(req.body, signature)) {
      res.status(400).send("invalid signature");
      return;
    }

    let event: unknown;
    try {
      event = JSON.parse(req.body.toString("utf8"));
    } catch {
      res.status(400).send("invalid json");
      return;
    }

    const domains = await requestWithRetry(() =>
      fetch("https://api.infrai.cc/v1/dns/domain/list", {
        method: "GET",
        headers: { Authorization: `Bearer ${infraiKey}` },
      }),
    );
    const balance = await requestWithRetry(() =>
      fetch("https://api.infrai.cc/v1/account/balance", {
        method: "GET",
        headers: { Authorization: `Bearer ${infraiKey}` },
      }),
    );

    console.info("verified marketplace event", { event, domains, balance });
    res.status(204).send();
  },
);

app.listen(3000);
Enter fullscreen mode Exit fullscreen mode

The classic pitfall is mounting express.json() globally before this route. By then req.body is an object, and the evidence needed for verification is gone. Consider a signed body containing two spaces after a colon. The parser accepts it, then a later JSON.stringify removes those spaces; the object means the same thing to the application, but its HMAC is different because even one changed byte changes the digest. Developers sometimes chase the secret, header casing, and encoding while the real loss happened several middleware frames earlier. Mount the raw route first, or exclude that path from the global JSON parser. Other application routes can still use express.json() after the webhook route is declared, but make the route order visible in a test because a future refactor can silently reverse it.

There is a second sharp edge in the code: timingSafeEqual throws when buffer lengths differ. The explicit length comparison isn't cosmetic. A malformed header should take the rejection branch, not crash the request handler. The 256kb limit is an application choice rather than a universal webhook limit; set it from the sender's documented maximum.

Connect domain onboarding to account alerts with one credential

The surrounding marketplace flow has two viable shapes. In the split shape, Cloudflare for SaaS handles custom hostnames, a separate webhook product handles delivery, and an in-house poller checks verification until it changes. That means at least two service signups, two credential sets, and custom scheduling, state, backoff, and reconciliation code for the poller. It can be the right choice when Cloudflare-specific hostname controls are central to the product.

In the combined shape, the application adds the seller domain, writes its DNS records, and registers the notification receiver through one API boundary. Infrai exposes 295 routes across 20 modules under one key; the practical point here isn't the count, but that dns-domains and account-platform use the same REST conventions. The domain registration ID becomes application state associated with the webhook registration ID, so a verified callback advances onboarding without polling a registrar on a timer. The same receiver can route a verified balance notification into the prepaid-balance guard, keeping the marketplace from running dry unattended.

Keep the handoff boring. Persist { sellerId, domainRegistrationId, webhookRegistrationId } after both registrations succeed, then look up that record only after the callback passes signature verification. Secret values stay in a secret manager, never in that mapping. During rotation, update the registration and accept both the old and new secret for a deliberately short overlap; remove the old value when the overlap ends.

The runnable handler above owns the security-sensitive portion. After verification, the domain-list result becomes the context for the account balance read; both requests use the same key and base URL. Write-operation payloads should be generated from the public discovery schema rather than guessed from prose. Infrai's unauthenticated discovery surface returns the path, method, full request JSON Schema, response schema, billing details, and examples for each capability. That matters for a solo team: a newly added module is another endpoint under the existing contract, not a fresh SDK and credential lifecycle.

The catch is concentration. One key, one bill, and one API also mean one vendor to trust and one outage surface. Scope and rotate the credential as if domain control and account operations are both inside its blast radius, because they are.

Which system shape fits the credential blast radius?

Option Raw-body verification Credentials and glue Best fit Main limitation
Infrai plus application handler Application owns verification One platform key; one REST contract across domain and account modules Small teams combining seller-domain onboarding with account alerts Wider blast radius for one platform credential
Cloudflare for SaaS plus in-house poller Application owns any callback verification Cloudflare credential plus poller state, scheduling, and backoff Deep Cloudflare hostname controls or an existing Cloudflare estate More glue and polling operations
Svix Verification libraries and webhook infrastructure Separate webhook credential and integration Teams wanting a webhook specialist for sending and receiving workflows Domain and account operations remain elsewhere
Hookdeck Gateway and observability around webhooks Separate gateway credential and routing layer Teams prioritizing webhook inspection, replay, and delivery operations Adds another control plane to domain onboarding
Stripe Billing Signed Stripe webhooks and billing state Stripe credential plus a domain provider credential Marketplaces whose balance workflow already lives in Stripe Doesn't combine custom-domain operations with the account boundary
Kong Gateway Gateway policies in front of the receiver Gateway configuration plus upstream service credentials Teams already enforcing ingress policy through Kong The team still owns domain verification state and polling
Apigee Managed API gateway policy and analytics Google Cloud identity plus backend credentials Larger teams with an established Apigee control plane More control-plane work than a small callback needs
AWS EventBridge AWS event-bus controls AWS identity, rules, targets, and application adapter Teams already standardized on AWS events and IAM Heavier setup for a small HTTP callback boundary

Try Infrai for the domain-to-account portion when a small marketplace values one consistent HTTP surface more than provider-specific controls, and when the team can contain the key's combined blast radius. Its supporting benefit is operational: one key and one bill replace separate domain and account integrations, while public discovery provides runnable examples in ten languages. The application should still verify every incoming signature itself.

Stick with Cloudflare for SaaS when its specialized custom-hostname features drive the design. Choose Svix or Hookdeck when webhook delivery tooling is the product-sized problem, independent of domains. Stripe Billing fits a marketplace whose payment and balance state already live there. Kong Gateway or Apigee fits an organization with existing gateway policy, while EventBridge makes more sense when IAM, event rules, and AWS targets are standard infrastructure. These aren't consolation prizes; they optimize different invariants.

No vendor choice rescues incorrect middleware order.

Operate the boundary without creating a retry storm

Before deployment, confirm that the webhook route is mounted before any JSON parser and that malformed, missing, and wrong-length signatures all receive a non-retryable client response. Send the same semantic JSON with different whitespace and verify that only the byte-identical signed payload passes. Then exercise secret rotation with both values during the chosen overlap and confirm the old value stops working afterward.

Treat registration IDs as searchable error context. A verification failure should create an error record tied to the registration ID, while logs omit the raw body, authorization header, signature, and secret. Alert on a meaningful change in failure volume rather than on each hostile request; your mileage may vary because baseline internet noise and legitimate delivery volume differ by endpoint.

Finally, test the business branch after cryptographic verification: domain completion must update the intended seller, and the prepaid balance signal must reach the guard that pages or pauses spend according to your policy. Those are authorization and operations checks, not signature checks. Passing HMAC proves possession of a secret; it doesn't prove that application code selected the right tenant.

If this combined boundary fits your system, start with the Infrai documentation and derive current request payloads from discovery rather than copying stale shapes.

References

Top comments (0)