OpsecForge Hub/Webhook Verifier
Paste-only • Client-side verification

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.

Fills in the public secret, payload and signature from GitHub's documentation.

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.
Back to Hub

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".

GitHub test vector
FieldValue
Signing secretIt's a Secret to Everybody
Raw body (13 bytes, no newline)Hello, World!
X-Hub-Signature-256sha256=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:

HMAC-SHA256 of three versions of the same body
BodyDigest
{"id": 7, "ok": true}e60ea4e432378b73473ec97255c8d2516d150f54aabbc0b2292af459c71721e1
After JSON.stringify(JSON.parse(body)): {"id":7,"ok":true}eb0039baafffe38bab5d5a68576e1e8ae9d7617361df304a09dc4605c1cb8c7d
Original plus a trailing newlineada8672dbfbe823567c83b4a06911f543c1701c217684731bd271bd9c4d89064
Troubleshooting checklist
SymptomLikely causeWhat to check
Mismatch on every requestBody was parsed and re-serialized, or decoded by middlewareCapture the raw bytes before any JSON parser runs. Stripe states that any manipulation of the raw body makes verification fail.
Mismatch on some requestsNon-ASCII characters or line endings changedTreat the payload as UTF-8 bytes end to end; GitHub notes payloads can contain Unicode.
GitHub header rejectedMissing prefix or legacy headerUse the full sha256=… value from X-Hub-Signature-256, not the older SHA-1 X-Hub-Signature.
Stripe signature matches but is flaggedTimestamp outside toleranceStripe libraries default to 5 minutes; check the server clock with NTP and never set the tolerance to 0.
Stripe never matchesWrong secretUse 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:

Stripe-Signature (synthetic)
t=1700000000,v1=3b013f9e1509641732c19541427ac38cd90267dce03162c866459d82459af335

Because 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

Node.js
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") -> true
Python
import 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") -> True

Both 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.

Share this: