An unverified webhook endpoint is an unauthenticated API into your agent's brain. If your handler acts on message.created events without checking where they came from, anyone who discovers the URL — and webhook URLs leak through logs, repos, and error trackers all the time — can POST fake JSON and make your agent reply, write to your database, or kick off downstream jobs on demand. For an email agent that sends autonomously, a forged "new message arrived" event is a remote control.
The fix is mechanical: verify a signature on every request and drop anything that doesn't match.
How the signature works
After your endpoint passes the initial challenge verification, Nylas generates a webhook_secret unique to that destination. Every notification then carries an x-nylas-signature header: a hex-encoded HMAC-SHA256 of the exact request body, signed with that secret. The digest is a 64-character hex string. You recompute it over the bytes you received and compare.
The phrase exact request body is doing real work there. The signature covers the raw bytes on the wire, not a parsed object. Most web frameworks parse JSON before your code runs, and re-serializing it — even with one reordered key or a whitespace change — produces a completely different digest. Capture the raw body before any parser touches it.
Drop forged requests before they do anything
In Express, the JSON body parser replaces the raw payload, so stash it with a verify hook first:
import express from "express";
import crypto from "crypto";
const WEBHOOK_SECRET = process.env.NYLAS_WEBHOOK_SECRET;
const app = express();
// Capture the raw body before JSON parsing re-serializes it.
app.use(express.json({ verify: (req, _res, buf) => { req.rawBody = buf; } }));
function isValidSignature(rawBody, signature) {
const expected = crypto
.createHmac("sha256", WEBHOOK_SECRET)
.update(rawBody)
.digest("hex");
const a = Buffer.from(expected, "utf8");
const b = Buffer.from(signature ?? "", "utf8");
// timingSafeEqual throws on length mismatch, so guard first.
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
app.post("/webhooks/nylas", (req, res) => {
const signature = req.get("x-nylas-signature");
if (!isValidSignature(req.rawBody, signature)) {
return res.status(401).send("Invalid signature");
}
// Signature is valid: safe to process req.body.
res.status(200).send();
});
The same shape works in Python. Flask exposes the raw payload through request.get_data(), which returns the bytes before request.json decodes them — and note the header arrives as either x-nylas-signature or X-Nylas-Signature depending on your stack's casing:
import hmac
import hashlib
import os
from flask import Flask, request
WEBHOOK_SECRET = os.environ["NYLAS_WEBHOOK_SECRET"].encode("utf-8")
app = Flask(__name__)
def is_valid_signature(raw_body: bytes, signature: str) -> bool:
expected = hmac.new(WEBHOOK_SECRET, raw_body, hashlib.sha256).hexdigest()
# compare_digest runs in constant time to resist timing attacks.
return hmac.compare_digest(expected, signature or "")
@app.post("/webhooks/nylas")
def handle_webhook():
raw_body = request.get_data() # raw bytes, before JSON parsing
signature = request.headers.get("X-Nylas-Signature", "")
if not is_valid_signature(raw_body, signature):
return "Invalid signature", 401
# Signature is valid: safe to parse request.json and process.
return "", 200
Two details matter more than they look:
Constant-time comparison. A plain === returns faster on an early mismatch, leaking the correct digest one byte at a time over many attempts. crypto.timingSafeEqual (or hmac.compare_digest in Python) takes the same time regardless of where the values differ, which closes that side channel across all 64 characters. HMAC-SHA256 is the construction from RFC 2104; the comparison step is where most homegrown implementations quietly weaken it.
Reject with a non-2xx, then stop. Return 401 on mismatch. The retry logic only re-sends on temporary 408, 429, 502, 503, 504, and 507 codes, so a 401 refuses the request without queueing a redelivery — and a forged request never reaches your business logic.
The gzip trap
If you enable compressed_delivery, Nylas gzip-compresses the payload and adds Content-Encoding: gzip. The signature covers the compressed bytes. Verify first, decompress after:
app.post(
"/webhooks/nylas",
express.raw({ type: "*/*" }), // keep the body as raw bytes
(req, res) => {
const signature = req.get("x-nylas-signature");
if (!isValidSignature(req.body, signature)) {
return res.status(401).send("Invalid signature");
}
const gzipped = req.get("content-encoding") === "gzip";
const json = gzipped ? zlib.gunzipSync(req.body) : req.body;
const payload = JSON.parse(json.toString("utf8"));
res.status(200).send();
}
);
Decompressing before verifying changes the bytes and breaks the check every single time. This is the most common cause of valid notifications failing verification — many HTTP frameworks inflate the body automatically before your handler runs, so confirm you're hashing the original wire bytes.
Habits that keep the check meaningful
HMAC-SHA256 proves a request was signed with the shared secret. That guarantee only holds while the secret stays secret:
-
Rotate the
webhook_secretlike any credential. If it leaks, an attacker can forge valid signatures, and the 64-character digest is worth nothing. -
Serve the endpoint over HTTPS — Nylas requires an HTTPS
webhook_urlanyway, which keeps the body and signature encrypted in transit. - Add a firewall layer if you want defense in depth. You can restrict inbound traffic to the published Nylas source addresses; the webhook notifications guide documents the allowlist alongside the signing behavior.
This matters double for Agent Accounts (in beta), where the typical pattern is webhook-in, LLM-reasons, email-out. Without verification, the "in" half of that loop accepts input from the entire internet.
FAQ
My genuine notifications keep failing verification. Why? Almost always one of two byte-mangling problems: your framework parsed and re-serialized the JSON before you hashed it, or it auto-decompressed a gzipped body. Even a 1-byte difference between what Nylas signed and what you hashed produces a completely different digest. Hash the wire bytes.
Where does the webhook_secret come from? Nylas generates it after your endpoint passes the initial challenge verification, and it's unique to that destination — two webhook destinations have two different secrets. Read it from the webhook security section of the notifications guide and store it like any other credential, in an environment variable or secrets manager.
Is verifying the signature enough on its own? It proves the request was signed with the shared secret — nothing more. Pair it with HTTPS (required for the webhook_url anyway), secret rotation, and ideally dedup logic downstream, since legitimate notifications can be redelivered and a signature check happily passes the same event twice.
The full recipe — including the Python/Flask version — is in the verify webhook signatures cookbook. A concrete test for this week: curl your own production endpoint with a made-up payload and no signature header. If you get anything other than a 401, you've found your next ticket.
Top comments (0)