MailKite
Get started
All posts
Gabe 5 min read

Verify your inbound email webhooks (HMAC — and why you must)

An unverified webhook endpoint is an open door: anyone can POST a fake email.received event. Here's how to verify the HMAC signature in Node, Python, and Go — and why the raw body matters.

Verifying an inbound email webhook means recomputing an HMAC signature over the exact raw bytes your provider sent, constant-time comparing it against the x-mailkite-signature header, and rejecting anything that doesn’t match or falls outside a ±5-minute replay window — because without it, your /hooks/mailkite endpoint is a public URL that anyone on the internet can POST forged email.received events to. If your app files a ticket, sends a reply, or triggers an agent from that event, an unverified endpoint means anyone can forge those actions. This is the security post in the series, and it’s the one I’d read first.

Here’s the uncomfortable framing. You wire up inbound email, it works, you move on. But your webhook handler is a URL sitting on the open internet. If it trusts whatever gets POSTed to it, then anyone who guesses or discovers that 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: address in the body proves nothing; the attacker wrote the whole body. The signature is the only thing that proves the request came from your provider and not from someone with curl.

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:

  1. Did this request come from my provider? — answered by the webhook signature.
  2. Is the email’s sender who they claim to be? — answered by the auth object (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.

Verify per 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 { MailKite } from "mailkite";

// 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); // only parse AFTER verifying
  // …handle event
  res.sendStatus(200);
});

Python (Flask):

from flask import request, abort
from mailkite import verify_webhook

@app.post("/hooks/mailkite")
def hook():
    sig = request.headers.get("x-mailkite-signature")
    # request.get_data() returns the raw bytes — do NOT use 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):

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(401)
        return
    }
    // json.Unmarshal(rawBody, &event) — only after verifying
    w.WriteHeader(200)
}

The same call exists in the other SDKs — PHP ($mk->verifyWebhook($sig, $rawBody, $secret)), Ruby (Mailkite.verify_webhook(sig, raw_body, secret)), and Java (mk.verifyWebhook(signature, body, secret)) — all with the identical contract: pass the raw body, get a boolean, reject on false.

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.

Constant-time compare and the replay window

Two more 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. verifyWebhook compares in constant time, so every wrong guess takes the same duration. (If you ever hand-roll verification, this is the part people forget.)
  • ±5-minute replay window. The signature covers a timestamp, and the SDK rejects anything more than ~5 minutes old. That way, 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.”

Tie it back to sender trust

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 auth to 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.

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 a timestamp, and requests older than about five minutes 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. Just pass it the header, the raw body, and your secret, and reject when it returns false.


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.

Discuss this post: Hacker News Share on X

Related posts