How to Verify Webhook Signatures (HMAC) the Right Way in Node.js, Next.js and WordPress

Flat vector illustration of a webhook request envelope passing through an HMAC signature check shield into a secure server
Flat vector illustration of a webhook request envelope passing through an HMAC signature check shield into a secure server

A few months ago a client pinged me on a Sunday: their store had marked eleven orders as paid, but the payment provider showed only eight real payments. Nobody had hacked the payment provider. Someone had simply found the public webhook URL and posted fake “payment succeeded” events at it. The endpoint trusted every JSON body that arrived. That is the whole bug, and it is far more common than people think.

In this guide I will show you exactly how I verify webhook signatures now on every project, whether it is a Stripe integration, a GitHub deploy hook, a WooCommerce order sync, or a custom service I built for a client. You will get working Node.js code for Express and Next.js route handlers, the three mistakes I see most often in code reviews, and a checklist you can paste into your own project README.

What a webhook signature actually proves

A webhook is just an HTTP POST from another service to a URL you own. Because that URL is public, anyone on the internet can send a request to it. The signature is how the sender proves two things: that the request really came from them, and that the body was not changed on the way.

Almost every provider uses the same idea, called HMAC (hash-based message authentication code). You and the provider share a secret. When the provider sends an event, it computes HMAC-SHA256 of the raw request body using that secret and puts the result in a header. When you receive the request, you compute the same HMAC over the exact bytes you received and compare. If the values match, the sender knew the secret and the body is untouched. If they do not match, you reject the request with a 400 or 401 and do nothing else.

Here are the header names you will meet most often:

  • Stripe: Stripe-Signature, with a timestamp t= and one or more v1= signatures. The signed payload is the timestamp, a dot, then the raw body.
  • GitHub: X-Hub-Signature-256, formatted as sha256= followed by the hex digest of the raw body.
  • Shopify: X-Shopify-Hmac-Sha256, a base64 digest of the raw body.
  • WooCommerce: X-WC-Webhook-Signature, also a base64 HMAC-SHA256 of the raw body.
  • Standard Webhooks spec (used by several newer platforms): webhook-id, webhook-timestamp and webhook-signature headers.

Always read the provider docs for the exact format, but once you understand one, the rest are small variations.

Diagram of the HMAC webhook flow: provider signs the raw body with a shared secret, server recomputes and compares the signature
How an HMAC webhook signature is created and verified.

Mistake 1: verifying the parsed JSON instead of the raw body

This is the number one bug I fix. The developer adds express.json() globally, then tries to verify the signature with JSON.stringify(req.body). It fails randomly, so they “fix” it by commenting out the check. Now the endpoint is wide open again.

The reason it fails is simple. The provider signed the exact bytes it sent. Parsing and re-stringifying changes those bytes: key order, whitespace, unicode escaping and number formatting can all differ. One extra space and the HMAC is completely different. You must verify against the raw body, before any parser touches it.

Here is the broken version I see in reviews:

// BROKEN: signature computed over re-serialized JSON
app.use(express.json());

app.post('/webhooks/github', (req, res) => {
  const expected = 'sha256=' + crypto
    .createHmac('sha256', process.env.GITHUB_WEBHOOK_SECRET)
    .update(JSON.stringify(req.body))   // not the bytes GitHub signed
    .digest('hex');

  if (req.get('X-Hub-Signature-256') !== expected) {  // also a timing leak
    return res.status(401).end();
  }
  // ...
});

And here is the fixed version. The webhook route gets its own raw body parser, registered before the global JSON parser, so the body arrives as a Buffer:

import express from 'express';
import crypto from 'node:crypto';

const app = express();

// Raw body ONLY for the webhook route, mounted before express.json()
app.post(
  '/webhooks/github',
  express.raw({ type: 'application/json', limit: '1mb' }),
  (req, res) => {
    const header = req.get('X-Hub-Signature-256') || '';
    if (!verifyGithub(req.body, header, process.env.GITHUB_WEBHOOK_SECRET)) {
      return res.status(401).send('invalid signature');
    }
    const event = JSON.parse(req.body.toString('utf8'));
    // handle event...
    res.status(200).send('ok');
  }
);

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

function verifyGithub(rawBody, header, secret) {
  const expected = 'sha256=' + crypto
    .createHmac('sha256', secret)
    .update(rawBody)
    .digest('hex');
  return safeEqual(header, expected);
}

Mistake 2: comparing signatures with === or !==

A normal string comparison stops at the first character that differs. In theory, an attacker who can measure response times very precisely can guess a valid signature one character at a time. Over the open internet this is hard, but it is not something you want to argue about in a security review, and the fix costs one line.

Node ships crypto.timingSafeEqual, which compares two Buffers in constant time. The catch is that it throws if the two Buffers have different lengths, so you check the length first:

import crypto from 'node:crypto';

export function safeEqual(a, b) {
  const bufA = Buffer.from(String(a), 'utf8');
  const bufB = Buffer.from(String(b), 'utf8');
  if (bufA.length !== bufB.length) return false;
  return crypto.timingSafeEqual(bufA, bufB);
}

Checking the length up front leaks only the length of the signature, which is public anyway because it is a fixed-size hex or base64 digest. That is fine.

Comparison showing why re-serialized JSON produces a different hash than the original raw request body
Verify the raw bytes, not JSON.stringify of the parsed body.

Mistake 3: no timestamp check, so old events can be replayed

A valid signature proves the provider sent a request at some point. It does not prove the provider sent it just now. If an attacker captures one legitimate request, from a leaked log, a misconfigured proxy, or a debugging tool someone left running, they can resend it again and again. Your signature check will pass every time.

This is why Stripe and the Standard Webhooks spec include a timestamp inside the signed payload. Because the timestamp is part of what is signed, it cannot be changed without breaking the signature. You then reject anything older than a small window, usually five minutes.

Here is a hand-written Stripe-style verifier so you can see every step. In a real Stripe project I use the official SDK method stripe.webhooks.constructEvent(rawBody, signature, secret), which does exactly this, but it is worth understanding what happens inside:

const TOLERANCE_SECONDS = 300; // 5 minutes

export function verifyStripeStyle(rawBody, header, secret) {
  // header looks like: t=1760000000,v1=abc123...,v1=def456...
  let timestamp = null;
  const signatures = [];
  for (const item of header.split(',')) {
    const [key, value] = item.split('=');
    if (key === 't') timestamp = Number(value);
    if (key === 'v1') signatures.push(value);
  }
  if (!timestamp || signatures.length === 0) return false;

  const age = Math.abs(Math.floor(Date.now() / 1000) - timestamp);
  if (age > TOLERANCE_SECONDS) return false; // replay or bad clock

  const signedPayload = `${timestamp}.${rawBody.toString('utf8')}`;
  const expected = crypto
    .createHmac('sha256', secret)
    .update(signedPayload)
    .digest('hex');

  return signatures.some((sig) => safeEqual(sig, expected));
}

Notice that it accepts any of several v1 values. Providers send more than one during secret rotation, which brings me to the next point.

Doing the same thing in a Next.js route handler

Most of my newer client builds are Next.js App Router projects, and webhooks there are actually easier, because a route handler gives you the raw body directly. Just call await request.text() and do not call request.json() first. A Request body can only be read once.

// app/api/webhooks/woocommerce/route.js
import crypto from 'node:crypto';

export const runtime = 'nodejs'; // node:crypto needs the Node runtime

export async function POST(request) {
  const rawBody = await request.text();
  const received = request.headers.get('x-wc-webhook-signature') || '';

  const expected = crypto
    .createHmac('sha256', process.env.WC_WEBHOOK_SECRET)
    .update(rawBody, 'utf8')
    .digest('base64');

  if (!safeEqual(received, expected)) {
    return new Response('invalid signature', { status: 401 });
  }

  const order = JSON.parse(rawBody);
  await queueOrderSync(order.id); // do the slow work elsewhere
  return new Response('ok', { status: 200 });
}

If you deploy to an edge runtime, node:crypto is not available. In that case use the Web Crypto API: crypto.subtle.importKey with the HMAC algorithm, then crypto.subtle.verify, which also performs a constant-time comparison for you.

async function verifyHmacEdge(rawBody, signatureHex, secret) {
  const enc = new TextEncoder();
  const key = await crypto.subtle.importKey(
    'raw', enc.encode(secret),
    { name: 'HMAC', hash: 'SHA-256' },
    false, ['verify']
  );
  const sigBytes = new Uint8Array(
    signatureHex.match(/../g).map((h) => parseInt(h, 16))
  );
  return crypto.subtle.verify('HMAC', key, sigBytes, enc.encode(rawBody));
}

Handle duplicates even when the signature is valid

Providers retry. If your endpoint times out or returns a 500, the same event will arrive again, often several times over a few hours. A perfectly valid, perfectly signed duplicate can still charge a customer twice or send two confirmation emails.

So after verification I store the event id (Stripe event.id, GitHub X-GitHub-Delivery, the Standard Webhooks webhook-id) in a table with a unique constraint. If the insert fails because the id already exists, I return 200 and skip processing. I wrote a full post on this pattern in my guide to API idempotency keys, and it applies to webhooks just as well.

-- one row per processed delivery
CREATE TABLE webhook_events (
  provider    TEXT NOT NULL,
  event_id    TEXT NOT NULL,
  received_at TIMESTAMPTZ NOT NULL DEFAULT now(),
  PRIMARY KEY (provider, event_id)
);
Diagram of timestamp window and event id table rejecting replayed and duplicate webhook deliveries
Timestamp checks stop replays; an event id table stops duplicates.

Respond fast, process later

Most providers expect a 2xx response within a few seconds, and they treat anything slower as a failure and retry. That is how a slow image resize or CRM sync turns into five duplicate deliveries. My rule: verify, record the event id, push a job to a queue (or a database table that a cron worker reads), return 200. The real work happens in the background where it can take as long as it needs and retry on its own terms.

Secrets: storage and rotation

  • Keep each webhook secret in an environment variable, never in the repo and never in a WordPress option that a plugin can print into a debug page.
  • Use a different secret per environment. Your staging endpoint should not accept production signatures.
  • Support two secrets during rotation. Verify against the new one first, fall back to the old one, and remove the old one after the provider has fully switched over.
  • Never log the secret or the full signature header. Log the event id and the verification result.
const secrets = [process.env.WEBHOOK_SECRET, process.env.WEBHOOK_SECRET_OLD]
  .filter(Boolean);

const ok = secrets.some((s) => verifyGithub(rawBody, header, s));

Testing your verification locally

Do not wait for production to find out your check is wrong. I write one small test that signs a fixture body with a test secret, sends it to the handler, and expects a 200, then changes a single character in the body and expects a 401. That pair of tests catches the raw body bug immediately, because a framework upgrade that silently adds a JSON parser will make the first test fail.

import crypto from 'node:crypto';
import assert from 'node:assert/strict';
import test from 'node:test';

const secret = 'test_secret';
const body = Buffer.from('{"action":"opened","number":42}');
const sig = 'sha256=' + crypto.createHmac('sha256', secret).update(body).digest('hex');

test('accepts a correct signature', () => {
  assert.equal(verifyGithub(body, sig, secret), true);
});

test('rejects a tampered body', () => {
  const tampered = Buffer.from('{"action":"opened","number":43}');
  assert.equal(verifyGithub(tampered, sig, secret), false);
});

For end-to-end checks, most providers have a CLI or dashboard button that sends a signed test event. The Stripe CLI command stripe listen --forward-to localhost:3000/api/webhooks/stripe is the one I use most, and GitHub has a “Redeliver” button on every recent delivery in the webhook settings page.

What about WordPress?

A lot of my clients run WordPress, and custom REST endpoints there have the same problem. If you register a route with register_rest_route to receive webhooks, read the raw body with $request->get_body(), compute hash_hmac('sha256', $body, $secret), and compare with hash_equals(), which is the PHP constant-time comparison. Do not use 'permission_callback' => '__return_true' and then forget the signature check, which is exactly how the client store from the start of this post got its fake orders.

function dp_verify_webhook( WP_REST_Request $request ) {
    $body     = $request->get_body();
    $received = $request->get_header( 'x_wc_webhook_signature' );
    $expected = base64_encode( hash_hmac( 'sha256', $body, DP_WC_WEBHOOK_SECRET, true ) );
    return is_string( $received ) && hash_equals( $expected, $received );
}

My webhook security checklist

This is the list I paste into the README of every project that receives webhooks. If any line is not true, the endpoint is not ready for production:

  • The signature is computed over the raw request body, before any JSON parser runs.
  • Comparison uses crypto.timingSafeEqual, crypto.subtle.verify or hash_equals, never ===.
  • Signed timestamps older than five minutes are rejected.
  • Processed event ids are stored with a unique constraint, so duplicates are skipped.
  • The handler returns 2xx within a couple of seconds and moves slow work to a queue.
  • Secrets live in environment variables, differ per environment, and support rotation.
  • There is a test that proves a tampered body is rejected.
  • Invalid signatures are logged (without the secret) and alerted on if they spike.

Wrapping up

The fix for the client store took about forty minutes: raw body parsing on the webhook route, a constant-time comparison, a timestamp window and an event id table. The fake orders stopped that same day, and they now get an alert if more than a handful of invalid signatures arrive in an hour. If you are running any webhook endpoint today, open it right now and check one thing: what bytes are you actually hashing? If the answer involves JSON.stringify, you know what to fix first.

If you want a second pair of eyes on your webhook or API integrations, that is a big part of what I do for clients. Feel free to get in touch through my site.

Comments

Leave a Reply

Your email address will not be published. Required fields are marked *