t impacting throughput.
Core Solution
The universal HMAC-SHA256 verification algorithm follows four deterministic steps:
- Capture the RAW request body (bytes, not parsed JSON).
- Compute
HMAC-SHA256(body, secret) β produces 32 bytes.
- Hex-encode or Base64-encode the digest to match the provider's header format.
- Compare against the signature header using a constant-time comparison.
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
}
Stripe-Specific: Timestamp + Signature
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();
},
);
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
GitHub Specific Handling:
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
Shopify-Specific Quirk: Shopify webhooks use Base64 encoding instead of Hex. Decode the header value or compute the expected signature using Base64.strict_encode64 before comparison.
Pitfall Guide
- Verifying After Body Parsing: Framework middleware reconstructs JSON, altering whitespace, key order, and encoding. Compute HMAC against the raw byte stream (
express.raw, request.body(), request.raw_post) before any deserialization.
- Using Standard Equality (
===) for Comparison: String comparison short-circuits on the first mismatched character, leaking timing information. Always use constant-time comparison (crypto.timingSafeEqual, hmac.compare_digest, Rack::Utils.secure_compare).
- Reusing Secrets Across Environments/Endpoints: Shared secrets create a single point of failure. If a staging secret leaks, production is compromised. Generate unique signing secrets per endpoint and per environment.
- Hardcoding Secrets in Source Control: Committing secrets to VCS exposes them to all repository collaborators and CI logs. Store secrets in environment variables or a dedicated secrets manager. Rotate immediately if exposed.
- Ignoring Provider-Specific Signature Formats: Providers attach prefixes (
sha256= for GitHub) or use different encodings (Base64 for Shopify, Hex for Stripe). Strip prefixes and match encoding before comparison.
- Omitting Timestamp Validation: Without timestamp validation, captured signatures can be replayed indefinitely. Validate the timestamp window (e.g., Β±5 minutes) and reject stale payloads to mitigate replay attacks.
Deliverables
- π Blueprint: Raw Body Capture & HMAC Verification Architecture Diagram (covers middleware ordering, byte-stream routing, constant-time comparison flow, and timestamp validation boundaries)
- β
Checklist: Pre-Deployment Webhook Verification Audit
- βοΈ Configuration Templates:
express.raw() middleware stack for Express/Next.js
- FastAPI/Flask async request body extraction pattern
- Rails
skip_before_action + raw_post controller skeleton
- Environment variable mapping schema (
WEBHOOK_SECRET, STRIPE_WEBHOOK_SECRET, etc.)