Webhook Signature Verification (HMAC-SHA256) in Node, Python, Ruby — 2026 Guide
If you don't verify the signature on incoming webhooks, anyone who knows your endpoint URL can POST fake events and trigger your business logic. This is the single most common webhook security mistake — and the easiest to fix.
This guide covers HMAC-SHA256 verification (used by Stripe, GitHub, Shopify, Slack, and ~80% of webhook providers) in three languages. The patterns are nearly identical across providers; the differences are mostly in the header name and small encoding details.
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 we 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. For more on Stripe-specific patterns, see our Stripe webhook best practices guide and the Stripe service page.
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
For more on testing GitHub webhooks specifically, see the GitHub service page.
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. See the Shopify service page for full event reference.
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
- 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. HookRay works the same way for all providers.
For comparison testing tools side-by-side, see the 7 best webhook testing tools (2026).
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.
If you're implementing webhook verification for a specific provider, see our service-specific guides:
For deeper Stripe-specific patterns (idempotency, retries, async processing), see Stripe webhook best practices.
If you're moving from Webhook.site to a tool that handles signature verification testing more cleanly, see the 60-second migration guide.
Get a free HookRay webhook URL → — no signup, captures raw payload + signature headers exactly as sent.
Ready to test your webhooks?
Get a free webhook URL in 5 seconds. No signup required.
Start Testing — Free