Webhook signature verification: proving a payload actually came from Meta

A webhook URL is a public endpoint anyone can find and POST to. Signature verification is the one mechanism separating a genuine Meta event from someone pretending to be one — and it's easy to get subtly wrong.

Intermediate

3 min read

The problem the verification handshake doesn't solve

An earlier lesson covered the one-time verification handshake — Meta's GET request checking hub.verify_token when a webhook URL is first configured. That handshake proves the endpoint belongs to the right App, once, at setup time. It says nothing about any individual POST request that arrives afterward. Once a webhook URL is live, it's a public HTTPS endpoint like any other — anyone who finds it can send it a POST with a fabricated payload, and without a second check, the receiver has no way to tell a real delivery-status update from a stranger's forged JSON.

The mechanism: X-Hub-Signature-256

Every real webhook POST from Meta includes a header containing an HMAC-SHA256 signature of the exact request body, computed with the App Secret:

X-Hub-Signature-256: sha256=7f3a9c1e...

Verifying it means recomputing that same HMAC independently and comparing:

computed = HMAC-SHA256(appSecret, rawRequestBody)
expected = header value, with the "sha256=" prefix stripped
valid    = constant_time_equal(computed, expected)

If they match, the request body genuinely wasn't altered in transit and genuinely came from an App holding that App Secret — which only Meta and the integration itself possess.

The bug that passes every manual test and fails in production: hashing the wrong bytes

The single most common implementation mistake is computing the hash over the parsed body instead of the raw one. Most web frameworks parse an incoming JSON body into an object before application code ever sees it — and re-serializing that object back to a string rarely produces byte-for-byte identical JSON (key order, whitespace, and Unicode escaping can all shift). Hashing that re-serialized string will never match Meta's signature, even though the payload is completely legitimate.

Wrong:  body = JSON.parse(request)        // parsing happens first
        computed = HMAC(secret, JSON.stringify(body))   // re-serialized, bytes changed
        -> mismatch on genuinely valid traffic

Right:  rawBody = request's raw bytes, captured before any parsing middleware runs
        computed = HMAC(secret, rawBody)
        body = JSON.parse(rawBody)         // parse only after verifying

This is worth testing explicitly, because it fails in a confusing way: everything works against a hand-crafted test payload built the same way the parser builds it, and only breaks against a genuine Meta request with real formatting.

Constant-time comparison matters, not just correctness

Comparing the two hash strings with a plain == leaks timing information — a naive string comparison returns faster the earlier the first mismatched byte occurs, which an attacker can exploit to guess a valid signature byte-by-byte over many requests. Every mainstream language ships a purpose-built constant-time comparison for exactly this (crypto.timingSafeEqual in Node, hmac.compare_digest in Python) — using the general-purpose equality operator here is a real, if narrow, vulnerability rather than a style nitpick.

What this check does — and doesn't — protect against

Signature verification confirms the payload's authenticity and integrity. It does not replace HTTPS (the header assumes a request already reached the endpoint over a secure channel), and it does not deduplicate retried deliveries — Meta can and does resend the same event more than once, so a receiver still needs its own idempotency handling (matching on message ID) independent of signature validity.

Further reading

Check your understanding

A quick comprehension check — not tracked, not graded, just for you.

1. A webhook endpoint already passed Meta's one-time hub.verify_token handshake when it was configured. Is that enough to trust every future POST to that URL?

2. A webhook receiver computes its HMAC over `JSON.stringify(JSON.parse(requestBody))` instead of the raw request body. What's the effect?

3. Why should X-Hub-Signature-256 be checked with a constant-time comparison rather than a plain `==`?

4. A webhook event with a valid X-Hub-Signature-256 arrives twice, a few seconds apart, for the same message. What does signature validity guarantee here?