How to verify inbound email webhooks (HMAC signatures)
An unverified webhook endpoint is an open door: anyone can POST a fake email.received event. How to verify the HMAC signature with one SDK call in Node, Python, and Go, why the raw body bytes matter, and the hand-rolled check if you can't take a dependency.
Concretely: your handler sits on the open internet, so if it trusts whatever gets POSTed to it, anyone who discovers the path can hand-craft a JSON body that looks exactly like a real inbound email (a forged support ticket, a fake reply from your CEO’s address, an instruction to an agent), and your app will act on it. The From: in the body proves nothing, because the attacker wrote the whole body. Two requests hit the same endpoint, and the signature is the only thing that tells them apart:
verifyWebhook() recomputes the HMAC over the raw bytes: the signed delivery reaches your handler, the forged body gets a 401.Here’s the whole thing in Node. Runs as pasted on Node 18+, one dependency (npm install mailkite):
// verify.mjs — run with: MAILKITE_WEBHOOK_SECRET=whsec_… node verify.mjs
import { createServer } from "node:http";
import { MailKite } from "mailkite";
const SECRET = process.env.MAILKITE_WEBHOOK_SECRET ?? "whsec_demo_secret";
createServer(async (req, res) => {
let raw = "";
for await (const chunk of req) raw += chunk;
// HMAC recompute, constant-time compare, ±5-minute replay window: one call
if (!MailKite.verifyWebhook(req.headers["x-mailkite-signature"], raw, SECRET)) {
res.writeHead(401).end();
return;
}
const event = JSON.parse(raw); // parse only AFTER verifying
if (event.type === "email.received") {
console.log(event.from.address, "·", event.subject);
}
res.writeHead(200).end("ok");
}).listen(3000);
The examples use MailKite, which we build, but the scheme is the standard one: a timestamped HMAC carried in a header, the same shape Stripe uses for its webhooks. Everything below transfers to whichever provider signs your deliveries.
The event you’re protecting
This is the shape an attacker would try to forge, which is exactly why every field in it needs to be trustworthy before you act:
{
"id": "msg_2Hk9…",
"type": "email.received",
"from": { "address": "ada@example.com" },
"to": [{ "address": "support@myapp.ai" }],
"subject": "Re: invoice #1042",
"text": "Looks good — approved!",
"html": "<p>Looks good — approved!</p>",
"threadId": "<a1b2c3@mail.example.com>",
"auth": { "spf": "pass", "dkim": "pass", "dmarc": "pass", "spam": "ham" },
"attachments": []
}
Notice there are two different trust questions hiding in one payload, and they’re easy to conflate:
- Did this request come from my provider? Answered by the webhook signature.
- Is the email’s sender who they claim to be? Answered by the
authobject (SPF/DKIM/DMARC).
You need both. The signature stops a stranger POSTing fake events to your endpoint. The auth field stops a real, correctly-delivered email from a spoofed sender being trusted. An attacker who can forge the request body can also write "spf": "pass", so auth is only meaningful after the signature checks out. Verify the signature first; then trust auth.
One SDK call, every language
The SDK does the hard part (HMAC recompute, constant-time compare, replay-window check) behind one call. Your job is to feed it the raw body bytes and reject on failure.
Node (Express):
import express from "express";
import { MailKite } from "mailkite";
const app = express();
const SECRET = process.env.MAILKITE_WEBHOOK_SECRET;
// RAW body: verify the exact bytes, not a re-serialized object.
app.use("/hooks/mailkite", express.raw({ type: "application/json" }));
app.post("/hooks/mailkite", (req, res) => {
const sig = req.headers["x-mailkite-signature"];
if (!MailKite.verifyWebhook(sig, req.body, SECRET)) {
return res.sendStatus(401);
}
const event = JSON.parse(req.body); // parse only AFTER verifying
// …handle event
res.sendStatus(200);
});
app.listen(3000);
Python (Flask):
import os
from flask import Flask, request, abort
from mailkite import verify_webhook
app = Flask(__name__)
SECRET = os.environ["MAILKITE_WEBHOOK_SECRET"]
@app.post("/hooks/mailkite")
def hook():
sig = request.headers.get("x-mailkite-signature")
# request.get_data() returns the raw bytes. Do NOT touch request.json first.
if not verify_webhook(sig, request.get_data(), SECRET):
abort(401)
event = request.get_json()
# …handle event
return "", 200
Go (net/http):
package main
import (
"io"
"net/http"
"os"
mailkite "github.com/mailkite/mailkite-go"
)
var secret = os.Getenv("MAILKITE_WEBHOOK_SECRET")
func hook(w http.ResponseWriter, r *http.Request) {
rawBody, _ := io.ReadAll(r.Body)
sig := r.Header.Get("x-mailkite-signature")
if !mailkite.VerifyWebhook(sig, string(rawBody), secret) {
w.WriteHeader(http.StatusUnauthorized)
return
}
// json.Unmarshal(rawBody, &event): only after verifying
w.WriteHeader(http.StatusOK)
}
func main() {
http.HandleFunc("/hooks/mailkite", hook)
http.ListenAndServe(":3000", nil)
}
The same one-call check exists in every SDK, with the identical contract in each: pass the raw body, get a boolean, reject on false.
| Language | The one call (raw body in, boolean out) |
|---|---|
| Node / TypeScript | MailKite.verifyWebhook(sig, rawBody, secret) |
| Python | verify_webhook(sig, raw_body, secret) |
| Go | mailkite.VerifyWebhook(sig, rawBody, secret) |
| PHP | $mk->verifyWebhook($sig, $rawBody, $secret) |
| Ruby | Mailkite.verify_webhook(sig, raw_body, secret) |
| Java | mk.verifyWebhook(signature, body, secret) |
Why the raw body, specifically
This is the detail that eats an afternoon if you miss it. The signature is an HMAC computed over the exact byte sequence the provider transmitted. The moment you let a JSON middleware parse the body into an object, those bytes are gone; you have a data structure. Re-serializing it (JSON.stringify, json.dumps) produces different bytes: keys may reorder, whitespace collapses, Unicode escapes change. The HMAC over those bytes won’t match, and every legitimate webhook fails with a 401.
So the ordering is non-negotiable: capture raw bytes → verify → then parse. In Express that’s express.raw(); in Flask it’s request.get_data() before touching request.json; in Go it’s reading r.Body yourself. Never verify a re-encoded body.
401. Reach for the raw-body accessor first, verify, and only then parse.What’s inside the header: constant-time compare and the replay window
The header itself looks like x-mailkite-signature: t=1719964800000,v1=5f2a…, where t is the send time in Unix epoch milliseconds and v1 is the hex HMAC-SHA256 over the string "<t>.<rawBody>", keyed with your webhook secret. Reading it top to bottom, this is exactly what verifyWebhook reconstructs and checks:
x-mailkite-signature: verifyWebhook recomputes the signature from the timestamp and the raw body, compares it in constant time, then checks the replay window. A mismatch or a stale timestamp is a 401.Two protections are baked into verifyWebhook, and they’re worth understanding even though you don’t implement them:
- Constant-time comparison. Comparing the two signatures with a normal
==leaks timing information: an attacker can measure how long the comparison takes and recover the correct signature byte by byte.verifyWebhookcompares in constant time, so every wrong guess takes the same duration. - ±5-minute replay window. Because the HMAC covers
t, the SDK rejects anything more than 300,000 ms old (configurable via thetoleranceMsargument). Even if an attacker captures a valid signed request off the wire, they can’t replay it hours later to re-fire an action. This is also why your server clock needs to be roughly right: a badly skewed clock will reject genuine webhooks as “too old.”
t, so code copied from a Stripe example computes the HMAC over the wrong string and rejects every delivery. We got the unit wrong in our own first hand-rolled draft, which is rather the point of leaning on the SDK call.Can’t take a dependency? The hand-rolled version
Prefer the SDK; this is what it does for you. If a dependency is off the table, the whole check is about a dozen lines of node:crypto:
// Hand-rolled alternative. t is in MILLISECONDS; v1 is hex HMAC-SHA256 over "<t>.<rawBody>".
import { createHmac, timingSafeEqual } from "node:crypto";
export function verifyWebhook(header, rawBody, secret, toleranceMs = 300_000) {
const parts = Object.fromEntries(
String(header ?? "").split(",").map((seg) => seg.split("=", 2))
);
const t = Number(parts.t);
if (!Number.isFinite(t) || !parts.v1) return false;
if (Math.abs(Date.now() - t) > toleranceMs) return false;
const expected = createHmac("sha256", secret).update(`${t}.${rawBody}`).digest("hex");
const a = Buffer.from(expected);
const b = Buffer.from(parts.v1);
return a.length === b.length && timingSafeEqual(a, b);
}
The whole check comes down to getting three things right, and each one, done wrong, produces the same symptom: a 401 on every legitimate delivery. These are the classic mistakes, whichever provider you’re verifying:
The signature trusts the request, not the sender
Verification proves the request is genuine. It says nothing about whether the email’s sender is genuine: a real inbound email from a spoofed From: will arrive with a perfectly valid signature. That’s what the auth object is for: spf, dkim, and dmarc results computed by the receiving edge. The rule that keeps you safe:
Verify the signature to trust the request. Read
authto trust the sender. Do both before your app takes any action on an inbound email.
Skip the first and anyone can forge events. Skip the second and anyone can spoof a sender into your verified pipeline. Neither is optional once your app actually does something with the mail.
FAQ
What happens if I don’t verify inbound webhooks?
Your endpoint is a public URL that accepts any POST. Anyone who finds it can send a hand-crafted email.received body and make your app file tickets, send replies, or trigger agent actions on data they fully control. Verification is what separates “a real email arrived” from “someone POSTed JSON.”
Why does my signature check fail on valid webhooks?
You’re almost certainly verifying a re-serialized body instead of the raw bytes. Parsing then re-encoding the JSON changes the bytes and breaks the HMAC. Capture the raw body first (express.raw, request.get_data(), reading r.Body), verify, and only then parse. If you hand-rolled the check, also confirm the timestamp unit: t is milliseconds.
Is checking the From: address enough to trust an email?
No. From: is plain text that anyone can set. Use the auth object’s SPF/DKIM/DMARC results, and only trust auth after the webhook signature has verified, since a forged request can put "spf": "pass" in the body.
What’s the ±5-minute window for?
It’s replay protection. The signature covers the t timestamp, and requests older than 300,000 ms are rejected, so a captured valid request can’t be replayed later. Keep your server clock accurate or genuine webhooks may be rejected as stale.
Do I need to implement HMAC myself?
No. verifyWebhook (all SDKs) does the recompute, constant-time compare, and replay check: pass it the header, the raw body, and your secret, and reject when it returns false. The hand-rolled version above is only for the zero-dependency case.
Verifying webhooks is five minutes of work that closes a door you don’t want left open. Read the webhook security docs, then point a domain at MailKite and ship inbound you can actually trust. New to the inbound side? Start with the pillar, Receiving email is the part nobody warns you about, and the Node walkthrough, Parse inbound email to JSON in Node.js.