I review a lot of webhook handlers. Roughly 3 out of 5 either have a subtle signature-verification bug — or someone disabled verification entirely "to make it work." Both leave a public POST endpoint that anyone with the URL can fire fake events at.
If your handler refunds a customer, sends an email, or flips a feature flag, that's a real problem.
This guide is the version I wish someone had handed me on day one: a single HMAC-SHA256 verifier in Node, Python, and Ruby — plus the 6 specific gotchas that break otherwise-correct code on Stripe, GitHub, Shopify, Slack, Twilio, Square, Vercel, HubSpot, Mailgun, SendGrid, Discord, Plaid, and Clerk.
Quick recipe: take the raw request body, compute HMAC-SHA256 with the provider's signing secret, compare against the signature header using a constant-time comparison. That's it. Everything below is just adapting that recipe to specific providers and languages.
Why this matters (and where it goes wrong)
Without signature verification, your webhook handler accepts any POST request that hits your endpoint. An attacker who guesses or scans your URL can fabricate Stripe payment events, GitHub pull request events, etc., and trigger your downstream logic. The damage scales with what your handler does: refunding the wrong customer, creating fake admin accounts, double-firing email campaigns.
The most common mistakes I see in code reviews:
-
Verifying after the body is parsed. Express's
body-parserrebuilds the JSON, then your HMAC computes against the rebuilt string — which differs by even one whitespace character from the original. The signature mismatches, you log a false-positive failure, and you eventually disable verification "to make it work." Don't. -
Using
===to compare signatures. Allows timing attacks. Use a constant-time compare (crypto.timingSafeEqualin Node,hmac.compare_digestin Python,Rack::Utils.secure_comparein Ruby). - Re-using one secret across endpoints / environments. If your test secret leaks, prod is also at risk. Each endpoint in each environment should have its own secret.
- Storing the secret in source code. Use environment variables. If it's already in a commit, rotate it.
The general algorithm
Every HMAC-SHA256 webhook verifier does these four steps:
1. Read the RAW request body (bytes, not parsed JSON).
2. Compute HMAC-SHA256(body, secret) → produces 32 bytes.
3. Hex-encode (or base64-encode) the 32 bytes — match what the provider uses.
4. Compare to the signature header using a constant-time comparison.
Some providers (Stripe) include a timestamp in the signing payload to prevent replay attacks. We'll cover that below.
Node.js (Express): generic HMAC-SHA256 verifier
import crypto from "node:crypto";
import express from "express";
const app = express();
// CRITICAL: capture the raw body so we can verify the signature.
// Do this BEFORE any JSON parser middleware runs.
app.post(
"/webhook",
express.raw({ type: "application/json" }),
(req, res) => {
const signature = req.header("X-Webhook-Signature");
if (!signature) return res.status(400).send("Missing signature");
const expected = crypto
.createHmac("sha256", process.env.WEBHOOK_SECRET)
.update(req.body) // req.body is a Buffer here, not a parsed object
.digest("hex");
// Constant-time compare to prevent timing attacks
const sigBuf = Buffer.from(signature, "hex");
const expBuf = Buffer.from(expected, "hex");
if (
sigBuf.length !== expBuf.length ||
!crypto.timingSafeEqual(sigBuf, expBuf)
) {
return res.status(401).send("Invalid signature");
}
// Now safe to parse and process
const event = JSON.parse(req.body.toString("utf8"));
handleEvent(event);
res.status(200).send("OK");
},
);
function handleEvent(event) {
// Your business logic
}
The key trick is express.raw({ type: "application/json" }) — this captures the bytes as a Buffer before body-parser would convert them to an object. The signature is computed against the original byte stream, not the rebuilt one.
Stripe-specific: timestamp + signature
Stripe webhooks include a timestamp to prevent replay attacks. The signed string is ${timestamp}.${body}, not just ${body}.
import Stripe from "stripe";
import express from "express";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);
app.post(
"/stripe/webhook",
express.raw({ type: "application/json" }),
(req, res) => {
const signature = req.header("stripe-signature");
let event;
try {
event = stripe.webhooks.constructEvent(
req.body,
signature,
process.env.STRIPE_WEBHOOK_SECRET,
);
} catch (err) {
return res.status(400).send(`Webhook Error: ${err.message}`);
}
// event is verified — safe to process
if (event.type === "checkout.session.completed") {
// your logic
}
res.status(200).send();
},
);
The Stripe SDK handles all the timestamp + dual-secret + signature parsing for you. Just make sure you pass the raw body.
Python (FastAPI / Flask)
import hmac
import hashlib
import os
from fastapi import FastAPI, Request, HTTPException
app = FastAPI()
@app.post("/webhook")
async def webhook(request: Request):
signature = request.headers.get("X-Webhook-Signature")
if not signature:
raise HTTPException(status_code=400, detail="Missing signature")
body = await request.body() # raw bytes
expected = hmac.new(
os.environ["WEBHOOK_SECRET"].encode("utf-8"),
body,
hashlib.sha256,
).hexdigest()
# Constant-time compare
if not hmac.compare_digest(signature, expected):
raise HTTPException(status_code=401, detail="Invalid signature")
# Now safe to parse
import json
event = json.loads(body)
handle_event(event)
return {"status": "ok"}
def handle_event(event):
pass # your logic
request.body() (FastAPI) and request.get_data() (Flask) both return the raw bytes — exactly what you need for HMAC verification.
For GitHub specifically, the header is X-Hub-Signature-256 and the value is prefixed with sha256=. Strip the prefix:
signature = request.headers.get("X-Hub-Signature-256", "")
if not signature.startswith("sha256="):
raise HTTPException(status_code=400)
sig_value = signature.removeprefix("sha256=")
# Then compare sig_value to expected as before
Ruby (Rails / Sinatra)
# config/routes.rb (Rails)
post "/webhook", to: "webhooks#receive"
# app/controllers/webhooks_controller.rb
class WebhooksController < ApplicationController
skip_before_action :verify_authenticity_token
def receive
signature = request.headers["X-Webhook-Signature"]
return head :bad_request unless signature
body = request.raw_post # raw bytes, BEFORE Rails JSON parsing
expected = OpenSSL::HMAC.hexdigest(
"SHA256",
ENV.fetch("WEBHOOK_SECRET"),
body
)
# Constant-time compare
return head :unauthorized unless Rack::Utils.secure_compare(signature, expected)
event = JSON.parse(body)
handle_event(event)
head :ok
end
private
def handle_event(event)
# your logic
end
end
request.raw_post (Rails) and request.body.read (Sinatra/Rack) give you the raw bytes. Rack::Utils.secure_compare is constant-time.
Shopify-specific quirk: base64 not hex
Shopify webhooks sign with HMAC-SHA256 but encode in base64, not hex. The verification:
const expected = crypto
.createHmac("sha256", secret)
.update(body)
.digest("base64"); // ← base64, not hex
The header is X-Shopify-Hmac-Sha256.
Per-service signature guides (13 services, 5 algorithm classes)
Different vendors use very different signing models. Most are HMAC-SHA256, but a few break the pattern in ways that catch out copy-pasted verifiers:
| Service | Algorithm | Notable quirk |
|---|---|---|
| Stripe | HMAC-SHA256 (hex) |
t={ts},v1={sig} composite header, 5-min replay window |
| GitHub | HMAC-SHA256 (hex) |
sha256= prefix; never trust the legacy SHA-1 header |
| Shopify | HMAC-SHA256 (base64) | Common copy-paste bug: .digest('hex') instead of 'base64'
|
| Slack | HMAC-SHA256 (hex) | Signs v0:{ts}:{body} — timestamp window is mandatory |
| Twilio | HMAC-SHA1 (base64) | URL-based signing; reverse proxies break it |
| Square | HMAC-SHA256 (base64) | Signs {notification_url}{body} — URL is part of message |
| Vercel | HMAC-SHA1 (hex) | Different secret per source (account / integration / drain) |
| HubSpot | HMAC-SHA256 (base64) | v3 signs {method}{URI}{body}{timestamp}
|
| Mailgun | HMAC-SHA256 (hex) | Signature is in the JSON body, not headers |
| SendGrid | ECDSA (base64) | Public-key crypto — no shared secret |
| Discord | Ed25519 | Public-key signature on {ts}{body}
|
| Plaid | JWT/ES256 | Header is a full JWT; fetch public key by kid
|
| Clerk | HMAC-SHA256 (Svix) | Three headers: svix-id, svix-timestamp, svix-signature
|
If your vendor isn't here, the generic HMAC verifier above covers ~70% of cases. The exceptions to watch for: base64-vs-hex (Shopify, Square), composite signed strings (Stripe, Slack, Square, HubSpot), public-key (Discord, SendGrid, Plaid), or the signature living in the body instead of the headers (Mailgun).
How to test signature verification without deploying
Verifying signatures locally is the part most engineers get wrong because the secret + raw-body combination is finicky. Two recommended workflows:
Option A: capture real webhooks with HookRay, replay locally
I built this workflow because the alternatives drove me nuts:
- Get a free HookRay URL (no signup).
- Paste it into Stripe / GitHub / Shopify dashboard webhook settings.
- Trigger a test event. HookRay captures the raw body + headers exactly as sent (including
X-Hub-Signature-256,stripe-signature, etc.). - Use HookRay's Replay feature to re-send the captured webhook to
http://localhost:3000/webhook(with a tunnel likengrokif needed, or use HookRay Pro to forward directly). - Your local code receives the EXACT same bytes Stripe/GitHub sent. If verification fails, the bug is in your code, not in transmission.
This isolates "is my code right?" from "is the network mangling the body?" — by far the most common source of false-negative failures.
Option B: use the provider's CLI (Stripe / GitHub specific)
Stripe: stripe listen --forward-to localhost:3000/webhook lets the Stripe CLI forward real test events directly to your local server.
GitHub: install smee.io or use the official GitHub CLI gh webhook forward.
These work but lock you to one provider's tooling.
Common verification failures (with fixes)
| Symptom | Cause | Fix |
|---|---|---|
| "signature mismatch" but you copy-pasted the secret | Body was JSON-parsed before HMAC | Use raw body / Buffer / bytes |
| Stripe SDK throws "No signatures found matching..." | Wrong secret (test vs. live, or wrong endpoint) | Each Stripe endpoint has its own secret — copy from the correct one |
GitHub X-Hub-Signature-256 doesn't match |
Forgot sha256= prefix in header value |
Strip the prefix before comparison |
| Shopify mismatch despite correct secret | Hex vs. base64 encoding | Use digest("base64") for Shopify |
| Works locally, fails in production | Different secret in env vars | Sync env vars; rotate secret if leaked |
| Intermittent failures (some events pass, some fail) | Body parser middleware running before raw capture in some routes | Add raw-body middleware ONLY to webhook routes |
Summary
- Raw body always. Never compute HMAC against re-parsed JSON.
-
Constant-time compare always.
===,==, or string equality leak timing information. - One secret per environment per endpoint. Rotate on leak.
- Test with real captured payloads. HookRay or the provider's CLI both work.
Signature verification is the security half of a robust receiver. The reliability half — idempotency, retries, dead-letter handling — is a whole separate beast that I covered in Webhook Retry Strategies (2026): Idempotency, Backoff, Dead Letters.
If you want a free webhook URL to test signature verification with, HookRay gives you one in 5 seconds — no signup, captures raw payload + signature headers exactly as sent.
If you found this useful, drop a 🔖 — and tell me in the comments which provider's signing scheme has hurt you the most. I'm building a service-by-service guide and the wildest stories tend to point at the worst documentation.
Top comments (0)