DEV Community

ShotaTanikawa
ShotaTanikawa

Posted on • Originally published at hookray.com

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.

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:

  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.
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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();
  },
);
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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:

  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.

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)