Verify Mailgun webhook signature headers, replay defense, rotation
Minimal correct verification for Mailgun: exact headers and signing inputs, replay protection, and a safe secret rotation workflow.
Includes runnable Node + Python snippets.
No credit card required. See pricing.
TL;DR
- Verify Mailgun authenticity before parsing or side effects.
- Verify over the raw request body bytes; avoid JSON re-stringify.
- Required headers: "x-mailgun-timestamp", "x-mailgun-token", "x-mailgun-signature".
- Add replay protection: store a dedupe key for a TTL window and reject repeats.
- Rotate secrets without downtime: accept {current, previous} during overlap, then retire.
Debugging auth failures? Use the webhook debugging playbook .
Anti-patterns
- Parsing/transforming the body before verification (breaks signing inputs).
- Returning 2xx before authenticity is proven (you just acknowledged an attacker).
- Skipping replay protection (signed requests can still be replayed).
If verification fails in production, capture the raw body + headers and follow the debugging playbook .
Core concepts
Secure webhooks by verifying authenticity deterministically: exact headers, exact signing input, and no side effects until verification passes.
Threat model (start here)
Forgery, replay, leaked secrets/tokens, and tenant confusion are the common webhook incidents. Design verification and secret lookup to fail closed.
Exact verification inputs
This strategy verifies HMAC-SHA256 (hex) using hex(HMAC_SHA256(secret, "<timestamp><token>")). Verify before parsing or side effects.
Replay + rotation
Signed requests can still be replayed. Store a dedupe key for a TTL window and rotate secrets with an overlap window (current + previous).
Required headers (from the ingest strategy)
Confirm these are present before debugging signature math.
- Mailgun can also include signature fields in JSON payloads depending on event setup.
Need workflows around tunnels/replay? See local development (mailgun) and retries & backoff (mailgun) .
Normal flow vs With Hooque flow
The goal is the same either way: authenticate first, then make retries/replays harmless with idempotency.
Normal security flow
- Request hits your endpoint.
- Verify signature/token before parsing or side effects.
- Add a dedupe key + TTL window to stop replays.
- Rotate secrets with overlap (current + previous).
- If your endpoint is down (deploy/offline/sleep), deliveries fail and you rely on retries/replays.
With Hooque
- Provider → Hooque ingest (verification at ingest time).
- Only verified requests are queued.
- Workers pull/stream events and ack/nack/reject explicitly.
- UI provides inspection + controlled redelivery after fixes.
- Ingest stays online when your worker is offline; events queue until you resume.
Production checklist
Use this when shipping Mailgun webhook verification (and when debugging signature failures).
- [ ] HTTPS only; reject plain HTTP
- [ ] Body size limit + content-type allowlist (prefer strict in production)
- [ ] Capture the raw request body bytes for verification
- [ ] Verify required headers: `x-mailgun-timestamp`, `x-mailgun-token`, `x-mailgun-signature`
- [ ] Verify signature/token before parsing or side effects
- [ ] Constant-time compare for signatures/tokens (avoid timing leaks)
- [ ] Replay protection (dedupe key + TTL window)
- [ ] Dedupe on a stable key (provider event ID when available, otherwise hash of signature+body)
- [ ] Secret storage in a secret manager/KMS (avoid hardcoding)
- [ ] Secret rotation: accept `{current, previous}` during overlap, then retire
- [ ] Multi-tenant safety: scope secrets to the webhook/tenant identity
- [ ] Structured logs for verification failures (never log secrets)
- [ ] Alert on spikes in auth failures (misconfig or attack) Reference implementation
Minimal correct verification, replay protection, and secret rotation (Node + Python).
1) Minimal verification
Verify authenticity before parsing or side effects.
Node
Express raw body + verification
// Minimal SHA256 verification (Express raw body)
// npm i express
import crypto from "crypto";
import express from "express";
const app = express();
app.post("/webhook", express.raw({ type: "*/*", limit: "2mb" }), (req, res) => {
const secret = process.env.WEBHOOK_SECRET ?? "replace_me";
const signature = req.header("x-mailgun-signature") ?? "";
const rawBody = Buffer.isBuffer(req.body) ? req.body : Buffer.from(req.body ?? "");
const payload = rawBody.toString("utf8");
if (!secret) return res.status(500).send("missing WEBHOOK_SECRET");
if (!signature) return res.status(401).send("missing x-mailgun-signature");
const timestamp = req.header("x-mailgun-timestamp") ?? "";
const token = req.header("x-mailgun-token") ?? "";
if (!timestamp || !token) return res.status(401).send("missing mailgun signature fields");
const toSign = `${timestamp}${token}`;
const hmac = crypto.createHmac("sha256", secret);
hmac.update(toSign, "utf8");
const digest = hmac.digest("hex");
const expected = "" + digest;
if (!timingSafeEqualString(signature, expected)) return res.status(401).send("bad signature");
return res.sendStatus(202);
});
app.listen(3000, () => console.log("listening on :3000"));
function timingSafeEqualString(a, b) {
const aBuf = Buffer.from(a, "utf8");
const bBuf = Buffer.from(b, "utf8");
if (aBuf.length !== bBuf.length) return false;
return crypto.timingSafeEqual(aBuf, bBuf);
} Python
FastAPI raw body + verification
# Minimal SHA256 verification (FastAPI raw body)
# pip install fastapi uvicorn
import hashlib
import hmac
import os
import time
from fastapi import FastAPI, Request, Response
app = FastAPI()
@app.post("/webhook")
async def webhook(request: Request) -> Response:
secret = (os.getenv("WEBHOOK_SECRET") or "replace_me").encode("utf-8")
signature = request.headers.get("x-mailgun-signature", "")
raw_body = await request.body()
payload = raw_body.decode("utf-8")
if not signature:
return Response("missing x-mailgun-signature", status_code=401)
timestamp = request.headers.get("x-mailgun-timestamp", "")
token = request.headers.get("x-mailgun-token", "")
if not timestamp or not token:
return Response("missing mailgun signature fields", status_code=401)
to_sign = f"{timestamp}{token}"
message = (to_sign)
if isinstance(message, str):
message = message.encode("utf-8")
digest = hmac.new(secret, message, hashlib.sha256).hexdigest()
expected = "" + digest
if not timing_safe_equal(expected, signature):
return Response("bad signature", status_code=401)
return Response(status_code=202)
def timing_safe_equal(a: str, b: str) -> bool:
# hmac.compare_digest is constant-time for equal-length strings.
return hmac.compare_digest(a, b) 2) Replay protection
Use timestamp max-age when available, plus a dedupe window so retries and replays are harmless.
Node
Timestamp max-age + dedupe window
// Replay protection: timestamp max-age (when available) + dedupe window (in-memory example)
import crypto from "crypto";
const seen = new Map(); // key -> expiresAtMs
function purgeSeen() {
const now = Date.now();
for (const [key, expiresAt] of seen.entries()) if (expiresAt <= now) seen.delete(key);
}
// Inside your verified webhook handler:
purgeSeen();
const sig = req.header("x-mailgun-signature") ?? "";
// Prefer a provider delivery/event ID when available. Otherwise hash signature + raw body.
const rawBody = Buffer.isBuffer(req.body) ? req.body : Buffer.from(req.body ?? "");
const key = crypto.createHash("sha256").update(sig).update(".").update(rawBody).digest("hex");
const ttlMs = 10 * 60 * 1000;
if (seen.has(key)) return res.status(409).send("replay detected");
seen.set(key, Date.now() + ttlMs); Python
Timestamp max-age + dedupe window
# Replay protection: timestamp max-age (when available) + dedupe window (in-memory example)
import hashlib
import time
seen: dict[str, float] = {} # key -> expires_at_epoch_seconds
def purge_seen() -> None:
now = time.time()
for k, exp in list(seen.items()):
if exp <= now:
del seen[k]
# Inside your verified webhook handler:
purge_seen()
sig = request.headers.get("x-mailgun-signature", "")
raw_body = await request.body()
key = hashlib.sha256((sig + ".").encode("utf-8") + raw_body).hexdigest()
ttl_seconds = 10 * 60
if key in seen:
return Response("replay detected", status_code=409)
seen[key] = time.time() + ttl_seconds 3) Secret rotation (current + previous overlap)
Accept two secrets during an overlap window, then retire the previous secret.
Node
Try both secrets during overlap
// Secret rotation: verify against {current, previous} during overlap
import crypto from "crypto";
function timingSafeEqualString(a, b) {
const aBuf = Buffer.from(a, "utf8");
const bBuf = Buffer.from(b, "utf8");
if (aBuf.length !== bBuf.length) return false;
return crypto.timingSafeEqual(aBuf, bBuf);
}
function verifyWithSecrets(payload, headers, secrets) {
const signature = headers["x-mailgun-signature"] ?? "";
if (!signature) return false;
const timestamp = headers["x-mailgun-timestamp"] ?? "";
const token = headers["x-mailgun-token"] ?? "";
if (!timestamp || !token) return false;
const toSign = `${timestamp}${token}`;
for (const secret of secrets) {
const hmac = crypto.createHmac("sha256", secret);
hmac.update(toSign, "utf8");
const digest = hmac.digest("hex");
const expected = "" + digest;
if (timingSafeEqualString(signature, expected)) return true;
}
return false;
}
const secrets = [process.env.WEBHOOK_SECRET_CURRENT, process.env.WEBHOOK_SECRET_PREVIOUS].filter(Boolean);
// ok = verifyWithSecrets(payload, headers, secrets) Python
Try both secrets during overlap
# Secret rotation: verify against {current, previous} during overlap
import hashlib
import hmac
import os
def verify_with_secrets(payload: str, headers: dict, secrets: list[bytes]) -> bool:
signature = headers.get("x-mailgun-signature", "")
if not signature:
return False
timestamp = headers.get("x-mailgun-timestamp", "")
token = headers.get("x-mailgun-token", "")
if not timestamp or not token:
return False
to_sign = f"{timestamp}{token}"
message = (to_sign)
if isinstance(message, str):
message = message.encode("utf-8")
for secret in secrets:
digest = hmac.new(secret, message, hashlib.sha256).hexdigest()
expected = "" + digest
if hmac.compare_digest(expected, signature):
return True
return False
secrets = [os.getenv("WEBHOOK_SECRET_CURRENT"), os.getenv("WEBHOOK_SECRET_PREVIOUS")]
secrets = [s.encode("utf-8") for s in secrets if s] For idempotency patterns and safe backoff policies, read Mailgun retries & backoff .
Common failure modes
Most security failures are configuration drift: wrong secrets, missing headers, or verifying the wrong bytes.
All Mailgun webhooks start failing verification
Likely causes
- Wrong secret/token configured (test vs prod mismatch).
- Missing/renamed headers (x-mailgun-timestamp, x-mailgun-token, x-mailgun-signature).
- Body parsing/middleware changed the signing input.
Next checks
- Confirm the secret value and environment match the sender.
- Log which headers are present (names only) and verify casing/normalization.
- Verify over raw bytes and ensure no transformation before verification.
Duplicate deliveries cause duplicate side effects
Likely causes
- No dedupe store/TTL window.
- Handler is not idempotent.
- Retries/replays treated as new events.
Next checks
- Choose a stable dedupe key and store it with TTL.
- Make side effects idempotent (unique constraints, idempotency keys).
- Use a queue/worker model and explicit retry controls.
How Hooque helps
Verify at ingest and only process verified events — then use the queue to make retries/replays safe and auditable.
- Hosted ingest for Mailgun with strategy-specific verification at ingest time.
- Only verified events enter the queue; unverified requests never reach your workers.
- Durable queue decouples ingest from processing (burst protection).
- Inspection + controlled redelivery after fixes (with an audit trail).
- Strategy-specific docs: local dev (mailgun) and retries & backoff (mailgun).
Start with signup, review pricing, and keep the debugging playbook handy for incidents.
FAQ
Short answers to the most common verification and incident-response questions.
Do I need the raw request body to verify Mailgun webhook signatures?
General: Yes — signature schemes are computed over the exact bytes sent. Parsing JSON and re-stringifying can change whitespace/ordering and break verification. (x-mailgun-timestamp, x-mailgun-token, x-mailgun-signature) How Hooque helps: Hooque verifies at ingest before your app code runs, so you avoid framework-specific body parsing pitfalls.
What’s a webhook replay attack and how do I prevent it?
General: A replay attack is when a valid, signed request is captured and sent again. Use a timestamp max-age when available and store a dedupe key (event ID or a hash) for a TTL window. How Hooque helps: Only verified requests enter the queue, and you can implement dedupe/idempotency in workers while using UI redelivery for safe replays.
How do I rotate webhook secrets without downtime?
General: Accept `{current, previous}` secrets during an overlap window, verify against both, then retire the previous secret. Rotate per environment and per tenant. How Hooque helps: You can update verification secrets in one place (ingest) and keep workers stable while rotations happen.
Should I rely on IP allowlists to secure webhooks?
General: IP allowlists are brittle (IPs change; shared infrastructure). Prefer cryptographic verification (HMAC/public key) as the primary control, and use HTTPS + rate limits as defense-in-depth. How Hooque helps: Ingest-time verification reduces the attack surface of your own infrastructure and keeps unverified traffic out of your worker systems.
What status code should I return for invalid signatures?
General: Use `401`/`403` for authentication failures and avoid returning `2xx` before authenticity is proven. How Hooque helps: Hooque can reject invalid requests at ingest so only verified events are available to consumers.
How do I avoid tenant confusion in a multi-tenant webhook endpoint?
General: Bind secret lookup to the webhook identity/tenant and never “try secrets until something matches” across tenants. Scope verification to the correct tenant first. How Hooque helps: Webhooks are isolated per workspace/webhook, so verification config is naturally scoped and auditable.
Why does signature verification fail only in production?
General: Common causes are wrong secrets (test vs prod), proxies/middleware changing the body, header normalization differences, and clock skew for timestamp-based schemes. How Hooque helps: A consistent ingest layer reduces environment differences, and the dashboard makes it easier to inspect failed deliveries and debug safely.
Start processing webhooks reliably
Verify at ingest, queue durably, and consume with explicit ack/nack/reject — without building custom webhook infrastructure.
No credit card required