SecurityApril 18, 20269 min read

Verifying Stripe, GitHub, and Shopify Webhook Signatures

A practical guide to verifying webhook signatures across providers: what can go 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

  • Every provider uses 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.

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";

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))
  );
}
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 crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(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 crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(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 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 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 if you want it to, 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 crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(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

Every provider uses HMAC-SHA256 over the raw body. The differences are encoding (hex vs base64) and whether a timestamp is included, and all seventeen schemes we have implemented are 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, event log, and one-click replay.