SecurityApril 18, 20269 min read

Webhook Signature Verification: Stripe, GitHub, Shopify (and One Header for All)

A practical guide to verifying webhook signatures across providers — what can go wrong, how timestamp tolerance works, and why retries break naive implementations.

If you're processing webhooks without verifying signatures, anyone on the internet can forge a payment_intent.succeeded against your endpoint. This is not theoretical — it's the most common auth hole in webhook integrations, and attackers know it.

This post is a working-engineer's guide to signature verification. What each provider does, the subtle ways implementations get wrong, and how to handle retry timestamps cleanly.

TL;DR

  • Every provider uses HMAC-SHA256 over the raw body bytes — don't re-serialize JSON before hashing
  • Stripe sends t + v1 in Stripe-Signature, hex-encoded, with 5-min timestamp tolerance
  • GitHub sends sha256=… in X-Hub-Signature-256, no timestamp
  • Shopify sends base64 HMAC-SHA256 in X-Shopify-Hmac-SHA256
  • Always use constant-time compare (crypto.timingSafeEqual), never ===
  • If retries are breaking your timestamp check, you want a relay that re-signs on every attempt

Why providers sign webhooks

Your webhook URL is public. Anything with your URL can POST to it. The signature proves two things:

  1. Authenticity — this request came from the provider, not an attacker
  2. Integrity — the body wasn't modified in transit

Without verification, someone who guesses or discovers your webhook URL can send you fake events. With verification, they'd also need the signing secret — which only you and the provider have.

Stripe

Stripe sends a Stripe-Signature header that looks like:

t=1617223220,v1=3b8a92b...,v1=9d7ac...
  • t is the timestamp when the webhook was sent
  • v1 is HMAC-SHA256 of {timestamp}.{raw_body}, hex-encoded
  • Multiple v1 entries exist during key rotation

Verification steps:

  1. Parse t and the v1 values from the header
  2. Reject if t is older than ~5 minutes (replay protection)
  3. Compute HMAC_SHA256(signing_secret, t + "." + raw_body)
  4. Constant-time compare against each v1 — accept if any match
import crypto from "node:crypto";

function verifyStripe(body: string, header: string, secret: string): boolean {
  const parts = Object.fromEntries(
    header.split(",").map((p) => p.split("=").slice(0, 2) as [string, string])
  );
  const t = parts.t;
  const v1s = header.match(/v1=([a-f0-9]+)/g)?.map((x) => x.slice(3)) ?? [];

  if (Math.abs(Date.now() / 1000 - Number(t)) > 300) return false;

  const expected = crypto
    .createHmac("sha256", secret)
    .update(`${t}.${body}`)
    .digest("hex");

  return v1s.some((sig) =>
    crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(sig))
  );
}

Common mistake: parsing the body as JSON first, then stringifying it back. That changes whitespace. The signature is over the raw bytes, so you must verify against the exact bytes you received.

GitHub

GitHub sends both X-Hub-Signature (SHA1, legacy) and X-Hub-Signature-256 (SHA256). Always use the 256 version.

Format: sha256=3b8a92b...

Verification:

function verifyGitHub(body: string, header: string, secret: string): boolean {
  const expected = "sha256=" + crypto
    .createHmac("sha256", secret)
    .update(body)
    .digest("hex");
  return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(header));
}

Simpler than Stripe — no timestamp, no replay protection. GitHub relies on the secret being secret.

Shopify

Shopify sends X-Shopify-Hmac-SHA256 as base64 (not hex) HMAC-SHA256 of the raw body.

function verifyShopify(body: string, header: string, secret: string): boolean {
  const expected = crypto
    .createHmac("sha256", secret)
    .update(body)
    .digest("base64");
  return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(header));
}

Common mistake: passing the Shopify body through a JSON serializer before hashing. Same story as Stripe — hash the raw bytes.

The retry-timestamp problem

Stripe's 5-minute timestamp tolerance means: if Stripe retries an event 10 minutes after the first attempt, the timestamp in the Stripe-Signature header is stale by the retry logic's own standard.

In practice, Stripe regenerates the signature on each retry with a fresh t, so this works. But in systems that cache-and-replay or sit behind a queue, you can end up replaying events with a stale timestamp. The fix: re-sign with a fresh timestamp at the boundary where you replay, using your own signing key.

That's exactly what AnyHook does. The original provider headers are forwarded intact so your verification code still works if you want it to, but AnyHook also signs every outbound delivery with its own AnyHook-Signature header. This header is:

  • HMAC-SHA256, hex-encoded
  • Format: t={ts},v1={hex} (Stripe-compatible shape)
  • Re-signed on every retry with a fresh timestamp — so 5-minute tolerance works correctly even during long retry cascades
  • Keyed on a per-destination signing secret, so compromising one endpoint's secret doesn't expose others
function verifyAnyHook(body: string, header: string, secret: string): boolean {
  const t = header.match(/t=(\d+)/)?.[1];
  const v1 = header.match(/v1=([a-f0-9]+)/)?.[1];
  if (!t || !v1) return false;
  if (Math.abs(Date.now() / 1000 - Number(t)) > 300) return false;

  const expected = crypto
    .createHmac("sha256", secret)
    .update(`${t}.${body}`)
    .digest("hex");
  return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(v1));
}

One verification function instead of three. No timestamp weirdness on retry. The provider still signs the original request; AnyHook verifies that at the edge, then re-signs each delivery with its own fresh signature.

Checklist: things to get right

  • Verify against raw body bytes, not re-serialized JSON
  • Use constant-time compare (crypto.timingSafeEqual), not ===
  • Reject requests where the signature header is missing — don't default to "allow"
  • Timestamp tolerance: 5 minutes is standard; shorter is more secure but breaks legitimate retries
  • Store signing secrets encrypted at rest, not in env vars on developer laptops
  • Rotate signing secrets on a schedule, and handle the rotation window (both old and new keys valid for 24h)

Takeaway

Every provider uses HMAC-SHA256 over the raw body. The differences are encoding (hex vs base64) and whether a timestamp is included. If you want one header to verify instead of three, point your providers at a relay — AnyHook handles the fan-in and re-signs each outbound delivery with a single consistent scheme.

All postsApril 18, 2026 · 9 min

Stop losing webhooks.

Change one URL. Get retries, event log, and one-click replay.