·8 min read·webhook-security, hmac, signature-verification, stripe, github, shopify, nodejs, python, ruby

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:

  1. 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.
  2. 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).
  3. 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.
  4. 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

  1. Get a free HookRay URL (no signup).
  2. Paste it into Stripe/GitHub/Shopify dashboard webhook settings.
  3. Trigger a test event. HookRay captures the raw body + headers exactly as sent (including X-Hub-Signature-256, stripe-signature, etc.).
  4. 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).
  5. 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)

SymptomCauseFix
"signature mismatch" but you copy-pasted the secretBody was JSON-parsed before HMACUse 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 matchForgot sha256= prefix in header valueStrip the prefix before comparison
Shopify mismatch despite correct secretHex vs. base64 encodingUse digest("base64") for Shopify
Works locally, fails in productionDifferent secret in env varsSync env vars; rotate secret if leaked
Intermittent failures (some events pass, some fail)Body parser middleware running before raw capture in some routesAdd 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