Verify Webhook Signatures at the Gateway

Signature verification is the first line of defense for any webhook — and the step teams most often get subtly wrong. Here's how to move it off your handler and onto the gateway for GitHub and Stripe.

Verify Webhook Signatures at the Gateway

Every webhook endpoint is a public URL. Anyone who finds it can POST to it. So before your code trusts a single field in the payload, it has to answer one question: did this request really come from Stripe, from GitHub, from the provider it claims to be?

The answer is signature verification. The provider signs each delivery with a shared secret; you recompute the signature and compare. It is the first line of defense for any webhook — and it is also the step teams most often get subtly wrong.

This post is about moving that step off your handler and onto the gateway.

Why hand-rolled verification is easy to get wrong

Verifying an HMAC signature looks like four lines of code. In production it hides several sharp edges:

  • You need the raw bytes. The signature is computed over the exact body the provider sent. If any middleware parses JSON first and you re-serialize it, a re-ordered key or a changed space breaks the hash — even though the data is “the same.”
  • Comparisons must be constant-time. A plain === on the hex digest leaks timing information. You need crypto.timingSafeEqual, and you need to handle length mismatches without throwing.
  • Every provider signs differently. GitHub sends x-hub-signature-256: sha256=<hex>. Stripe sends stripe-signature: t=<timestamp>,v1=<hex> and signs "<timestamp>.<body>", not the body alone. Get the scheme wrong and valid requests get rejected.
  • Replay protection is separate. A signature proves authenticity, not freshness. Stripe includes a timestamp so you can reject events that were captured and replayed hours later — but only if you actually check it.

None of this is hard once. The problem is doing it correctly in every function, for every provider, forever.

Verify at the gateway instead

On Inquir, signature verification is an opt-in property of the gateway route, not something you re-implement in each handler. You set two things on the route — the mode and the secret — and the gateway verifies the raw request body before your function is ever invoked:

provider → gateway route (verify raw body) → 403 BAD_SIGNATURE
                                           ↘ your function (authentic request only)

If the signature is missing or wrong, the caller gets 403 { "error": { "code": "BAD_SIGNATURE" } } and your code never runs. If it is valid, your handler receives the request exactly as before. There are three built-in modes.

GitHub

GitHub signs the raw body with HMAC-SHA256 and sends x-hub-signature-256: sha256=<hex>. Set the route mode to github and paste your webhook secret. That is the whole configuration:

{
  "webhookMode": "github",
  "webhookSecret": "whsec_..."
}

The gateway reads x-hub-signature-256, recomputes sha256=<hmac> over the raw body, and compares it in constant time.

Stripe

Stripe is the one people most often trip over, because it does not sign the body directly — it signs "<timestamp>.<rawBody>" and ships both in the stripe-signature header as t=...,v1=.... The stripe mode parses that header, rebuilds the signed payload, and compares every v1 candidate:

{
  "webhookMode": "stripe",
  "webhookSecret": "whsec_..."
}

Because Stripe includes the timestamp, the gateway can also reject deliveries that are too old — replay protection — when you enable a tolerance window. A signature that verified yesterday will not pass today.

Custom (and everything else)

For providers that use a straightforward hex HMAC of the raw body, custom mode lets you point at any header:

{
  "webhookMode": "custom",
  "webhookSecret": "...",
  "signatureHeader": "x-signature"
}

A word of honesty: not every provider fits these three modes. Slack, for example, signs v0:<timestamp>:<body> with its own scheme, and some providers use non-HMAC signatures entirely. For those, keep verifying inside the handler — the gateway just takes the two most common cases (GitHub and Stripe) off your plate. We would rather ship three correct modes than one vague “verifies everything” checkbox that quietly does the wrong thing.

Before and after

Hand-rolled, the top of a Stripe handler looks like this — and this is the correct version, which is more than most endpoints ship:

import { createHmac, timingSafeEqual } from 'node:crypto';

export async function handler(event) {
  const header = event.headers['stripe-signature'] ?? '';
  const parts = Object.fromEntries(header.split(',').map((p) => p.split('=')));
  const signed = `${parts.t}.${event.body}`;               // body must be the raw string
  const expected = createHmac('sha256', process.env.STRIPE_SECRET).update(signed).digest('hex');
  const ok = parts.v1 && timingSafeEqual(Buffer.from(parts.v1), Buffer.from(expected));
  if (!ok) return { statusCode: 403, body: 'bad signature' };

  // ... only now can we trust the event
}

With webhookMode: 'stripe' on the route, the same handler starts where the real work begins:

export async function handler(event) {
  // The gateway already rejected anything with a bad or missing signature.
  const evt = JSON.parse(event.body);
  // ... handle evt.type
}

The verification logic does not disappear — it moves to a place where it is written once, tested once, and applied uniformly to every route that needs it.

Verification is not the whole story

Signature checking answers “is this authentic?” It does not answer “have I already processed this?” Providers retry deliveries, and a retry carries a valid signature too. So the reliable webhook shape is still:

  1. Verify the signature — now a route setting for GitHub and Stripe.
  2. Acknowledge fast — return 200/202 inside the provider’s timeout window.
  3. Deduplicate on the event ID so a retried delivery is a no-op.
  4. Do the heavy work asynchronously in a pipeline, so a slow downstream never costs you an ACK.

Moving step 1 to the gateway makes the other three easier to see. Your handler stops being half signature-plumbing and starts being the business logic you actually wanted to write.

If you are building webhook endpoints on Inquir, turn on webhookMode for your GitHub and Stripe routes and delete the HMAC boilerplate. It is the kind of code that is much better to never write again.