Skip to content

Concept map· 6 connected terms

X-Hub-Signature-256

Also called: Webhook signature · Meta webhook signature · X-Hub-Signature-256 header

In one line
The header on every Meta webhook POST: sha256= plus an HMAC-SHA256 of the raw body keyed with your app secret, proving the event is genuine.

Reviewed

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

3 / 3

Incoming request

As signed

POST /webhook HTTP/1.1

X-Hub-Signature-256: sha256=7594e383788aaff25494f24301d5174358da3a36df5a7da172a18a86d83c406a

389 bytes, exactly as the sender signed them.

Your server verifies

  1. Read the raw body and the headerUse the exact bytes received — 389 bytes, before any JSON parsing.
  2. Recompute the HMACHMAC-SHA256(secret, raw body)
  3. Compare the two digestsUse a constant-time comparison.
App secret (App settings, Basic)demo-app-secret

X-Hub-Signature-256 (received) vs HMAC-SHA256(secret, raw body) (computed)

Receivedsha256=7594e383788aaff25494f24301d5174358da3a36df5a7da172a18a86d83c406a
Computedsha256=7594e383788aaff25494f24301d5174358da3a36df5a7da172a18a86d83c406a
Match — signature validThe body is byte-for-byte what the sender signed. Process the event.
Meta signed this messages webhook with your app secret. Recompute the HMAC over the raw body, change one character or swap in the wrong secret, and see why verification must run on the exact bytes received.

Match — 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 MB
verify: (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 wamid
return res.sendStatus(200);
});

How does the X-Hub-Signature-256 header work?

  1. Capture the raw body

    Configure the framework to keep the unparsed request bytes — for example the verify hook of express.json() in Node, or request.get_data() in Flask before touching the JSON.

  2. Read the header

    Take X-Hub-Signature-256 (header names are case-insensitive) and check that it starts with sha256=. Reject requests where it is missing.

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

  4. Compare in constant time

    Use crypto.timingSafeEqual in Node or hmac.compare_digest in Python, checking lengths first. A plain == stops at the first differing character and leaks timing.

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

X-Hub-Signature-256 FAQs

How do I verify X-Hub-Signature-256 in Node.js?+
Compute an HMAC-SHA256 of the raw request body with your app secret, prefix it with sha256= and compare it with the header using crypto.timingSafeEqual. Capture the raw body with the verify option of express.json.
Why does my webhook signature never match?+
Usually because you are hashing parsed and re-serialised JSON instead of the raw bytes. Other causes are the wrong app's secret, a body truncated by a size limit, or middleware that decoded the body before you read it.
Is X-Hub-Signature-256 the same as the verify token?+
No. The verify token is a string you choose for the one-time hub.challenge GET; X-Hub-Signature-256 is an HMAC keyed with the app secret Meta issues, and it arrives on every POST.
What about the older X-Hub-Signature header?+
Use the SHA-256 header. X-Hub-Signature carried an older SHA-1 HMAC; X-Hub-Signature-256 is the header Meta documents for validating webhook payloads today.

See InfiQ pricing

Try it on the official WhatsApp Business API

InfiQ is an official Meta Business Partner. Start a 7-day free trial — no card needed — and see this running on your own number.