Webhook Signature Verifier
Debug HMAC webhook signatures locally — Stripe, GitHub and generic SHA-256 presets.
Header: Stripe-Signature: t=...,v1=<hex>. Paste only the v1 hex below.
Fill in the payload, secret and received signature — the verdict computes live via WebCrypto.
Computed signature (hex)
—
Stripe signs `${t}.${payload}` and rejects timestamps older than the tolerance (default 5 min) to bound replay attacks.
import { createHmac, timingSafeEqual } from "node:crypto";
export function verifyStripe(rawBody: string, sigHeader: string, secret: string, toleranceSec = 300): boolean {
const parts = Object.fromEntries(sigHeader.split(",").map((p) => p.split("=") as [string, string]));
const t = Number(parts.t);
if (!t || Math.abs(Date.now() / 1000 - t) > toleranceSec) return false; // replay window
const expected = createHmac("sha256", secret).update(`${t}.${rawBody}`).digest("hex");
const a = Buffer.from(expected); const b = Buffer.from(parts.v1 ?? "");
return a.length === b.length && timingSafeEqual(a, b);
}How it works
Webhook signature verification fails for maddeningly invisible reasons: a body-parsing middleware that re-serialized your JSON, a hex digest compared against a base64 one, a timestamp prefix you didn't know the provider included. This tool makes the failure visible. Paste the payload exactly as received, the signing secret, and the signature from the header — and it computes the HMAC-SHA256 locally with WebCrypto, shows you the result alongside the expected value, and gives a clear match or mismatch verdict.
The provider presets encode the part documentation buries. For Stripe, the signed message is not the body alone but {timestamp}.{body}, with the timestamp taken from the t= field of the Stripe-Signature header and the digest hex-encoded as v1. For GitHub, the raw body is signed and the header value is prefixed with sha256=. The generic preset is a plain HMAC over the body with your choice of hex or base64 output, which covers Shopify-style (base64) and most home-grown schemes.
When the verdict is a mismatch, debug in this order. First, raw body: the provider signed the exact bytes on the wire, so any re-serialization — pretty-printing, key reordering, unicode normalization — breaks the HMAC. Capture req.rawBodyor equivalent. Second, encoding: hex versus base64 digests look nothing alike, and the tool shows the computed signature in the preset's expected encoding so you can eyeball the format. Third, the secret itself: webhook signing secrets are distinct from API keys, are per-endpoint in Stripe, and rotate when you recreate the endpoint.
The code snippets below the verdict are production-shaped: TypeScript and Python implementations using constant-time comparison, with the provider's exact signing string and the timestamp-tolerance check where the scheme includes one. Copy them as a starting point rather than hand-rolling the comparison — naive === on signatures is a timing-oracle anti-pattern, even if exploiting it over a network is hard in practice. Everything on this page runs in your browser; nothing is transmitted.
Frequently asked questions
What is the raw-body gotcha?
The provider signs the exact bytes it sent — before any framework touches them. If your server parses the JSON and re-serializes it (Express's json middleware, Rails params), key order, whitespace and unicode escaping can change, and the recomputed HMAC will never match. You must verify against the raw request body. Most signature failures in the wild are this bug, not a wrong secret.
How does Stripe's timestamp tolerance work?
Stripe's Stripe-Signature header carries a timestamp t and one or more v1 signatures. You sign the string `{t}.{raw_body}` and compare against v1, then reject if the timestamp is older than your tolerance — Stripe's SDKs default to 5 minutes. The tolerance bounds replay attacks: a captured webhook can only be replayed within that window. Skip the timestamp check and a valid signature stays valid forever.
How do providers differ in signing schemes?
The HMAC-SHA256 core is universal; the envelope varies. Stripe signs `timestamp.body` and sends `t=...,v1=hex`. GitHub signs the raw body and sends `sha256=hex` in X-Hub-Signature-256. Shopify signs the raw body but encodes the digest as base64 in X-Shopify-Hmac-Sha256. Slack signs `v0:timestamp:body`. The preset selector here reproduces each provider's exact signing string so you can compare like with like.
What protects against replay attacks?
A valid signature only proves the payload came from the provider at some point — an attacker who captures a webhook in transit can resend it verbatim and the signature still verifies. Defenses: enforce the timestamp tolerance (Stripe, Slack embed one in the signed string), track processed event IDs for idempotency, and make handlers idempotent so a duplicate delivery is harmless. Providers also retry legitimately, so idempotency is needed regardless.
Is it safe to paste my webhook secret here?
The computation runs entirely in your browser via the WebCrypto API — the secret, payload and signatures never leave the page, and you can verify zero network requests in devtools. That said, treat production secrets with care anywhere: prefer testing with a development-mode secret (Stripe's test mode, a GitHub test repo), and rotate any secret you suspect has been exposed in logs or screenshots.
Built by FORG — AI cost observability for agentic coding. Free tools, no signup, nothing leaves your browser.
Learn about FORG