How to Verify Webhook Signatures (HMAC-SHA256): GitHub, Stripe and Slack
Verify GitHub, Stripe and Slack webhook signatures with HMAC-SHA256: raw body handling, constant-time comparison, replay windows, event-ID deduplication, working Node and Python code, and a table of why verification fails.
Primary source: authoritative reference
Compute HMAC-SHA256 over the exact bytes of the request body, using the secret the provider gave you, and compare the result to the signature header with a constant-time function. Stripe and Slack also sign a timestamp, so reject requests whose timestamp is more than a few minutes old. Everything else, such as which header to read and what string to sign, differs per provider and is covered below.
Check a signature in your browser
The Webhook Signature Verifier compares a payload, secret and signature for GitHub, Stripe or generic HMAC formats, locally in the browser tab. It shows whether the bytes you pasted match; it cannot show who sent a real request or whether it was replayed.
Open the Webhook Signature Verifier →The functions behind that page are in lib/webhook-verifier.ts. Run against GitHub's published test vector (secret It's a Secret to Everybody, payload Hello, World!) and a synthetic Stripe header, they return:
GitHub vector : true
GitHub, payload "Hello, World?": false
Stripe synthetic, 60 s old : {"signatureMatched":true,"timestamp":1700000000,"ageSeconds":60,"withinTolerance":true}
Stripe synthetic, 400 s old : {"signatureMatched":true,"timestamp":1700000000,"ageSeconds":400,"withinTolerance":false}
The Stripe values are synthetic, generated for this article and not produced by Stripe: secret whsec_synthetic_example_only, body {"id":"evt_synthetic_1","object":"event"}, header t=1700000000,v1=cb0ce34c4a56d6e4716e4b9a886c3beb19829e4d1d0c04be1192875efcc999ca. Paste them into the tool and the signature matches; the tool measures age against your device clock, so it will report the timestamp as outside tolerance.
Use the raw body
The signature covers bytes. If a framework parses the JSON and you serialize it again, key order, whitespace or escaping can change and the HMAC no longer matches. Stripe's troubleshooting guide lists it, and Slack's documentation says to use the body before it is deserialized. Read the body first, verify, then parse.
// Express: put the raw parser on the webhook route, and express.json() only after it
app.post("/webhooks/github", express.raw({ type: "application/json" }), (req, res) => {
const ok = verifyGitHub(req.body, req.get("X-Hub-Signature-256"), SECRET); // req.body is a Buffer
res.sendStatus(ok ? 204 : 401);
});
// Next.js route handler (app/api/webhooks/github/route.ts): use text(), never json()
const raw = await request.text();
# Flask: call get_data() before touching request.json or request.form
raw = request.get_data()
# FastAPI: read the body from the Request object, not from a Pydantic model
raw = await request.body()
What each provider signs
| GitHub | Stripe | Slack | |
|---|---|---|---|
| Header | X-Hub-Signature-256 | Stripe-Signature | X-Slack-Signature |
| Header value | sha256=<hex> | t=<unix seconds>,v1=<hex>[,v1=...] | v0=<hex> |
| Signed string | body | <t>.<body> | v0:<timestamp>:<body> |
| Timestamp | none | t= in the header | X-Slack-Request-Timestamp |
| Key | webhook secret | endpoint secret (whsec_...) | app signing secret |
Details from the docs: GitHub says the payload should be handled as UTF-8 (GitHub). Stripe tells you to ignore every scheme other than v1, and a header can carry several v1 values while a secret is being rolled (Stripe). Slack uses the signing secret as a plain UTF-8 string and notes that header names are case-insensitive.
Node.js
crypto.timingSafeEqual throws when the two inputs differ in length, so check lengths first. Tested on Node 22.
import { createHmac, timingSafeEqual } from "node:crypto";
const hmacHex = (secret, ...chunks) => {
const h = createHmac("sha256", secret);
chunks.forEach((c) => h.update(c)); // Buffer or string; bytes are hashed as given
return h.digest("hex");
};
function safeEqual(a, b) {
const x = Buffer.from(a);
const y = Buffer.from(b);
return x.length === y.length && timingSafeEqual(x, y);
}
const fresh = (ts, now, tolerance) =>
/^\d+$/.test(ts ?? "") && Math.abs(now - Number(ts)) <= tolerance;
export function verifyGitHub(rawBody, signatureHeader, secret) {
if (!signatureHeader) return false;
return safeEqual("sha256=" + hmacHex(secret, rawBody), signatureHeader);
}
export function verifyStripe(rawBody, header, secret, { tolerance = 300, now = Date.now() / 1000 } = {}) {
const pairs = (header ?? "").split(",").map((p) => p.trim().split("="));
const t = pairs.find(([k]) => k === "t")?.[1];
const v1 = pairs.filter(([k]) => k === "v1").map(([, v]) => v);
if (!fresh(t, now, tolerance) || v1.length === 0) return false;
const expected = hmacHex(secret, `${t}.`, rawBody);
return v1.some((sig) => safeEqual(expected, sig));
}
export function verifySlack(rawBody, timestamp, signature, secret, { tolerance = 300, now = Date.now() / 1000 } = {}) {
if (!signature || !fresh(timestamp, now, tolerance)) return false;
return safeEqual("v0=" + hmacHex(secret, `v0:${timestamp}:`, rawBody), signature);
}
Python
hmac.compare_digest is what the Python documentation recommends instead of == when comparing digests. Encoding both sides to bytes means odd input returns False instead of raising TypeError. Tested on Python 3.12 (needs 3.10+ for the type syntax).
import hashlib
import hmac
import time
def _hex(secret: str, *chunks: bytes) -> str:
mac = hmac.new(secret.encode("utf-8"), digestmod=hashlib.sha256)
for chunk in chunks:
mac.update(chunk)
return mac.hexdigest()
def _same(a: str, b: str) -> bool:
return hmac.compare_digest(a.encode("utf-8"), b.encode("utf-8"))
def _fresh(ts: str | None, now: float, tolerance: int) -> bool:
return bool(ts) and ts.isdigit() and abs(now - int(ts)) <= tolerance
def verify_github(raw_body: bytes, signature_header: str | None, secret: str) -> bool:
if not signature_header:
return False
return _same("sha256=" + _hex(secret, raw_body), signature_header)
def verify_stripe(raw_body: bytes, header: str | None, secret: str,
tolerance: int = 300, now: float | None = None) -> bool:
now = time.time() if now is None else now
pairs = [p.strip().split("=", 1) for p in (header or "").split(",") if "=" in p]
t = next((v for k, v in pairs if k == "t"), None)
v1 = [v for k, v in pairs if k == "v1"]
if not _fresh(t, now, tolerance) or not v1:
return False
expected = _hex(secret, f"{t}.".encode(), raw_body)
return any(_same(expected, sig) for sig in v1)
def verify_slack(raw_body: bytes, timestamp: str | None, signature: str | None, secret: str,
tolerance: int = 300, now: float | None = None) -> bool:
now = time.time() if now is None else now
if not signature or not _fresh(timestamp, now, tolerance):
return False
return _same("v0=" + _hex(secret, f"v0:{timestamp}:".encode(), raw_body), signature)
Both versions pass GitHub's published vector, reproduce the Slack documentation's worked example (v0=a2114d57...), and agree on the synthetic Stripe digest above. They reject a changed payload, a stale timestamp, a millisecond timestamp, a secret with a stray space or newline, and a body that was parsed and pretty-printed again.
Replay protection is not exactly-once
A valid signature stays valid when someone captures the request and sends it again. Stripe and Slack sign the timestamp, so a tolerance window limits how old a replay can be. Stripe's libraries default to five minutes and Slack's example uses five minutes. Stripe warns that a tolerance of 0 disables the recency check entirely, and that your server clock should be synced with NTP.
The window does not stop a replay inside it, and providers also redeliver on purpose: Stripe retries failed deliveries with a new signature and timestamp each time. So store the event ID (id in Stripe's body, X-GitHub-Delivery for GitHub, event_id in Slack Events API payloads) and skip IDs you have processed. Write the ID in the same database transaction as the side effect. Delivery can repeat, so this gives you idempotent handling under at-least-once delivery, not exactly-once processing. Stripe's own advice is to track event IDs rather than rely on created or event order.
Verifying at a gateway
Verification can run in a gateway or proxy only if that component receives the original body and headers untouched. GitHub's documentation tells you to make sure a proxy or load balancer does not modify the payload or headers, and Stripe's troubleshooting page shows that AWS API Gateway needs a body mapping template before the Lambda function can see the raw body. If the gateway verifies and then forwards a re-encoded body, downstream services can no longer verify it themselves and must trust the gateway instead. Decide that trade-off on purpose.
Why verification fails
| Symptom | Likely cause | What to check |
|---|---|---|
| Fails on every request | Body was parsed and re-serialized | Hash the bytes you verify; use express.raw, get_data(), text() |
| Fails only with non-ASCII text | Body decoded with a non-UTF-8 charset | Decode as UTF-8 or hash the bytes |
| Fails only behind a proxy | Proxy rewrites body or headers | Compare body length at the proxy and at the app |
| Works in test, fails in live | Test and live secrets are mixed | The same Stripe endpoint has a different secret in test and live use, and stripe listen prints its own |
| Secret looks right | Space or newline around the secret | Print its length; trim once when loading |
| Digest has the right letters but fails | Hex versus Base64, or missing sha256= / v0= prefix | GitHub and Slack prefix the value; Stripe's v1= value is bare hex |
Header is undefined | Name case, or no secret configured | Header names are case-insensitive; GitHub omits the header when no secret is set |
| Valid signature, request rejected | Timestamp in milliseconds, or clock drift | Stripe and Slack timestamps are Unix seconds |
Sources
- GitHub: Validating webhook deliveries, including the
Hello, World!test vector - GitHub: Webhook events and payloads (
X-GitHub-Delivery) - Stripe: Webhooks and Stripe: Resolve webhook signature verification errors
- Slack: Verifying requests from Slack and Slack: Events API
- Node.js: crypto.timingSafeEqual
- Python: hmac.compare_digest
Sources reviewed 2026-10-23. For a documented case where a handler skipped this step, see our write-up of the OneUptime webhook signature advisory.