formpit

Webhooks

How to Verify Webhook Signatures in Node.js (HMAC-SHA256, Done Right)

A webhook endpoint is a public URL that does something whenever it receives a request. Without a check, anyone who finds that URL can send fake events to it: fake orders, fake sign-ups, fake form submissions. Signature verification is the check. It takes about ten lines of code, but there are four details that most tutorials get wrong, and each one either breaks verification or quietly leaves a hole.

How webhook signatures work

The sender and you share a secret key. For every delivery, the sender combines the secret with the exact request body (and often a timestamp) using HMAC-SHA256, and puts the result in a header. You repeat the same calculation with your copy of the secret. If your result matches the header, the request came from someone who knows the secret, and the body wasn't changed on the way.

Providers differ only in the details:

SenderSignature headerWhat is signedFormat
GitHubX-Hub-Signature-256Bodysha256= + hex
StripeStripe-Signaturetimestamp.bodyt=…,v1= hex
SlackX-Slack-Signaturev0:timestamp:bodyv0= + hex
ShopifyX-Shopify-Hmac-Sha256BodyBase64
FormpitX-Formpit-Signaturetimestamp.bodyv1= + hex

The code below uses Formpit's format, where the timestamp arrives in X-Formpit-Timestamp. Adapting it to any row of that table is a matter of changing the header names and the string you sign.

Mistake 1: hashing the parsed body

This is the number one reason signatures "never match". The signature covers the exact bytes that were sent. If a body parser turns them into a JavaScript object first and you then call JSON.stringify() to get a string back, spacing, key order or number formatting can differ, and the hash is different.

In Express, use express.raw() on the webhook route so req.body is a Buffer of the original bytes. And make sure a global app.use(express.json()) doesn't run before it, because a body can only be read once.

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

const app = express();
const SECRET = process.env.FORMPIT_WEBHOOK_SECRET;

// Register this BEFORE any app.use(express.json())
app.post('/webhooks/formpit',
  express.raw({ type: 'application/json' }),
  (req, res) => {
    const ok = verifySignature(req.body,
      req.get('X-Formpit-Timestamp'),
      req.get('X-Formpit-Signature'),
      SECRET);
    if (!ok) return res.status(401).send('Invalid signature');

    const event = JSON.parse(req.body.toString('utf8'));
    // ...handle the event (see "duplicates" below)
    res.sendStatus(200);
  });

Mistake 2: comparing with ===

A normal string comparison stops at the first character that differs. That means a wrong signature that shares its first few characters with the right one takes very slightly longer to reject. With enough requests, an attacker can measure that and recover a valid signature one character at a time. crypto.timingSafeEqual always takes the same time.

One catch: timingSafeEqual throws if the two buffers are different lengths, so check the length first.

function verifySignature(rawBody, timestamp, signature, secret,
                         toleranceSeconds = 300) {
  if (!rawBody || !timestamp || !signature || !secret) return false;
  if (!/^\d+$/.test(timestamp)) return false;

  // Mistake 3: reject old (replayed) requests
  const age = Math.abs(Date.now() / 1000 - Number(timestamp));
  if (age > toleranceSeconds) return false;

  const expected = 'v1=' + crypto
    .createHmac('sha256', secret)
    .update(timestamp + '.')
    .update(rawBody)                // the raw Buffer, not parsed JSON
    .digest('hex');

  const a = Buffer.from(signature);
  const b = Buffer.from(expected);
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}

Mistake 3: no replay protection

A signature proves a request is genuine. It doesn't prove it's new. If someone captures one real delivery (from a log, a proxy, or a misconfigured tool), they can send it again tomorrow and it will still verify. That's why good senders include a timestamp inside the signed data. Your code rejects anything older than a few minutes, and because the timestamp is part of the signature, it can't be changed without breaking it.

Five minutes is a common tolerance. It allows for clock differences and retries without leaving a big window. If your server's clock drifts, fix that with NTP rather than widening the window.

Mistake 4: assuming each event arrives once

Webhooks are delivered "at least once". Networks fail, senders retry, and the same event can arrive twice. If your handler sends an email or creates an order, a duplicate means doing it twice.

Use the delivery or event ID that the sender includes (Formpit sends X-Formpit-Delivery) and remember which ones you've handled. A unique constraint in your database is the simplest way:

// Pseudocode with any SQL database
try {
  await db.run('INSERT INTO handled_webhooks (id) VALUES (?)', deliveryId);
} catch (err) {
  if (isUniqueViolation(err)) return res.sendStatus(200); // seen it, skip
  throw err;
}
await handleEvent(event);

Also reply quickly. Most senders wait only a few seconds before treating a delivery as failed and retrying. Do the slow work (sending emails, calling other APIs) after you've returned a 200, or in a background job.

Next.js (App Router) example

In a route handler, read the raw body with req.text() before doing anything else:

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

export async function POST(req) {
  const raw = await req.text();
  const ts = req.headers.get('x-formpit-timestamp') || '';
  const sig = req.headers.get('x-formpit-signature') || '';

  if (!/^\d+$/.test(ts) || Math.abs(Date.now() / 1000 - Number(ts)) > 300) {
    return new Response('Stale request', { status: 401 });
  }
  const expected = 'v1=' + crypto
    .createHmac('sha256', process.env.FORMPIT_WEBHOOK_SECRET)
    .update(`${ts}.${raw}`)
    .digest('hex');
  const a = Buffer.from(sig), b = Buffer.from(expected);
  if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
    return new Response('Invalid signature', { status: 401 });
  }

  const event = JSON.parse(raw);
  // ...handle it
  return new Response('ok');
}

Python example

The same check in Python uses hmac.compare_digest for the constant-time comparison. In Flask, the raw body is request.get_data().

import hashlib, hmac, time

def verify_signature(raw: bytes, timestamp: str, signature: str,
                     secret: str, tolerance: int = 300) -> bool:
    if not timestamp or not timestamp.isdigit():
        return False
    if abs(time.time() - int(timestamp)) > tolerance:
        return False
    mac = hmac.new(secret.encode(), timestamp.encode() + b"." + raw,
                   hashlib.sha256).hexdigest()
    return hmac.compare_digest(signature or "", "v1=" + mac)

Looking after the secret

  • Keep it out of your code. Store it in an environment variable or your platform's secret manager, never in the repository.
  • One secret per endpoint. If one leaks, you only rotate one.
  • Rotate without downtime. When you create a new secret, accept both the old and new one for a short while, then drop the old one.
  • Don't log full requests with their signature headers in places many people can read.

Testing your endpoint

Send yourself a real delivery and a tampered one. With Formpit, put your endpoint's URL in the form's Integrations tab, copy the signing secret shown under it, and press Send test submission. To see exactly what arrives before writing any code, point the webhook at a request inspector such as webhook.site first. Then check that your code accepts the genuine delivery and returns 401 when you change one character of the body, the signature or the timestamp.

Checklist

  1. Hash the raw body, not parsed JSON.
  2. Compare with timingSafeEqual (or hmac.compare_digest), after a length check.
  3. Reject timestamps older than about five minutes.
  4. Skip duplicate delivery IDs.
  5. Return 200 quickly; do slow work afterwards.
  6. Keep the secret in an environment variable and rotate it if it leaks.

Get those six right and your webhook endpoint is as trustworthy as the sender's own systems.

Frequently asked questions

Why does my signature never match?

Most often because the body was parsed as JSON before hashing. Hash the raw bytes exactly as they arrived.

Why use timingSafeEqual instead of ===?

A normal comparison can stop at the first different character, which leaks timing information an attacker can measure. timingSafeEqual always takes the same time.

Do I need the timestamp check?

Yes, if the sender provides one. Without it, a captured genuine request can be replayed later and will still pass the signature check.

More from the blog

How to Add a Contact Form to a Static Website (No Backend Needed)How to Stop Contact Form Spam Without a CAPTCHAHTML Form Not Working? 11 Common Mistakes and How to Fix Them