Platform integration

API strategy

Webhook signature verification: a practical HMAC-SHA256 guide

Webhook signature verification with HMAC-SHA256: a 7-step guide covering raw bodies, constant-time comparison, replay protection, and the pitfalls that matter.

7 minute read
Decorative imagery showcasing Pontil's brand

Webhooks are how one system tells another that something happened. But an unverified webhook is just an HTTP request from the internet — anyone can send one, and if your handler trusts it, an attacker can trigger refunds, cancel subscriptions, or poison your event store.

Signature verification fixes that. This guide walks through implementing HMAC-SHA256 webhook signature verification end to end: parsing the signature header, computing the expected digest, comparing safely, and hardening against replay. You'll finish with a working Node.js handler and a checklist of the mistakes that break verification in production.

Prerequisites: a webhook sender that signs payloads (Stripe, GitHub, Shopify, or your own), the signing secret, and a webhook endpoint you control. Time: about 30 minutes.

Step 1 — Understand what the signature actually proves

Before writing code, be clear on what verification gives you and what it doesn't.

An HMAC signature proves two things: the payload wasn't modified in transit, and whoever sent it holds the shared secret. That's it. It does not prove the event is recent, that you haven't seen it before, or that the sender's business logic is correct. Those are separate problems — replay protection, idempotency, and application-level validation — and you need all four to trust a webhook in production.

HMAC-SHA256 is the standard choice. It's fast, well-supported in every language, and used by Stripe, GitHub, Shopify, Slack (for Events API requests), and most other major webhook senders. The mechanism is identical across providers; only the header format and the exact string being signed differ.

Step 2 — Read the raw request body, not the parsed JSON

The single most common verification bug: signing the parsed and re-serialised JSON instead of the exact bytes the sender signed.

HMAC is byte-exact. A single whitespace difference, a re-ordered key, or a Unicode normalisation step will invalidate the signature. Your web framework almost certainly parses JSON by default, and once parsed, the original bytes are gone.

In Express, capture the raw body before any JSON middleware runs:

import express from 'express';

const app = express();

app.post(
 '/webhooks/provider',
 express.raw({ type: 'application/json' }),
 (req, res) => {
   // req.body is now a Buffer of the exact bytes sent
   const rawBody = req.body;
   // ... verify signature against rawBody
 }
);

In FastAPI, use await request.body() before any Pydantic parsing. In Go's net/http, read r.Body into a byte slice and don't call json.Decode on the request directly.

If your framework has already parsed the body by the time your handler runs, you cannot verify the signature reliably. Fix that first.

Step 3 — Parse the signature header

Each provider formats the signature header differently. Three common shapes:

Provider
Header
Format

Stripe

`Stripe-Signature`

`t=1614...,v1=5257a...` (timestamp and signature, comma-separated)

GitHub

`X-Hub-Signature-256`

`sha256=5257a...` (prefix, then hex digest)

Shopify

`X-Shopify-Hmac-Sha256`

Raw base64 digest


Read the sender's documentation carefully — the header name and format are provider-specific and getting either wrong will silently fail verification. Extract the timestamp (if included) and the signature bytes into named variables before doing anything else. That makes the rest of the code readable and easier to test.

function parseStripeSignature(header) {
 const parts = Object.fromEntries(
   header.split(',').map((p) => p.split('='))
 );
 return { timestamp: parts.t, signature: parts.v1 };
}

Note: this simplified snippet ignores secret rotation. During rotation, Stripe sends multiple v1= entries (one per active secret) and Object.fromEntries will keep only the last. A production parser should collect every v1= value into an array and try each against your computed signature.

Step 4 — Compute the expected signature

Build the string the sender signed, then run HMAC-SHA256 over it using your shared secret.

For Stripe, the signed payload is ${timestamp}.${rawBody}. For GitHub, it's just the raw body. Again — check the sender's docs. Getting the signed string wrong is the second most common bug after parsing the wrong body.

import crypto from 'node:crypto';

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

Run this against a test webhook and log both the computed and received signatures. They should match exactly. If they don't, the problem is almost always in step 2 (wrong bytes) or the signed-string construction — not the crypto library.

Step 5 — Compare signatures in constant time

Do not use === or == to compare signatures.

A normal string comparison returns as soon as it finds a mismatched character. An attacker who can send many requests and measure response time can use that timing difference to guess the signature one character at a time. This is a real, exploited attack, not a theoretical one.

Use a constant-time comparison function that always takes the same time regardless of where the mismatch occurs:

function verify(expected, received) {
 const a = Buffer.from(expected, 'hex');
 const b = Buffer.from(received, 'hex');
 if (a.length !== b.length) return false;
 return crypto.timingSafeEqual(a, b);
}

Python has hmac.compare_digest. Go has hmac.Equal. Ruby has OpenSSL.fixed_length_secure_compare in the stdlib, or Rack::Utils.secure_compare if you're already using Rack. Every mainstream language ships one — in the stdlib or a standard framework — so use it.

Step 6 — Reject replayed requests

A valid signature proves authenticity. It does not prove freshness. An attacker who captures one legitimate webhook can replay it forever.

If the provider includes a timestamp in the signed payload (Stripe does; GitHub does not), reject requests where the timestamp is more than five minutes old:

const FIVE_MINUTES = 5 * 60 * 1000;

function isFresh(timestamp) {
 const eventTime = parseInt(timestamp, 10) * 1000;
 return Math.abs(Date.now() - eventTime) < FIVE_MINUTES;
}

If the provider doesn't sign a timestamp, you need an application-level defence: store the event ID from the payload and reject any ID you've seen before. That gets you into idempotency territory, which is a whole discipline of its own — see our guide on API idempotency for AI agents for how to build the storage and lookup properly.

Step 7 — Put the full handler together

Here's the complete verified webhook handler, Stripe-style, with the pieces from every step above:

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

const app = express();
const SECRET = process.env.WEBHOOK_SECRET;
const TOLERANCE_MS = 5 * 60 * 1000;

app.post(
 '/webhooks/provider',
 express.raw({ type: 'application/json' }),
 (req, res) => {
   const header = req.get('Stripe-Signature');
   if (!header) return res.status(400).send('missing signature');

   const parts = Object.fromEntries(
     header.split(',').map((p) => p.split('='))
   );
   const { t: timestamp, v1: received } = parts;

   if (Math.abs(Date.now() - parseInt(timestamp, 10) * 1000) > TOLERANCE_MS) {
     return res.status(400).send('timestamp outside tolerance');
   }

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

   const a = Buffer.from(expected, 'hex');
   const b = Buffer.from(received, 'hex');
   if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
     return res.status(400).send('invalid signature');
   }

   const event = JSON.parse(req.body.toString('utf8'));
   // handle event...
   res.status(200).send('ok');
 }
);

Respond 200 fast, then process asynchronously. Webhook senders retry on any non-2xx or timeout, and doing real work inside the handler is the second-fastest way to blow your rate limits (the fastest is not verifying at all and getting DDoS'd).

Common pitfalls

Signing the parsed body. Covered in step 2. If verification fails intermittently on payloads with unusual characters, this is almost always why.

Storing the secret in code. Signing secrets belong in a secrets manager (AWS Secrets Manager, Vault, Doppler). Rotating a leaked secret in production is straightforward. Rotating a secret hard-coded across seventeen services is not.

One secret across every environment. Use separate signing secrets for staging and production. A leaked staging secret should never let anyone forge production events.

Ignoring the timestamp because it's inconvenient. Replay attacks are cheap. If your provider signs a timestamp, verify it.

Trusting the signature and skipping business validation. A valid signature means the sender sent it. It does not mean the event is one you should act on. Check that the customer ID, resource state, and event type match what your system expects before doing anything with side effects.

Not testing the failure path. Write tests that send a webhook with a bad signature, an old timestamp, a missing header, and a tampered body. All four should return 400. If any return 200, your handler is broken and you won't find out until you're the top story on Hacker News.

For a broader look at making webhook delivery itself reliable — retries, dead-letter queues, delivery guarantees — see our companion piece on webhook reliability patterns. And if you're still deciding whether to use webhooks at all, the webhook vs polling decision guide covers when each pattern fits.

Join our weekly newsletter

Stay up to date on the ever changing agentic landscape.

POSTS

Related content

Agent infrastructure

Platform integration

Webhook reliability patterns: how to deliver events agents can actually trust

9 minute read

Platform integration

API strategy

Webhook vs polling for integrations: a decision guide with working setup

7 minute read

Agent infrastructure

API strategy

API idempotency for AI agents: a practical guide to safe tool retries

7 minute read