Skip to content
Skip to main content

Verify webhook signatures

Your webhook URL is public, so anyone who finds it can POST fake JSON at it. Without a check, your handler treats a forged message.created the same as a real one, which means an attacker can trigger replies, writes, or downstream jobs at will. The fix is to verify a signature on every request and drop anything that doesn’t match.

Nylas signs each notification with a secret only your endpoint and the API share. This recipe shows how to recompute that signature and compare it in constant time, in Node.js and Python, including the one detail that trips up most teams: compressed bodies.

After your endpoint passes the challenge verification, the API generates a webhook_secret unique to that destination. Every notification then carries an x-nylas-signature (or 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 and compare.

The signature covers the raw bytes Nylas sent, not a parsed object. Your framework often re-serializes JSON before your code runs, and even a reordered key or changed whitespace produces a different digest. Capture the body as raw bytes or a raw string before any parser touches it, and read the secret from the webhook security section.

Read the raw request body, compute an HMAC-SHA256 over those exact bytes using your webhook_secret, then compare the hex digest against the x-nylas-signature header with a constant-time function. A standard === comparison leaks timing information one byte at a time, so the 64-character hex digests must be checked in constant time across all 64 bytes.

In Express, the JSON body parser replaces the raw payload, so register a verify hook that stashes req.rawBody first. The handler below rejects any request whose recomputed digest doesn’t match before it parses or acts on a single field.

The Python handler follows the same shape. Flask exposes the raw payload through request.get_data(), which returns the bytes before request.json decodes them. Use hmac.compare_digest for the constant-time check, and return a non-2xx status on any mismatch so a forged request never reaches your business logic.

When you enable compressed_delivery, Nylas gzip-compresses the JSON and adds a Content-Encoding: gzip header. The signature covers the compressed bytes, so you verify it against the raw gzip body first and decompress only after the check passes. Decompressing first changes the bytes and breaks verification every time.

This is the single most common cause of valid notifications failing the check. Many HTTP frameworks decompress automatically before your handler runs, so confirm you’re hashing the original wire bytes, not the inflated JSON. Even a 1-byte difference between the compressed and decompressed payload produces a completely different digest. The full behavior lives in the compressed notifications section, which spells out that the digest covers the compressed payload.

Signature verification is the core defense, but a few operational habits keep your endpoint trustworthy. HMAC-SHA256 (RFC 2104) proves a request was signed with the shared secret, yet it only holds if that secret stays secret and the comparison stays constant-time. Treat the four points below as a checklist before you ship.

  • Rotate the webhook_secret. Treat it like any credential. If it leaks, an attacker can forge valid signatures, so rotate it and update the value your handler reads. The 64-character hex digest is only as strong as the key behind it.
  • Serve the endpoint over HTTPS. Nylas requires an HTTPS webhook_url, which encrypts the body and signature in transit. Plain HTTP would expose both to anyone on the network path.
  • Compare in constant time. A byte-by-byte === returns faster on an early mismatch, leaking the correct digit over many tries. crypto.timingSafeEqual and hmac.compare_digest take the same time regardless of where two values differ, which closes that timing side channel.
  • Reject on mismatch with a non-2xx status. Return 401 and stop. The retry logic only re-sends on temporary 408, 429, 502, 503, 504, and 507 codes, so a 401 tells the API the request was refused without queueing a redelivery.

For defense in depth, you can also restrict inbound traffic to the published Nylas source addresses at your firewall. The webhook notifications guide documents the source-address allowlist option alongside the rest of the signing behavior.