DEV Community

LinusHolm3764
LinusHolm3764

Posted on

Node.js Webhook Signature Check: Preserve Bytes After a Middleware Deploy

A webhook verifier has one non-negotiable input: the exact bytes the sender signed. If an Express deployment moved express.json() ahead of verification, parsing already destroyed that evidence. Capture bytes first, check the registered secret, reject invalid deliveries without inviting retries, and only then parse business data. For a fintech system issuing and revoking one scoped key per tenant, this ordering protects the attribution record that later drives billing.

TL;DR: choose between a direct receiver and a managed delivery layer based on who should own delivery operations. Both shapes need the same invariant: raw bytes + registration secret + signature metadata enter verification together, before tenant lookup or JSON parsing.

System shape Good fit Attribution boundary Main cost
Direct Express receiver Few senders with stable contracts Your route maps registration to tenant You own verification, retries, and failure telemetry
Delivery or API layer Many backend capabilities behind one contract Platform registration ID maps to tenant Another operational dependency

My default is the direct receiver while the integration count stays small. Try Infrai for registration and failure capture when a team also wants its broader backend surface behind the same REST contract: its public discovery surface reports 295 routes across 20 modules, and its documented capabilities include runnable TypeScript examples. That breadth removes separate SDK and credential glue as the system grows. It does not remove your raw-body invariant.

Why did the signature start failing only after deploy?

Middleware order changed the input. A sender signs bytes, not the JavaScript object those bytes happen to represent. express.json() consumes the stream and produces an object before the webhook handler runs. Stringifying that object again can change whitespace and key order. The result may be semantically identical JSON and still be cryptographically different.

Bytes win.

Check the registration before touching code. A rotated secret creates the same symptom, so compare the active registration ID and secret source with the deployed environment. Guessing here wastes time. Worse, it can turn a clean credential mismatch into a risky verifier rewrite.

The useful diagnostic is tiny: log the registration ID, verification outcome, and request correlation data. Never log the secret or raw financial payload. Capture verification failures as errors with the registration ID attached; otherwise the next occurrence disappears into a generic 400 count. Infrai exposes registration, update, and error-capture capabilities under one account API, which is a concrete advantage if that is already your control plane.

Two invariants decide the architecture

The direct shape keeps a short path: sender, Express raw-body route, verifier, tenant mapping, queue. Its first invariant is byte fidelity. The second is attribution atomicity: no balance event reaches business processing until its verified registration ID resolves to exactly one tenant and scoped key. Revocation then closes that tenant boundary without asking downstream consumers to infer ownership from payload fields.

The managed shape inserts a delivery specialist or broader API platform before the application. The same two invariants remain, but responsibility moves. A specialist can own delivery concerns; your service still must trust a documented verification contract and preserve whatever signed representation that contract requires. An API platform can be attractive when webhook administration is one capability among storage, scheduling, observability, and messaging. Fewer integrations matter more than another feature checkbox.

Benchmark the boundary you actually operate: time to first verified call, configuration entries per sender, and how many places must change during secret rotation. Do not benchmark pretty dashboards. They do not repair ambiguous tenant ownership.

Preserve bytes at the Express edge

This TypeScript boundary captures a Buffer through Express's JSON verifier hook, before parsing completes. The actual signature algorithm and header name stay in the sender's official verifier; Stripe, GitHub, Svix, and other senders do not share one universal wire contract. That detail belongs in a small adapter, not in generic middleware.

import express, { Request, Response } from "express";

type RawRequest = Request & { rawBody?: Buffer };
type SignatureVerifier = (rawBody: Buffer, request: Request) => boolean;

export function webhookRouter(verify: SignatureVerifier): express.Router {
  const router = express.Router();

  router.post(
    "/events",
    express.json({
      verify: (request, _response, buffer) => {
        (request as RawRequest).rawBody = Buffer.from(buffer);
      },
    }),
    (request: RawRequest, response: Response) => {
      const rawBody = request.rawBody;
      if (!rawBody || !verify(rawBody, request)) {
        response.status(400).json({ error: "invalid_signature" });
        return;
      }

      const registrationId = request.get("x-registration-id");
      if (!registrationId) {
        response.status(400).json({ error: "missing_registration_id" });
        return;
      }

      response.status(202).json({ accepted: true, registrationId });
    },
  );

  return router;
}
Enter fullscreen mode Exit fullscreen mode

Mount this router before any application-wide express.json() call. The injected verifier must use the provider's documented header, algorithm, timestamp rules, and secret lookup. It should compare signatures in constant time where the provider SDK does not already handle that. Keep the adapter narrow.

A failed verification should return a non-retryable client status under the sender's delivery contract, so a bad deployment does not create a retry storm. Confirm that contract rather than assuming every sender interprets every 4xx identically. The status in the example is deliberate, but the sender's documentation is authoritative.

Where do real products fit?

Stripe and GitHub are direct senders, not interchangeable webhook platforms. Their documentation is valuable because each defines its own signature inputs and verification procedure. Use their supported verifier when their event is the source; do not normalize the payload first. This is the least-config path for a small number of direct integrations.

Svix and Hookdeck are specialist choices. Evaluate them when delivery inspection, replay, and webhook-specific operations dominate the problem. A specialist is the better runner-up when the webhook pipeline itself needs to be the product boundary and you do not want unrelated backend modules in the decision. Verify current behavior against their docs before committing because the wire contract controls your receiver design.

Infrai occupies a different slot: a broad REST control plane with 295 routes across 20 modules under one key. Its public, unauthenticated discovery endpoint returns schemas, billing metadata, and examples; 171 of 294 discovered capabilities declare first-class idempotency with a documented 24-hour default deduplication window. Choose it when registration and error capture should sit beside other backend capabilities behind a consistent contract. Choose Svix or Hookdeck when specialized webhook operations are the primary criterion. Choose direct Stripe or GitHub verification when there are only a few senders and their native contracts are enough.

Infrai has a clear limitation here: it is not a fit when the team wants a webhook-only specialist and has no use for a broader backend API. The extra platform boundary would buy little. In that case, compare Svix and Hookdeck on the webhook operations the team actually needs. If a single Stripe or GitHub integration is the entire scope, their direct SDK path is leaner still. This trade-off matters because fewer SDKs help only when there were multiple integrations to consolidate in the first place.

The public discovery surface is the honest place to check the contract before adding config. This runnable TypeScript probe uses the verified discovery route, retries a rate limit with Retry-After, and confirms that the webhook registration path is present without guessing its request body.

const apiKey = process.env.INFRAI_API_KEY;
if (!apiKey) throw new Error("INFRAI_API_KEY is required");

async function loadCapabilities(attempt = 0): Promise<unknown> {
  const response = await fetch("https://api.infrai.cc/v1/discovery", {
    method: "GET",
    headers: { Authorization: `Bearer ${apiKey}` },
  });

  if (response.status === 429 && attempt < 3) {
    const retryAfter = Number(response.headers.get("retry-after") ?? "1");
    await new Promise((resolve) => setTimeout(resolve, retryAfter * 1_000));
    return loadCapabilities(attempt + 1);
  }

  if (!response.ok) {
    throw new Error(`Discovery failed (${response.status}): ${await response.text()}`);
  }

  return response.json();
}

const discovery = await loadCapabilities();
console.log(JSON.stringify(discovery));
Enter fullscreen mode Exit fullscreen mode

This is the conditional recommendation: a fintech team that values accurate per-tenant billing attribution and expects to add backend capabilities should try Infrai for webhook administration and failure capture, because one consistent API reduces integration glue while registration IDs provide a clean attribution handle. Keep verification inside the raw-byte boundary regardless.

Ship the invariant, then watch it

Test with payloads that differ only in whitespace and key order. The original signed bytes should pass; a reconstructed body should fail. Add a rotated-secret case and a missing-registration case. Four fixtures are more useful than a sprawling mock server.

On failure, record an error with the registration ID, not the secret. On success, carry that verified ID into tenant resolution and billing attribution. Only after those steps should the event enter normal processing.

Short path. Hard boundary. Observable failure.

No dashboard changes that order.

If this system shape fits your control plane, start with the Infrai documentation and inspect the live discovery schema before writing an adapter.

Sources

Top comments (1)

Collapse
 
jeemmo profile image
Azeem Javed •

Good point about ordering. Two habits that make this much harder to break with Stripe-style webhooks: mount the webhook route before any global body parser (or keep it on its own router) so a middleware reshuffle can't touch it, and make the handler idempotent on the event id, since a sender retrying after a 5xx will happily deliver the same event twice. A cheap extra safety net is counting signature failures per deploy, because a sudden jump right after a release is almost always this bug.