Back to Blog
OpsecForge EditorialAPI SecuritySources reviewed 2026-10-23

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

GitHubStripeSlack
HeaderX-Hub-Signature-256Stripe-SignatureX-Slack-Signature
Header valuesha256=<hex>t=<unix seconds>,v1=<hex>[,v1=...]v0=<hex>
Signed stringbody<t>.<body>v0:<timestamp>:<body>
Timestampnonet= in the headerX-Slack-Request-Timestamp
Keywebhook secretendpoint 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

SymptomLikely causeWhat to check
Fails on every requestBody was parsed and re-serializedHash the bytes you verify; use express.raw, get_data(), text()
Fails only with non-ASCII textBody decoded with a non-UTF-8 charsetDecode as UTF-8 or hash the bytes
Fails only behind a proxyProxy rewrites body or headersCompare body length at the proxy and at the app
Works in test, fails in liveTest and live secrets are mixedThe same Stripe endpoint has a different secret in test and live use, and stripe listen prints its own
Secret looks rightSpace or newline around the secretPrint its length; trim once when loading
Digest has the right letters but failsHex versus Base64, or missing sha256= / v0= prefixGitHub and Slack prefix the value; Stripe's v1= value is bare hex
Header is undefinedName case, or no secret configuredHeader names are case-insensitive; GitHub omits the header when no secret is set
Valid signature, request rejectedTimestamp in milliseconds, or clock driftStripe and Slack timestamps are Unix seconds

Sources

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.

Share this: