Webhook Signature Verification (HMAC-SHA256) in Node, Python, Ruby — 2026 Guide
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. 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-parser rebuilds 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.timingSafeEqual in Node, hmac.compare_digest in Python, Rack::Utils.secure_compare in 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. 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. 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 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. 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 # 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 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. 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). 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 like ngrok if 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. 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 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.
