Dev.to Security πŸ” Cybersecurity πŸ‘ 0 πŸ“– 2 min read

Verify webhook signatures correctly: Stripe, Shopify, GitHub and Gumroad side by side

Every major provider signs webhooks with HMAC, and almost every integration bug comes from the same three mistakes: hashing a re-serialized body, comparing strings unsafely, and processing the same event twice.

Every major provider signs webhooks with HMAC, and almost every integration bug comes from the same three mistakes: hashing a re-serialized body, comparing strings unsafely, and processing the same event twice.

1. Hash the raw bytes, never parsed JSON

JSON.parse β†’ JSON.stringify changes whitespace and key order, so the HMAC won't match. Capture the raw body before any JSON middleware.

// Express: mount this BEFORE app.use(express.json())
app.post("/webhooks/stripe", express.raw({ type: "application/json" }), handler);

Next.js App Router: use await req.text(), not req.json().

2. Each provider, one function

import crypto from "node:crypto";
const safeEq = (a, b) => a.length === b.length && crypto.timingSafeEqual(Buffer.from(a), Buffer.from(b));

// Stripe: header "t=...,v1=..."; signed payload = `${t}.${raw}`, hex
export function stripe(raw, header, secret, tolerance = 300) {
  const parts = Object.fromEntries(header.split(",").map(p => p.split("=")));
  if (Math.abs(Date.now() / 1000 - Number(parts.t)) > tolerance) return false;
  const sig = crypto.createHmac("sha256", secret).update(`${parts.t}.${raw}`).digest("hex");
  return safeEq(sig, parts.v1);
}

// Shopify: X-Shopify-Hmac-Sha256, base64 of HMAC over raw body
export const shopify = (raw, h, secret) =>
  safeEq(crypto.createHmac("sha256", secret).update(raw).digest("base64"), h);

// GitHub: X-Hub-Signature-256 = "sha256=" + hex
export const github = (raw, h, secret) =>
  safeEq("sha256=" + crypto.createHmac("sha256", secret).update(raw).digest("hex"), h);

In production prefer the official SDK where one exists (stripe.webhooks.constructEvent), but knowing the shape helps you debug it.

3. Use a timing-safe comparison

sig === header leaks how many leading characters matched. crypto.timingSafeEqual (Node), hmac.compare_digest (Python) and subtle.ConstantTimeCompare (Go) don't. Check lengths first; timingSafeEqual throws on unequal lengths.

4. Reject replays

Stripe includes a timestamp in the signed payload, so enforce a window (5 minutes is the SDK default). For providers without one, rely on step 5.

5. Be idempotent

Providers retry on timeouts, so you will receive duplicates.

CREATE TABLE processed_events (id text PRIMARY KEY, received_at timestamptz DEFAULT now());
-- INSERT ... ON CONFLICT DO NOTHING; only act if a row was inserted

Respond 2xx fast and move slow work to a queue, or the provider will time out and retry anyway.

Debug checklist

  • Using the endpoint's signing secret (whsec_...), not your API key?
  • Test-mode vs live-mode secret mixed up?
  • A proxy (Cloudflare, API Gateway) modifying or re-encoding the body?
  • Body read as a UTF-8 string when it contained non-UTF-8 bytes? Hash the Buffer.

I keep a one-page Webhook Signature Cheat Sheet (free) with the header names, encodings and gotchas for all four providers. If you want the whole toolchain (a self-hosted relay that verifies and retries, AI coding-assistant skills for webhook work, and the CORS kit), there's a Webhook & CORS Fix Bundle.

πŸ“° Read the original article on Dev.to Security

Originally published by Dev.to Security. Aggregated on AIWithGhost for educational purposes β€” full credit and traffic to the original publisher.