---
title: Secure Inbound Email Webhooks in Node.js | SendHQ
description: Learn to secure inbound email webhooks with HMAC verification, replay protection, idempotency, safe MIME parsing, attachment quarantine, and guarded replies.
canonical: https://sendhq.cc/blog/secure-inbound-email-webhooks-nodejs
last-updated: 2026-09-24
author: Dhrumil Barot
author_bio: Dhrumil is an email marketing expert. Dhrumil built SendHQ so developers and AI agents can send, receive and track email without fighting deliverability on their own.
published: 2026-09-24
updated: 2026-09-24
---
# How to Secure Inbound Email Webhooks in Node.js

By [Dhrumil Barot](https://sendhq.cc/about.md#founder), Founder

Build a secure inbound email webhook in Node.js with raw-body signature verification, replay protection, idempotent ingestion, MIME limits, attachment quarantine, HTML sanitization, and policy-gated replies.

Published September 24, 2026 · Updated September 24, 2026

To secure an inbound email webhook, verify the provider's signature against the untouched request bytes, reject stale or replayed deliveries, persist each event exactly once, and treat every email field and attachment as hostile input. Return success only after the event is durably queued. Parse MIME, scan files, sanitize HTML, and trigger replies in a separate worker with strict authorization and rate limits.

That boundary matters because an inbound email crosses two trust transitions: the provider delivers an HTTP request to your application, and an unknown sender supplies the email inside it. Authenticating the first does not make the second trustworthy.

## Start with the correct trust model

An inbound webhook usually contains an event identifier, envelope sender and recipient, message headers, text and HTML bodies, and attachment metadata. Do not assign all of those fields the same trust level.

Use three separate decisions:

1. **Did your email provider send this HTTP request?** Verify its documented signature over the raw request body. HTTPS protects the connection, but it does not prove that an arbitrary request reaching your public endpoint came from the provider.
2. **Has this delivery already been accepted?** Check a provider delivery ID or another stable event ID under a database uniqueness constraint.
3. **What may the email cause your system to do?** Apply business authorization after parsing. The visible From header, subject, body, links, and attachments remain untrusted even when the webhook is authentic.

[RFC 5322](https://www.rfc-editor.org/info/rfc5322/) distinguishes message content from the SMTP envelope and defines fields such as From, Reply-To, and Message-ID. They are useful inputs, not authorization credentials. Current [DMARC guidance in RFC 9989](https://www.rfc-editor.org/info/rfc9989/) explains that a DMARC pass validates authorized use of the author domain through aligned SPF or DKIM. It does not establish that the message is benign, factually true, or permitted to invoke a privileged action.

## Verify the signature before parsing JSON

Follow your provider's exact header names, encoding, timestamp format, and signing string. The placeholder contract below assumes the provider signs `timestamp.rawBody` with HMAC-SHA256 and sends a hexadecimal digest. Do not copy those details unless they match your provider's documentation.

```
import crypto from "node:crypto";
import express from "express";

const app = express();
const MAX_BODY_BYTES = 2 * 1024 * 1024;
const MAX_AGE_SECONDS = 300;

app.post(
  "/webhooks/inbound-email",
  express.raw({ type: "application/json", limit: MAX_BODY_BYTES }),
  async (req, res) => {
    const signature = req.get("X-Webhook-Signature") || "";
    const timestamp = req.get("X-Webhook-Timestamp") || "";

    if (!verifyWebhook(req.body, timestamp, signature)) {
      return res.status(401).send("invalid signature");
    }

    let event;
    try {
      event = JSON.parse(req.body.toString("utf8"));
    } catch {
      return res.status(400).send("invalid JSON");
    }

    if (!isAllowedInboundEvent(event)) {
      return res.status(400).send("invalid event");
    }

    const inserted = await insertEventOnce(event.id, req.body);
    if (!inserted) return res.status(200).send("duplicate");

    await enqueueInboundEvent(event.id);
    return res.status(202).send("accepted");
  }
);

function verifyWebhook(rawBody, timestamp, suppliedHex) {
  if (!Buffer.isBuffer(rawBody)) return false;
  if (!/^\d{10}$/.test(timestamp)) return false;

  const age = Math.abs(Date.now() / 1000 - Number(timestamp));
  if (age > MAX_AGE_SECONDS) return false;
  if (!/^[a-f0-9]{64}$/i.test(suppliedHex)) return false;

  const signed = Buffer.concat([
    Buffer.from(timestamp + ".", "utf8"),
    rawBody,
  ]);
  const expected = crypto
    .createHmac("sha256", process.env.INBOUND_WEBHOOK_SECRET)
    .update(signed)
    .digest();
  const supplied = Buffer.from(suppliedHex, "hex");

  return supplied.length === expected.length &&
    crypto.timingSafeEqual(supplied, expected);
}
```

The raw bytes are essential. Parsing and re-serializing JSON can change whitespace or key order and invalidate a correct signature. GitHub's official [webhook validation documentation](https://docs.github.com/en/webhooks/using-webhooks/validating-webhook-deliveries) uses the same general pattern: sign the payload with a secret, use SHA-256, and avoid modifying the payload before verification. Node documents `createHmac` and constant-time digest comparison in its [Crypto API](https://nodejs.org/api/crypto.html).

The timestamp check limits replay opportunities, but it is not enough. An attacker who captures a valid delivery inside that window can send it again. You also need a stable delivery ID and a unique constraint.

## Make acceptance idempotent and durable

Create the event record before returning success. Keep the database operation small and make duplication impossible at the storage layer:

```
CREATE TABLE inbound_webhook_events (
  provider_event_id TEXT PRIMARY KEY,
  received_at       TIMESTAMPTZ NOT NULL DEFAULT now(),
  raw_payload       BYTEA NOT NULL,
  status            TEXT NOT NULL DEFAULT 'queued'
);
```

Use `INSERT ... ON CONFLICT DO NOTHING`, then enqueue work through a transactional outbox or another durable mechanism tied to the insert. A process that inserts the row, crashes before publishing to the queue, and still returns success can strand mail. A process that queues first and crashes before recording the event can process it twice. The outbox pattern closes that gap at the cost of another table and worker.

Return quickly after durable acceptance. MIME parsing, antivirus scanning, CRM updates, and agent calls belong in workers. This keeps slow dependencies from causing provider timeouts and retry storms. If the event is invalid, return a permanent 4xx response. If durable storage is temporarily unavailable, return a retryable 5xx response. Check the provider's retry contract rather than assuming every non-2xx status is treated identically.

Retain the raw payload only as long as operations and compliance require. Email may contain personal or confidential data. Encrypt stored payloads, restrict access, redact bodies from ordinary application logs, and document deletion periods. Logs usually need event IDs, state transitions, sizes, verdicts, and error codes, not full message content.

## Validate the event envelope before MIME parsing

Reject structurally invalid events before handing them to a complex parser. Use an allowlist for event types, cap string and array lengths, validate destination addresses, and require the provider event ID. Never let the payload choose a filesystem path, queue name, database table, or outbound sender identity.

Then parse the email with a maintained MIME library under hard resource limits:

- Cap total message bytes and attachment count.
- Limit nesting depth for multipart messages.
- Limit the decoded size of each part, not only its encoded size.
- Bound header count and line length.
- Put parsing in an isolated worker with CPU and memory limits.
- Quarantine malformed messages instead of repeatedly retrying them.

Email parsers must accept substantial legacy syntax. RFC 5322 explicitly includes obsolete forms that conforming parsers need to interpret, which makes hand-written parsing a bad bargain. Store the provider's canonical message ID for diagnostics, but use the webhook delivery ID for deduplication because Message-ID originates in the email content and should not be trusted as your only unique key.

## Quarantine attachments and sanitize HTML

An attachment name and declared MIME type are attacker-controlled hints. Generate your own storage key, keep the original name as escaped metadata, and never write to a path derived from that name. Store attachments outside the web root, enforce per-file and total decoded-size limits, allow only formats your product actually needs, and scan before making a file available.

OWASP's [File Upload Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/File_Upload_Cheat_Sheet.html) recommends layered validation because file extensions, Content-Type values, and file signatures can each be spoofed or bypassed. It also recommends generated filenames, size limits, storage outside the web root, antivirus or sandbox scanning where available, and protection against decompression bombs.

Use an explicit attachment state machine:

```
received -> quarantined -> scanned_clean -> available
                        -> rejected
                        -> scan_failed
```

Do not treat `scan_failed` as clean. A scanner outage should delay access, not silently weaken the boundary.

Email HTML is also active input. Do not inject it into your application with `innerHTML`. Prefer a plain-text view. If product requirements demand formatted HTML, sanitize it with a maintained allowlist-based library and render it in a restricted context. Remove scripts, event-handler attributes, unsafe URL schemes, forms, remote resources, and other active content. OWASP's [Cross-Site Scripting Prevention Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/Cross_Site_Scripting_Prevention_Cheat_Sheet.html) distinguishes output encoding from HTML sanitization and explains when each defense applies.

Remote images create a quieter risk: loading them can disclose the viewer's IP address and signal that the message was opened. Proxy them through a controlled service or block them by default.

## Use authentication results without over-trusting them

If your receiving provider supplies SPF, DKIM, DMARC, spam, or virus verdicts, preserve those structured results. For example, Amazon SES inbound notifications can include `spamVerdict`, `virusVerdict`, `spfVerdict`, `dkimVerdict`, and `dmarcVerdict`, as shown in the official [SES receiving notification examples](https://docs.aws.amazon.com/ses/latest/dg/receiving-email-notifications-examples.html).

Use verdicts as policy signals:

- Quarantine malware or clear spam failures.
- Require stronger checks before allowing financial, account, or deployment actions.
- Compare the authenticated domain with an expected partner domain where the workflow has a pre-established relationship.
- Route ambiguous results to review instead of inventing certainty.

Do not authorize a password reset, bank-detail change, repository mutation, or data export because the display name looks familiar. For sensitive workflows, bind the email to an existing account and require an authenticated confirmation in your application.

## Put outbound effects behind a second policy boundary

Inbound mail often triggers replies, tickets, or AI agents. Keep that automation separate from webhook acceptance. The worker should load a narrow policy for the intended inbox and decide whether it may draft, queue, or send.

At minimum, enforce recipient allowlists or established conversation state, per-thread and per-sender rate limits, a loop-prevention marker, suppression checks, and an idempotency key for every outbound attempt. Require human approval when an agent changes recipients, handles sensitive content, sends outside an existing thread, or exceeds a low-risk volume threshold.

An inbound message is not blanket consent for marketing. Automated replies should serve the user's request or another legitimate transactional context. Honour opt-outs and suppressions, and never turn harvested sender addresses into a campaign list.

## Production checklist

- Verify the provider signature over raw request bytes.
- Reject missing, malformed, or stale timestamps.
- Deduplicate with a stable delivery ID and a database unique constraint.
- Durably queue before acknowledging the event.
- Bound request, MIME, header, and decoded attachment sizes.
- Quarantine and scan attachments before access.
- Sanitize HTML or render plain text.
- Store authentication and malware verdicts as signals, not identity proof.
- Keep email bodies and attachments out of normal logs.
- Gate replies and agent actions with explicit policy, consent, rate limits, and idempotency.
- Alert on signature failures, parse failures, queue lag, scan failures, and unusual inbound volume.
- Test duplicate, replayed, oversized, malformed, malicious, and partial-failure cases.

The simplest safe architecture is a thin authenticated ingestion endpoint, a durable queue, an isolated parser, quarantined content storage, and a policy-controlled action worker. Every extra synchronous dependency makes the endpoint less reliable; every shortcut that treats email content as trusted makes it less safe.

If you want inbound inboxes, outbound sending, and delivery events behind one workspace-scoped API, [SendHQ](https://sendhq.cc/index.md) gives product teams a cleaner boundary to build on. Keep your application responsible for business authorization, consent, and the final decision to act.

## Keep reading

- [How to Process Inbound Email Webhooks Without Duplicate Replies](https://sendhq.cc/blog/inbound-email-webhooks-no-duplicate-replies): Build an inbound email webhook that verifies signatures, deduplicates retries, routes replies safely, and prevents one message from triggeri Read article →
- [How to Receive Email with Amazon SES: S3 and Lambda](https://sendhq.cc/blog/receive-email-amazon-ses-s3-lambda): Build a production-ready inbound email pipeline with Amazon SES, S3, and Lambda, including MIME safety, tenant routing, idempotency, retries Read article →
- [How to Send Transactional Email in Node.js with an Email API](https://sendhq.cc/blog/send-transactional-email-nodejs-api): Build a production-safe Node.js email flow with fetch, stable idempotency keys, explicit error handling, durable retries, and honest deliver Read article →
