Webhook Signature Verifier
Compare exact bytes without uploading secrets
Verify Generic HMAC, GitHub X-Hub-Signature-256, or Stripe Stripe-Signature values using the exact raw request body.
Payloads, signatures, and secrets stay in this browser tab. They are not fetched, uploaded, stored, logged, or included in analytics events.
Whitespace, line endings, encoding, and key order change the signed bytes. Do not reformat JSON before verification.
Boundaries that still matter in production
- Verify before parsing. Capture the exact raw bytes before middleware changes the body.
- Use official provider libraries. This page is a debugging aid, not receiver middleware.
- Handle replay separately. Enforce timestamp tolerance where defined and deduplicate provider delivery or event IDs.
- Protect the secret. A match is meaningful only if the configured signing secret is controlled by the expected parties.
Reproduce GitHub's official test vector
GitHub publishes a secret, a payload and the signature that HMAC-SHA256 must produce for them. Click Load GitHub test vector above, or enter the values yourself with the GitHub provider selected, then press Verify. The result should read "Supplied GitHub values match".
| Field | Value |
|---|---|
| Signing secret | It's a Secret to Everybody |
| Raw body (13 bytes, no newline) | Hello, World! |
| X-Hub-Signature-256 | sha256=757107ea0eb2509fc211221cce984b8a37570b6d7586c22c46f4379c8b043e17 |
Now change one thing. Make the body Hello, World?, add a newline after the exclamation mark, or change a letter of the secret, and the check reports a mismatch. HMAC offers no partial match: one different byte gives an unrelated digest.
Why the right secret still fails
The most common cause is that the bytes you verify are not the bytes the sender signed. Take the body {"id": 7, "ok": true} signed with the synthetic secret synthetic-demo-secret. Choose Generic HMAC, HMAC-SHA256, and paste the first digest below as the expected value. Only the original body matches:
| Body | Digest |
|---|---|
{"id": 7, "ok": true} | e60ea4e432378b73473ec97255c8d2516d150f54aabbc0b2292af459c71721e1 |
After JSON.stringify(JSON.parse(body)): {"id":7,"ok":true} | eb0039baafffe38bab5d5a68576e1e8ae9d7617361df304a09dc4605c1cb8c7d |
| Original plus a trailing newline | ada8672dbfbe823567c83b4a06911f543c1701c217684731bd271bd9c4d89064 |
| Symptom | Likely cause | What to check |
|---|---|---|
| Mismatch on every request | Body was parsed and re-serialized, or decoded by middleware | Capture the raw bytes before any JSON parser runs. Stripe states that any manipulation of the raw body makes verification fail. |
| Mismatch on some requests | Non-ASCII characters or line endings changed | Treat the payload as UTF-8 bytes end to end; GitHub notes payloads can contain Unicode. |
| GitHub header rejected | Missing prefix or legacy header | Use the full sha256=… value from X-Hub-Signature-256, not the older SHA-1 X-Hub-Signature. |
| Stripe signature matches but is flagged | Timestamp outside tolerance | Stripe libraries default to 5 minutes; check the server clock with NTP and never set the tolerance to 0. |
| Stripe never matches | Wrong secret | Use the signing secret (starts with whsec_) of the endpoint that received the event; Stripe generates a different one for each endpoint. It is not an API key. |
A Stripe-style header to try
Stripe signs timestamp.rawBody with HMAC-SHA256 and sends it in Stripe-Signature as t=…,v1=…. This synthetic example uses the secret synthetic-endpoint-secret and the body {"id":"evt_synthetic","type":"example.created"}. Select Stripe and paste:
t=1700000000,v1=3b013f9e1509641732c19541427ac38cd90267dce03162c866459d82459af335Because the timestamp is from November 2023 and the check uses your device clock, the tool answers "Signature matches, timestamp outside tolerance". That confirms the HMAC while still rejecting a stale request. The tool also checks every v1 value, which matters while a secret is being rotated.
Verify in your own code
const crypto = require('node:crypto');
function verifyGitHub(rawBody, signatureHeader, secret) {
const expected = 'sha256=' + crypto.createHmac('sha256', secret).update(rawBody).digest('hex');
const a = Buffer.from(expected);
const b = Buffer.from(signatureHeader);
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
// verifyGitHub(Buffer.from('Hello, World!'), header, "It's a Secret to Everybody") -> trueimport hmac, hashlib
def verify_github(raw_body: bytes, signature_header: str, secret: str) -> bool:
expected = "sha256=" + hmac.new(secret.encode(), raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, signature_header)
# verify_github(b"Hello, World!", header, "It's a Secret to Everybody") -> TrueBoth functions return false for the body with a trailing newline. They use constant-time comparison, as GitHub recommends, rather than ==. For Stripe in production, prefer its official libraries, which also handle the timestamp tolerance.