What is the X-Hub-Signature-256 header?
X-Hub-Signature-256 is the HTTP header Meta attaches to every webhook notification it sends, including WhatsApp messages events. Its value is sha256= followed by a 64-character lowercase hex digest: an HMAC-SHA256 computed over the raw request body, using your app secret — found in the App Dashboard under App settings, Basic — as the key. Because only Meta and you know the app secret, a matching signature proves the payload came from Meta for your app and was not modified on the way.
Verification takes three steps: read the raw body bytes exactly as received, compute HMAC-SHA256 with your app secret, and compare the result with the header in constant time. Almost every failure comes from step one. Frameworks that parse JSON first hand you an object and lose the original bytes; re-serialising it changes whitespace, key order and escaped characters such as \u0939, and the digest no longer matches. Capture the raw buffer in middleware before parsing.
The header is different from the webhook verification request: the hub.challenge GET runs once when you save the callback URL and proves you control the endpoint, while X-Hub-Signature-256 authenticates each POST afterwards. It also differs from webhook mTLS, which proves the connection came from Meta's servers but cannot tell one Meta app's deliveries from another's. A valid signature does not prove freshness — Meta retries failed deliveries for up to seven days, and a captured request could be replayed — so pair the check with deduplication by wamid or event ID.
Key takeaways
- X-Hub-Signature-256 is the header Meta adds to every webhook POST:
sha256=and an HMAC-SHA256 of the raw body keyed with your app secret. - Compute the HMAC over the exact bytes received, before any JSON parsing, or the signature will never match.
- Compare with a constant-time function and reject a mismatch with 401 before doing any other work.
- The hub.challenge handshake verifies your URL once; X-Hub-Signature-256 authenticates every single delivery.
- A valid signature proves origin and integrity, not freshness: deduplicate by wamid to handle retries and replays.
Tamper with a webhook and watch the signature fail
Incoming request
As signedPOST /webhook HTTP/1.1
X-Hub-Signature-256: sha256=7594e383788aaff25494f24301d5174358da3a36df5a7da172a18a86d83c406a
389 bytes, exactly as the sender signed them.
Your server verifies
- Read the raw body and the headerUse the exact bytes received — 389 bytes, before any JSON parsing.
- Recompute the HMACHMAC-SHA256(secret, raw body)
- Compare the two digestsUse a constant-time comparison.
demo-app-secretX-Hub-Signature-256 (received) vs HMAC-SHA256(secret, raw body) (computed)
sha256=7594e383788aaff25494f24301d5174358da3a36df5a7da172a18a86d83c406asha256=7594e383788aaff25494f24301d5174358da3a36df5a7da172a18a86d83c406aMatch — signature valid. The body is byte-for-byte what the sender signed. Process the event.
The X-Hub-Signature-256 header in code
import crypto from "node:crypto";import express from "express";const app = express();// Keep the raw bytes: the signature covers them, not the parsed object.app.use("/webhooks/whatsapp", express.json({limit: "4mb", // payloads can be up to 3 MBverify: (req, _res, buf) => { req.rawBody = buf; },}));function isValidSignature(rawBody, header) {if (!rawBody || !header?.startsWith("sha256=")) return false;const expected = crypto.createHmac("sha256", process.env.APP_SECRET).update(rawBody).digest("hex");const a = Buffer.from(header.slice("sha256=".length), "utf8");const b = Buffer.from(expected, "utf8");return a.length === b.length && crypto.timingSafeEqual(a, b);}app.post("/webhooks/whatsapp", (req, res) => {if (!isValidSignature(req.rawBody, req.get("X-Hub-Signature-256"))) {return res.sendStatus(401);}queue.add("whatsapp-event", req.body); // the worker deduplicates by wamidreturn res.sendStatus(200);});
How does the X-Hub-Signature-256 header work?
Capture the raw body
Configure the framework to keep the unparsed request bytes — for example the
verifyhook ofexpress.json()in Node, orrequest.get_data()in Flask before touching the JSON.Read the header
Take
X-Hub-Signature-256(header names are case-insensitive) and check that it starts withsha256=. Reject requests where it is missing.Compute the HMAC
HMAC-SHA256 the raw bytes with your app secret and hex-encode the digest in lowercase, then prefix it with
sha256=or strip the prefix from the header.Compare in constant time
Use
crypto.timingSafeEqualin Node orhmac.compare_digestin Python, checking lengths first. A plain==stops at the first differing character and leaks timing.Act on the result
On a mismatch return 401 and log the failure without the body; on a match enqueue the event and return 200 quickly.
Common wrong turns
Common mistakes with the X-Hub-Signature-256 header
Hashing parsed and re-stringified JSON
JSON.stringify(req.body)differs from what Meta signed: spacing, key order and Unicode escapes change. Hash the raw buffer you captured before parsing.Using the wrong secret
The key is the app secret of the app subscribed to the webhook — not the access token, the verify token or another app's secret. After resetting the app secret, update the verifier at the same moment, or accept both secrets briefly.
Comparing with ==
Ordinary string comparison returns at the first differing character, which leaks timing information. Use a constant-time comparison and check lengths first.
Assuming a valid signature means a new event
Retries and replays carry the same valid signature. Deduplicate by
wamid(plus status for status events) before acting, especially on actions such as refunds.Truncating the body before verifying
Payloads can be up to 3 MB. A proxy or parser limit below that cuts the body and breaks the HMAC, so allow at least 3 MB on the webhook path.
Why does the X-Hub-Signature-256 header matter?
Your webhook URL is public, and anything that can reach it can POST a convincing fake: an inbound message that triggers a refund bot, a "delivered" status that hides a failure, a flood of junk that fills your queue. Verifying X-Hub-Signature-256 is the check that separates genuine Meta events from forgeries. Meta's documentation calls validation optional but recommends it — in practice, an endpoint that skips it trusts the whole internet.
Example: A forged refund request, rejected
A Surat textile e-shop runs a bot that starts refunds when customers send "Refund order #…" on WhatsApp. One night its logs show 40 POSTs to the webhook URL carrying plausible messages payloads — someone had found the URL in an old public repository — but with signatures computed using a guessed key. Every request fails the X-Hub-Signature-256 check and gets a 401 before the bot sees it. The team moves the endpoint to a new path anyway and adds an alert when signature failures exceed ten an hour.
Key facts at a glance
The X-Hub-Signature-256 header: key facts and limits
- Header value
- sha256= followed by 64 lowercase hex characters
- Algorithm
- HMAC-SHA256
- Key
- Your app secret (App settings, Basic)
- Input
- The raw request body, before parsing
- Sent on
- Every webhook POST, not the verification GET
- On mismatch
- Return 401 and do not process
- Retries
- Up to 7 days, so the same signed event can repeat
Good practice
Best practices for the X-Hub-Signature-256 header
- Verify before parsing, logging or calling anything else.
- Count signature failures and alert on spikes: they mean a misconfigured secret or an attack.
- Load the app secret from your secret store at start-up, never from source code.
- Keep a signed sample payload in your test suite, so a framework upgrade that breaks raw-body capture fails CI.