How to verify webhook signatures: Stripe, GitHub, Slack
Every major webhook provider signs their requests with HMAC. Here's the working verification code for each, the timing-attack pitfall everyone hits, and a universal template.
# The universal pattern
Every webhook provider does some variant of:
1. Take the raw request body
2. Concatenate with a timestamp
3. HMAC-SHA256 with a shared secret
4. Send the hex digest in a header
You reverse it:
1. Read the raw body (not parsed JSON — byte-for-byte)
2. Extract timestamp + signature from headers
3. Recompute the HMAC
4. Constant-time compare
<div class="callout callout-warning" role="note"><div class="callout-title">Warning</div><div class="callout-body"><p>The #1 cause of "my verification fails" is that a middleware parsed the JSON before you got to the handler. JSON stringify → re-stringify is NOT byte-identical to the original. You need the raw bytes.</p></div></div>
# Stripe
Header: Stripe-Signature: t=1234567890,v1=abc123...
import { createHmac, timingSafeEqual } from "node:crypto";
function verifyStripe(rawBody: Buffer, header: string, secret: string): boolean {
const parts = Object.fromEntries(
header.split(",").map((kv) => kv.split("=") as [string, string]),
);
const timestamp = parts.t;
const signature = parts.v1;
if (!timestamp || !signature) return false;
// 5-minute tolerance — reject old requests (replay protection)
const age = Math.floor(Date.now() / 1000) - parseInt(timestamp, 10);
if (age > 300) return false;
const signed = `${timestamp}.${rawBody.toString("utf8")}`;
const expected = createHmac("sha256", secret).update(signed).digest("hex");
return timingSafeEqual(Buffer.from(expected, "hex"), Buffer.from(signature, "hex"));
}
# GitHub
Header: X-Hub-Signature-256: sha256=abc123...
function verifyGithub(rawBody: Buffer, header: string, secret: string): boolean {
if (!header?.startsWith("sha256=")) return false;
const sig = header.slice("sha256=".length);
const expected = createHmac("sha256", secret).update(rawBody).digest("hex");
if (expected.length !== sig.length) return false;
return timingSafeEqual(Buffer.from(expected, "hex"), Buffer.from(sig, "hex"));
}
GitHub doesn't include a timestamp in the header, so replay protection has to be done a different way (idempotency key, or checking the delivery ID).
# Slack
Headers: X-Slack-Signature: v0=abc123... + X-Slack-Request-Timestamp: 1234567890
function verifySlack(
rawBody: Buffer,
signature: string,
timestamp: string,
secret: string,
): boolean {
const age = Math.floor(Date.now() / 1000) - parseInt(timestamp, 10);
if (age > 300) return false;
const signed = `v0:${timestamp}:${rawBody.toString("utf8")}`;
const expected = "v0=" + createHmac("sha256", secret).update(signed).digest("hex");
if (expected.length !== signature.length) return false;
return timingSafeEqual(Buffer.from(expected), Buffer.from(signature));
}
# The timing-attack lesson
// ❌ WRONG — leaks signature byte-by-byte via timing
if (expected === actual) { ... }
// ✅ RIGHT — constant time regardless of where they differ
if (timingSafeEqual(Buffer.from(expected), Buffer.from(actual))) { ... }
<div class="callout callout-danger" role="note"><div class="callout-title">Danger</div><div class="callout-body"><p>Timing attacks are real. In 2013, a production Github attack vector existed because of non-constant-time comparison. Node's <code>crypto.timingSafeEqual</code> is the one-line fix.</p></div></div>
# Python
import hmac, hashlib, time
def verify(raw_body: bytes, signature: str, timestamp: str, secret: str) -> bool:
if abs(time.time() - int(timestamp)) > 300:
return False
signed = f"{timestamp}.{raw_body.decode('utf-8')}".encode()
expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, signature)
# Go
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
)
func verify(rawBody []byte, signature, secret string) bool {
mac := hmac.New(sha256.New, []byte(secret))
mac.Write(rawBody)
expected := hex.EncodeToString(mac.Sum(nil))
return hmac.Equal([]byte(expected), []byte(signature))
}
# Framework-specific: getting the raw body
# Express
// For a specific route — raw body instead of parsed JSON
app.post("/webhooks/stripe",
express.raw({ type: "application/json" }),
(req, res) => {
const valid = verifyStripe(req.body, req.headers["stripe-signature"], SECRET);
// req.body is a Buffer here
},
);
# FastAPI (Python)
@app.post("/webhooks/stripe")
async def stripe_webhook(request: Request):
raw = await request.body()
sig = request.headers["stripe-signature"]
if not verify(raw, sig, SECRET):
return Response(status_code=400)
# Next.js
Use a Route Handler + request.text():
export async function POST(request: Request) {
const raw = await request.text();
const sig = request.headers.get("stripe-signature");
// verify, then parse JSON
}
# Debugging "signature doesn't match"
When verification fails, check in this order:
1. Are you using the raw body? A parsed-and-re-stringified JSON body will not match. Bytes in = bytes the signer used.
2. UTF-8 vs UTF-16 vs Latin-1. JavaScript strings are UTF-16; sign/verify both sides as UTF-8 bytes.
3. Leading/trailing whitespace. Some frameworks trim headers; some add newlines. Log rawBody.length.
4. The right secret. Test mode vs live mode secrets are different in Stripe, Shopify, Square — easy to mix up.
5. Algorithm mismatch. Most providers use SHA-256; a few use SHA-1 (GitHub's old X-Hub-Signature header) or SHA-512. Read the docs.
# Try it
Generate or verify HMAC-SHA256 signatures in-browser with our HMAC Generator. Paste a secret + a message, get the hex/base64 signature back instantly — useful for testing your webhook handler with a known-good signature.
# Related reading
Frequently asked questions
›Why does it matter if I verify the signature?
Anyone who finds your webhook URL can POST fake payloads. Without verification, they can trigger any side effect the handler does — issue refunds, send messages, update database records. The signature proves the request came from the provider you share a secret with.
›What's the 'timing attack' everyone mentions?
Plain `==` string comparison returns as soon as two bytes differ. Measuring the response time byte-by-byte lets an attacker brute-force the signature one character at a time. Use a constant-time comparison function to make every comparison take the same time regardless of where they differ.
›Do I need to verify the timestamp separately?
Yes. The signature proves the payload wasn't altered, but not that it wasn't replayed. Check the timestamp is recent (within 5 minutes is common) and reject older ones.