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
tandv1inStripe-Signature, hex-encoded, with a 5-minute timestamp tolerance - GitHub sends
sha256=…inX-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:
- Parse
tand thev1values from the header - Reject if
tis older than about 5 minutes (replay protection) - Compute
HMAC_SHA256(signing_secret, t + "." + raw_body) - 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 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.