Verify WhatsApp Webhook Signatures in Node and Python
Your webhook URL is not a secret. It shows up in logs, proxies and the browser history of whoever set it up. Anyone who finds it can POST a perfectly shaped payload saying a customer replied "yes, cancel my order", and a handler that trusts the body will act on it.
The fix is to verify the WhatsApp webhook signature on every request. Meta signs each delivery with your app secret, and checking it takes six lines. The catch is that most first attempts fail on real traffic, almost always because the framework parsed the body before the code could hash it.
This guide shows exactly what Meta signs, how to get the raw bytes in Express, Next.js, Flask, FastAPI and Django, correct Node and Python code with constant-time comparison, a local test with openssl, and how Tenashi's timestamped signatures differ. Start with the complete guide to the WhatsApp API if you're new to the platform.
What Meta signs, exactly
Every Cloud API webhook POST carries an X-Hub-Signature-256 header in the form sha256= followed by 64 hex characters. Per Meta's endpoint guide, that value is an HMAC-SHA256 of the request body, keyed with your app secret.
To check it:
- Read the request body as raw bytes, before any JSON parsing.
- Compute HMAC-SHA256 of those bytes with your app secret as the key, and hex-encode it.
- Compare it, in constant time, with everything after
sha256=in the header. - If they match, process the event. If not, reject it with a 4xx.
The most common mix-up is which secret to use. A Cloud API integration has three, and they do different jobs:
| Secret | Where it lives | What it's for |
|---|---|---|
| Verify token | A string you invent and enter in App Dashboard, WhatsApp, Configuration | Only the one-time GET handshake (hub.verify_token) |
| App secret | App Dashboard, App settings, Basic | Signing every webhook POST |
| Access token | System user in Business Settings | Your outbound API calls to Meta |
If you've hashed with the verify token or the access token, the signature will never match. Each app has its own secret, so if two apps subscribe to the same WhatsApp Business Account, each receives payloads signed with its own key.
Meta describes validation as optional but recommended. Treat it as required: it's the only thing proving a payload came from Meta.
Why the raw body matters
An HMAC covers bytes, not meaning. If your framework parses the JSON and you re-serialize it to hash, the bytes change: whitespace disappears, key order can shift, non-ASCII characters may be escaped differently, numbers may be reformatted. {"a": "Café"} and {"a":"Café"} are the same object and two different signatures.
So the signature check has to happen on the body exactly as it arrived. Here's how to get it in common stacks:
| Stack | How to get the raw body |
|---|---|
| Express | express.raw({ type: "application/json" }) on the webhook route, or express.json({ verify }) to keep a copy |
| Next.js App Router | Buffer.from(await request.arrayBuffer()) before calling request.json() |
| Flask | request.get_data() returns bytes; call it before request.get_json() |
| FastAPI or Starlette | await request.body() returns bytes |
| Django | request.body is bytes |
| AWS Lambda behind API Gateway | event.body, base64-decoded first when event.isBase64Encoded is true |
Two more places bodies get changed without you noticing. A reverse proxy or API gateway that "normalises" JSON breaks signatures for everything behind it. And global middleware, such as an app.use(express.json()) added near the top of an Express app, consumes the stream before your route-level raw parser ever runs.
Node.js: a verification function you can drop in
This function takes the raw body as a Buffer, decodes the hex in the header, and compares the two digests with crypto.timingSafeEqual. That function throws if the buffers differ in length, so the length check comes first.
import crypto from "node:crypto";
// X-Hub-Signature-256 = "sha256=" + hex HMAC-SHA256(rawBody, APP_SECRET)
export function verifyMetaSignature(rawBody, header, appSecret) {
if (typeof header !== "string" || !header.startsWith("sha256=")) return false;
const expected = crypto.createHmac("sha256", appSecret).update(rawBody).digest();
const received = Buffer.from(header.slice(7), "hex");
return received.length === expected.length && crypto.timingSafeEqual(received, expected);
}
Using it in Express, keeping JSON parsing and a raw copy side by side:
import express from "express";
import { verifyMetaSignature } from "./verify.js";
import { enqueue } from "./jobs.js"; // your queue
const app = express();
app.use(express.json({ limit: "5mb", verify: (req, res, buf) => { req.rawBody = buf; } }));
app.post("/webhooks/whatsapp", (req, res) => {
if (!verifyMetaSignature(req.rawBody, req.get("x-hub-signature-256"), process.env.APP_SECRET)) {
return res.sendStatus(401);
}
res.sendStatus(200);
enqueue(req.body); // already parsed, and proven to be from Meta
});
And in a Next.js App Router route handler:
import { verifyMetaSignature } from "@/lib/verify";
import { enqueue } from "@/lib/jobs"; // your queue
export async function POST(request: Request) {
const raw = Buffer.from(await request.arrayBuffer());
const ok = verifyMetaSignature(raw, request.headers.get("x-hub-signature-256"), process.env.APP_SECRET!);
if (!ok) return new Response("invalid signature", { status: 401 });
const payload = JSON.parse(raw.toString("utf8"));
await enqueue(payload);
return new Response(null, { status: 200 });
}
Why not === on two hex strings? A plain string comparison can return as soon as it finds a differing character, which in principle leaks timing information about how much of a forged signature was right. Constant-time comparison removes that question entirely, and it costs nothing.
Python: Flask and FastAPI
hmac.compare_digest is Python's constant-time comparison. Comparing bytes rather than str also avoids a TypeError if someone sends a header with non-ASCII characters.
import hashlib
import hmac
def verify_meta_signature(raw_body: bytes, header: str, app_secret: str) -> bool:
"""X-Hub-Signature-256 is 'sha256=' + hex HMAC-SHA256 of the raw body."""
if not header or not header.startswith("sha256="):
return False
expected = hmac.new(app_secret.encode(), raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected.encode(), header[7:].lower().encode())
Flask:
import os
from flask import Flask, request, abort
from verify import verify_meta_signature
from jobs import enqueue # your queue
app = Flask(__name__)
@app.post("/webhooks/whatsapp")
def whatsapp():
raw = request.get_data() # call this before request.get_json()
if not verify_meta_signature(raw, request.headers.get("X-Hub-Signature-256", ""), os.environ["APP_SECRET"]):
abort(401)
payload = request.get_json()
enqueue(payload)
return "", 200
FastAPI:
import os
from fastapi import FastAPI, Request, HTTPException
from verify import verify_meta_signature
from jobs import enqueue # your queue
app = FastAPI()
@app.post("/webhooks/whatsapp")
async def whatsapp(request: Request):
raw = await request.body() # bytes, before any model parsing
if not verify_meta_signature(raw, request.headers.get("x-hub-signature-256", ""), os.environ["APP_SECRET"]):
raise HTTPException(status_code=401)
enqueue(await request.json())
return {"ok": True}
Don't declare the webhook body as a Pydantic model parameter in FastAPI. That parses it before your function runs, and you'd be tempted to hash a re-serialized copy. Our Node.js WhatsApp API tutorial builds these pieces into a full send-and-receive app.
Test it locally before Meta does
You don't need Meta to test the check. Save a sample payload to payload.json, sign it with your app secret, and post it with curl:
SIG=$(openssl dgst -sha256 -hmac "$APP_SECRET" < payload.json | sed 's/^.* //')
curl -i -X POST "https://your-domain.example/webhooks/whatsapp" \
-H "Content-Type: application/json" \
-H "X-Hub-Signature-256: sha256=$SIG" \
--data-binary @payload.json
Use --data-binary, not -d. curl's -d @file strips newlines from the file, so the bytes sent no longer match the bytes you signed; in our test, the same payload returned 200 with --data-binary and 401 with -d. Then flip one character of $SIG and confirm you get a 401.
In production, log failures with the header value and body length (not the body itself, which contains customer data) and alert on the rate. A sudden jump to 100% failures is almost never an attack. It's a rotated app secret or a newly added body parser. More causes of silent webhook failures are in why your WhatsApp webhook isn't working.
Tenashi signatures: timestamp plus body
Tenashi signs its webhooks differently, and adds replay protection Meta's scheme doesn't have. Each delivery carries:
X-Tenashi-Timestamp: Unix seconds when it was signed.X-Tenashi-Signature:v1=followed by the hex HMAC-SHA256 of the timestamp, a dot, and the raw body, keyed with that endpoint'swhsec_secret.X-Tenashi-EventandX-Tenashi-Delivery(the event id).
Because the timestamp is inside the signed string, an attacker can't take an old genuine delivery and resend it later: you reject anything older than 5 minutes.
import crypto from "node:crypto";
export function verifyTenashiSignature(rawBody, headers, secret) {
const timestamp = headers["x-tenashi-timestamp"];
const header = String(headers["x-tenashi-signature"] || "");
if (!timestamp || !header.startsWith("v1=")) return false;
if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false; // replay window
const expected = crypto
.createHmac("sha256", secret)
.update(Buffer.concat([Buffer.from(`${timestamp}.`), Buffer.from(rawBody)]))
.digest();
const received = Buffer.from(header.slice(3), "hex");
return received.length === expected.length && crypto.timingSafeEqual(received, expected);
}
import hashlib, hmac, time
def verify_tenashi_signature(raw_body: bytes, headers, secret: str) -> bool:
timestamp = headers.get("X-Tenashi-Timestamp", "")
header = headers.get("X-Tenashi-Signature", "")
if not timestamp.isdigit() or not header.startswith("v1="):
return False
if abs(time.time() - int(timestamp)) > 300: # refuse replays older than 5 minutes
return False
expected = hmac.new(secret.encode(), timestamp.encode() + b"." + raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected.encode(), header[3:].encode())
The Node version expects lower-cased header names (as Node's req.headers provides). The Python version works with Flask's and Starlette's case-insensitive header objects. Each endpoint has its own secret, so a leaked secret only affects one URL. For keys, tokens and logging, see our WhatsApp API security checklist.
Where Tenashi fits
If you're verifying signatures because you want WhatsApp conversations flowing into your own systems safely, Tenashi gives you signed, timestamped webhooks for the number your team already uses, linked by QR scan. Messages from DMs and groups arrive as message.received, and every send, including ones typed on the phone, arrives as message.sent. The API comes with every paid plan, from $18/mo.
Tenashi isn't Meta's Business Platform and doesn't replace the Cloud API for OTPs or high-volume notifications. It's built for conversations your team runs.
Read the API docs · Get started
FAQ
Which secret signs WhatsApp Cloud API webhooks?
Your Meta app's app secret, found in App Dashboard under App settings, Basic. It's not the verify token, which is only used for the one-time GET handshake, and not your access token, which authenticates outbound API calls. Each app has its own secret, so payloads to two subscribed apps carry different signatures.
Why does my computed signature never match X-Hub-Signature-256?
Almost always because you hashed a parsed and re-serialized body instead of the raw bytes, or used the wrong secret. Capture the body before any JSON middleware runs, hash it with the app secret, and compare against the hex after the sha256= prefix. Also check that no proxy rewrites the request body.
Is webhook signature verification required by Meta?
Meta calls it optional but recommended, and your endpoint will receive webhooks either way. Skip it and anyone who learns your callback URL can send forged events that look exactly like real customer messages. Treat it as mandatory, reject unsigned or mismatched requests with a 4xx, and alert when failures spike.
How do I verify WhatsApp signatures in a serverless function?
Use the body exactly as the platform hands it to you. On AWS Lambda behind API Gateway, base64-decode event.body first when isBase64Encoded is true. In Next.js or edge-style handlers, read request.arrayBuffer once and parse JSON from that buffer. Never hash the output of a JSON parse.
How is Tenashi's webhook signature different from Meta's?
Tenashi signs the timestamp, a dot and the raw body with a per-endpoint whsec_ secret, and sends the result as v1= hex in X-Tenashi-Signature. Because the timestamp is signed, you can reject deliveries older than 5 minutes and block replays. Meta's X-Hub-Signature-256 signs only the body.
A signature check is a few lines that decide whether your WhatsApp integration can be trusted. If you'd like signed, replay-protected events for your team's own number, read Tenashi's API docs.