Verify webhook signatures with HMAC

Learn to verify HMAC-SHA256 webhook signatures over the raw request body, reject old or replayed deliveries, compare signatures in constant time, and test the verifier.

  • 40 min
  • Advanced

Most production integrations run in both directions. You call APIs, and other services call you: a payment provider reports a charge, a source host reports a push, a form tool reports a submission. Those inbound calls are webhooks, and the endpoint that receives them is a public URL that anybody on the internet can POST to.

APIVerve itself does not send webhooks. Its endpoints are request and response, and nothing on the platform calls your server. But almost every system that calls APIVerve also receives webhooks from somewhere, and very often the webhook is what triggers the API call: a new signup arrives from your auth provider and you validate the email; a payment succeeds and you generate an invoice. A forged webhook in that chain spends your credits on attacker-chosen input and, worse, makes your system act on events that never happened.

This lesson builds a verifier for the most common signing scheme, tests it, and closes the gaps (raw bodies, replays, timing leaks) where real verifiers usually fail.

What a signature proves

The sender and you share a secret, typically shown once when you register the endpoint. For each delivery, the sender computes an HMAC (hash-based message authentication code) over the request and sends the result in a header. HMAC-SHA256 takes a secret and a message and produces a 32-byte value that only someone holding the secret could have produced for that exact message. Change one byte of the message, or use a different secret, and the output is unrelated.

You recompute the HMAC with your copy of the secret over what you received and compare. If they match, two things are true:

  • Authenticity: the request came from someone who holds the secret.
  • Integrity: the signed bytes were not changed in transit.

Two things are not covered by a bare HMAC. It says nothing about confidentiality (the body is readable; TLS is what protects it in transit). And it says nothing about freshness: a captured request with a valid signature stays valid forever, so an attacker who sees one delivery can replay it. That is why the schemes worth trusting sign a timestamp along with the body.

Signing schemes differ in detail between providers, so always implement the one your provider documents. Three common shapes:

Provider styleHeaderSigned messageReplay protection
StripeStripe-Signature: t=<unix>,v1=<hex><t>.<raw body>Timestamp inside the signed message
SlackX-Slack-Signature: v0=<hex> plus X-Slack-Request-Timestampv0:<timestamp>:<raw body>Timestamp inside the signed message
GitHubX-Hub-Signature-256: sha256=<hex><raw body>None in the signature; use the delivery id

The rest of this lesson uses the Stripe-style t=...,v1=... format, because it carries everything a good scheme needs in one header, and many providers copy it.

The header, field by field

A Stripe-style header is a comma-separated list of key=value pairs:

t=1700000000,v1=559ce53eb4efe6d1069e5776295348f9902079cfaa716425b5f770682854f464
  • t is the Unix time in seconds when the sender signed the delivery.
  • v1 is the hex HMAC-SHA256 of the string `${t}.${rawBody}`: the timestamp, a literal dot, then the body exactly as sent.
  • There may be more than one v1. During a secret rotation the sender signs with both the old and new secret, and a delivery is valid if any v1 matches. Your parser must collect them all rather than keeping the last one.
  • Other schemes such as v0 may appear. Ignore any scheme you do not verify; never accept a delivery because an unknown scheme was present.

Because t is inside the signed message, an attacker cannot take an old delivery and put a fresh timestamp on it: changing t changes the HMAC. So you can safely reject anything whose t is too far from your clock, which turns "valid forever" into "valid for five minutes".

You can compute an expected signature with APIVerve's HMAC Generator, which is useful for building test fixtures and for checking your own implementation against an independent one. The message here is a timestamp, a dot and a small JSON body, exactly what a sender would sign:

Try it liveHMAC Generator1 credit
POSThttps://api.apiverve.com/v1/hmac
curl -X POST "https://api.apiverve.com/v1/hmac" \
  -H "x-api-key: YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"message":"1700000000.{\"id\":\"evt_1\",\"type\":\"order.paid\"}","secret":"whsec_test_123","algorithm":"sha256","encoding":"hex"}'
Sign in or create a free account to run this with your own key.

data.hmac is the v1 value a sender would put in the header for t=1700000000 and this exact body.

Run the same thing locally and compare. They match byte for byte:

sign.mjs
import { createHmac } from 'node:crypto';
const t = 1700000000;
const body = '{"id":"evt_1","type":"order.paid"}';
console.log(createHmac('sha256', 'whsec_test_123').update(`${t}.${body}`, 'utf8').digest('hex'));
// 559ce53eb4efe6d1069e5776295348f9902079cfaa716425b5f770682854f464

Verify over the raw body

This is where most broken verifiers break. The signature covers the exact bytes the sender transmitted. If your framework parses the JSON before your handler runs and you re-serialise it with JSON.stringify, you are hashing a different string: whitespace disappears, é becomes é, 1.0 becomes 1, and the HMAC no longer matches, or worse, you "fix" it by loosening the check.

So the order is fixed: read raw bytes, verify, and only then parse. How you get the raw bytes depends on the framework:

// The raw parser must apply to this route BEFORE any app-wide express.json().
import express from 'express';
const app = express();

app.post('/webhooks/orders', express.raw({ type: 'application/json', limit: '256kb' }), (req, res) => {
  const rawBody = req.body.toString('utf8');   // req.body is a Buffer here
  // verify, then JSON.parse(rawBody)
});

app.use(express.json()); // everything else

Next.js route handlers follow the Worker shape: await req.text(), never await req.json(), before verification. Whatever the framework, cap the body size. A verifier still has to read the body to hash it, and an unbounded body is a cheap way to tie up your server.

Check yourself

A handler runs const event = await req.json() and then verifies JSON.stringify(event) against the v1 signature. Deliveries fail intermittently. Why?

Run every example on your own key. Free plan, no card.

Scaling up?

Volume pricing, custom SLAs, and dedicated support for high-traffic teams.

Contact sales