SecurityApril 18, 20269 min read

Verifying Stripe, GitHub, and Shopify Webhook Signatures

Verifying webhook signatures across providers: what goes wrong, how timestamp tolerance works, and why retries break naive implementations.

THREE SIGNATURES, ONE YOU HAVE TO VERIFYprovidersigns raw bytesStripe-Signatureedgeverifies, persistsre-signseach attemptfresh timestampyouforwarded provider headers keep their original timestamp, so they expire on retriesAnyHook-Signature is re-signed per attempt, so the 5-minute tolerance always holds
Three signatures exist. Your handler should only ever have to check one.

If you're processing webhooks without verifying signatures, anyone on the internet can forge a payment_intent.succeeded against your endpoint. This isn't 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 it wrong, and how to handle retry timestamps cleanly.

TL;DR

  • Stripe, GitHub, and Shopify all use HMAC-SHA256 over the raw body bytes. Don't re-serialize JSON before hashing
  • Stripe sends t and v1 in Stripe-Signature, hex-encoded, with a 5-minute timestamp tolerance
  • GitHub sends sha256=… in X-Hub-Signature-256, with 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: that the request came from the provider rather than an attacker, and that the body wasn't modified in transit.

It is worth being precise about what that leaves out, because a green test suite here is weaker evidence than it looks. Your Webhook Signature Tests Prove Nothing has the fixtures that pass against verification code an attacker walks straight through. And a signature says nothing about whether an event arrived at all, which is why a dropped revocation webhook is a security incident rather than a gap in a chart, and why the log that records what did arrive is worth making tamper-evident.

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 about 5 minutes (replay protection)
  3. Compute HMAC_SHA256(signing_secret, t + "." + raw_body)
  4. Constant-time compare against each v1, and accept if any match
import crypto from "node:crypto";

// timingSafeEqual throws on inputs of different lengths, so check that first
function safeEqual(a: string, b: string): boolean {
  const x = Buffer.from(a);
  const y = Buffer.from(b);
  return x.length === y.length && crypto.timingSafeEqual(x, y);
}

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) => safeEqual(expected, sig));
}
THE HMAC IS OVER BYTES, NOT OVER MEANINGwire bytes{"id":"evt_1","n":2}req.text()hmacmatchesparsed + restringified{ "n": 2, "id": "evt_1" }req.json()hmacdoes not matchsame datasame data. different key order, different whitespace,and the payload looks perfect in your logs.
Both objects are the same event. Only one of them still hashes to the signature the provider sent.

The common mistake here is parsing the body as JSON first and then stringifying it back. That changes whitespace. The signature is over the raw bytes, so you have to 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 safeEqual(expected, header);
}

Simpler than Stripe, with no timestamp and no replay protection. GitHub relies on the secret staying secret.

Shopify

Shopify sends X-Shopify-Hmac-SHA256 as base64 rather than 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 safeEqual(expected, header);
}

Same trap as Stripe: passing the body through a JSON serializer before hashing will break it. Hash the raw bytes.

The retry-timestamp problem

Stripe's 5-minute timestamp tolerance means that if Stripe retried an event 10 minutes after the first attempt with the original header, its timestamp would be 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 that sit behind a queue, you can end up replaying events with a stale timestamp. The fix is to 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 on a first attempt (on a retry or replay they carry the provider's original timestamp), but AnyHook also signs every outbound delivery with its own AnyHook-Signature header. That header is HMAC-SHA256, hex-encoded, in the Stripe-compatible shape t={ts},v1={hex}. It's re-signed on every retry with a fresh timestamp, so 5-minute tolerance works correctly even during long retry cascades, and it's keyed on a per-destination signing secret, so compromising one endpoint's secret doesn't expose the 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 safeEqual(expected, v1);
}

One verification function instead of three, and 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, and 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)

The raw-body item is the one that bites hardest on serverless, because the framework consumes the stream before your code runs. Receiving Webhooks on Vercel Without Losing Them has the App Router and Pages Router versions, plus the middleware matcher that silently breaks it.

Takeaway

Most providers use HMAC-SHA256 over the raw body; Twilio, Intercom, and Vercel use HMAC-SHA1, and Discord, SendGrid, and PayPal use public-key signatures. Among the HMAC ones the differences are encoding (hex vs base64) and whether a timestamp is included, and every scheme we have implemented is tabulated in the webhook provider reference. 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, an event log and one-click replay. Or see it first: sign up and run the 30-second outage drill, and watch an event survive your server being down. No server needed.