Skip to main content

Block Disposable Emails in Node.js and Express

Published Updated 16 minute readGuidesRichelo Killian

To block disposable emails in Node.js, run a live check inside an Express middleware on your signup route, act on the action verdict it returns, and make the whole thing fail open so a slow or unreachable check never costs you a real signup. You can implement the check two ways: install the official isitdisposable npm package, or call the detection Application Programming Interface (API) directly with the fetch built into Node.js 18 and later. This guide gives you both as copy-pasteable ECMAScript Modules (ESM) code, sharing one Express middleware that gates POST /signup.

I have spent more than fifteen years in email deliverability, and the reason this check is worth ten minutes of wiring is simple: disposable domains do not stand still. In our August 2026 data report we counted 10,864 genuinely new disposable domains in a single 34 day window, which is why this guide uses a live API instead of a bundled list that starts going stale the day you install it. The throwaway signups you accept today become the hard bounces in next month's campaign, and a bounce spike is a list-quality signal your Email Service Provider (ESP) acts on. The background on all of that lives in The Complete Guide to Disposable Email Detection; this piece is the Node.js implementation.

What do you need before you start?

Four things, all ordinary. Node.js 18 or later, because the raw path uses the global fetch that shipped in 18; if you are on Node.js 20.6 or later you also get --env-file, which removes the need for a dotenv dependency. Express 4 or 5; everything below runs unchanged on either, and the one real difference between them, how async errors are handled, is covered in the fail-open section. ESM, so your package.json needs "type": "module" (or name your files .mjs). And an API key.

Install the dependencies:

npm install express isitdisposable

Then put your secret key in the environment, never in source. The isitdisposable API uses two kinds of key: sk_ secret keys for server-side use like this, and pk_ publishable keys that are safe in the browser because they are restricted to origins you approve. An Express server is server-side, so it uses a secret key. Put it in a .env file:

ISITDISPOSABLE_API_KEY=sk_live_your_secret_key

and start your server with the file loaded, which on Node.js 20.6+ needs no library at all:

node --env-file=.env server.js

If you deploy somewhere that injects environment variables directly, such as a Platform as a Service (PaaS) dashboard or a container manifest, set ISITDISPOSABLE_API_KEY there and skip the file.

How do you block disposable emails with the official npm package?

The official Software Development Kit (SDK) is the shortest path: npm install isitdisposable (already done above), construct one client, and call check. It has zero runtime dependencies, ships TypeScript types, runs on Node.js 18 and later, and handles timeouts, retries, and fail-open behavior for you.

The detection logic lives in one small file, check.js. It wraps the SDK call and normalizes the result into the only four fields the rest of this guide needs:

import { IsItDisposable } from "isitdisposable";

const client = new IsItDisposable(); // reads ISITDISPOSABLE_API_KEY

const ALLOW_ON_FAILURE = Object.freeze({
  checked: false,
  disposable: false,
  relay: false,
  action: "allow",
});

export async function checkEmail(email) {
  try {
    // failOpen is true by default: on a timeout, dropped connection,
    // rate limit, or server error, the SDK resolves to a safe
    // checked: false, action: "allow" result instead of throwing.
    const r = await client.check({ email });
    return {
      checked: r.checked === true,
      disposable: r.disposable === true,
      relay: r.relay === true,
      action: r.action ?? "allow",
    };
  } catch (err) {
    // Only genuine misconfiguration, like a missing or revoked key,
    // reaches this catch. Log it loudly, but never let it block signup.
    console.error("isitdisposable check failed:", err);
    return ALLOW_ON_FAILURE;
  }
}

The contract of this file is the important part, and both implementation paths in this guide honor it: checkEmail never throws, and any trouble at all resolves to an allow. That single promise is what makes the middleware below safe to drop into any Express app. The full request and response reference is at the isitdisposable docs.

How do you gate POST /signup with an Express middleware?

With a small async middleware that runs after body parsing and before your signup handler. This is the Express email validation middleware the rest of the guide revolves around, in middleware.js:

import { checkEmail } from "./check.js";

export async function rejectDisposableEmail(req, res, next) {
  const email =
    typeof req.body?.email === "string" ? req.body.email.trim() : "";

  if (!email) {
    return res.status(400).json({ error: "Email is required." });
  }

  const result = await checkEmail(email);

  if (result.action === "block") {
    return res.status(422).json({
      error:
        "Please use a permanent email address. Temporary addresses stop working, and you would lose access to this account.",
    });
  }

  res.locals.emailCheck = result;
  return next();
}

And the server that uses it, server.js:

import express from "express";
import { rejectDisposableEmail } from "./middleware.js";

const app = express();
app.use(express.json());

app.post("/signup", rejectDisposableEmail, async (req, res) => {
  const { email } = req.body;
  const check = res.locals.emailCheck;

  if (check.action === "warn") {
    // Soft signal: proceed, but consider requiring email verification
    // before this account gets anything worth farming.
  }

  // Create the account here: hash the password, insert the user, etc.
  res.status(201).json({ ok: true, email });
});

app.listen(3000, () => {
  console.log("listening on http://localhost:3000");
});

Three files, and the flow is complete: express.json() parses the body, the middleware runs the live check and refuses a blocked address with a 422 and a human explanation, and everything else lands in your handler with the verdict attached to res.locals.emailCheck. The 400 versus 422 choice is taste; what matters is the message, because the person on the other end of a block is sometimes a legitimate user who typed a throwaway out of habit, and a clear sentence converts a surprising number of them into real signups.

Keep the middleware on the routes where an address enters your database, POST /signup above all, plus anywhere else that captures email, like a newsletter endpoint or an invite form. Do not mount it globally with app.use, because most requests carry no email and there is no reason to spend a network call on them.

What do allow, warn, and block actually mean in your handler?

The API condenses everything it knows about an address into one action field, and the middleware maps each value to one behavior. This is deliberate: your route code stays a three-way branch instead of a pile of per-signal conditionals.

Action What it means What the middleware does
allow The address is fine, or the check could not complete (fail open) Passes the request through untouched
warn Something is off, for example a suspicious pattern, but not enough to refuse Passes the request through with the verdict on res.locals.emailCheck so the handler can add friction
block The address is a confirmed throwaway that will not survive Responds 422 with a clear message; the handler never runs

The recommended default is exactly what the code above does: refuse only on block, and treat warn as a cue for proportionate friction rather than a refusal. The friction that works best is requiring email verification before the account can touch anything worth abusing, trials, credits, referral rewards, because a throwaway that must receive and click a link has already lost most of its appeal. If you would rather branch on raw signals, the normalized result also carries disposable and relay booleans, and the full response has more fields still, but action exists so that the service's judgment, including its handling of edge cases, arrives pre-digested. Notice what the mapping does not do: it does not block on relay, which is the subject of its own section below.

How do you call the API directly with fetch instead?

If you would rather not add a dependency, the disposable email API is simple to call from plain Node: replace the contents of check.js with a hand-rolled version. Same file name, same export, same never-throw contract, so middleware.js and server.js do not change by a single character. It calls POST https://api.isitdisposable.com/v1/check with a bearer token and a two-second budget:

const API_URL = "https://api.isitdisposable.com/v1/check";

const ALLOW_ON_FAILURE = Object.freeze({
  checked: false,
  disposable: false,
  relay: false,
  action: "allow",
});

export async function checkEmail(email) {
  const controller = new AbortController();
  const timer = setTimeout(() => controller.abort(), 2000);

  try {
    const res = await fetch(API_URL, {
      method: "POST",
      headers: {
        "Content-Type": "application/json",
        Authorization: `Bearer ${process.env.ISITDISPOSABLE_API_KEY}`,
      },
      body: JSON.stringify({ email }),
      signal: controller.signal,
    });

    if (!res.ok) {
      console.error(`isitdisposable responded ${res.status}`);
      return ALLOW_ON_FAILURE;
    }

    const data = await res.json();
    return {
      checked: data.checked === true,
      disposable: data.disposable === true,
      relay: data.relay === true,
      action: data.action ?? "allow",
    };
  } catch (err) {
    console.error("isitdisposable check failed:", err);
    return ALLOW_ON_FAILURE;
  } finally {
    clearTimeout(timer);
  }
}

The two-second AbortController budget is doing important work: without it, a slow network hop would stall your signup form, and a signup form is the one place where waiting is worse than skipping the check. The helper deliberately makes a single attempt with no retries, because inside a two-second budget on an interactive request, a retry is just a slower failure; if the first attempt does not land, the right move is to allow and log.

The raw response is richer than the four fields this guide keeps. A check on an address at a known disposable domain comes back shaped like this, trimmed to the fields you are most likely to use:

{
  "checked": true,
  "disposable": true,
  "relay": false,
  "mx_valid": true,
  "action": "block",
  "domain": "mailinator.com",
  "normalized_email": "[email protected]"
}

Mailinator is the classic example of the category, public inboxes on a domain that very much receives mail, which is why mx_valid is true while action is still block; deliverable and permanent are different questions. You can see what the dataset currently knows about that domain on its public page at isitdisposable.com/d/mailinator.com, and every other tracked domain has the same page at the same path.

Should you use the npm package or the raw API?

Both are correct, both are shown in full above, and both feed the identical middleware, so this is a local decision inside one file. Here is the honest comparison for a disposable email checker npm decision:

Consideration Official SDK (isitdisposable) Roll it yourself (fetch)
Setup npm install isitdisposable, construct a client No dependency, write the fetch and the parsing
Types TypeScript types ship with the package You define your own
Timeouts and retries Built in, tuned for interactive use You own the timeout; the example above deliberately skips retries
Fail-open handling Built in (failOpen: true by default), resolves to allow on network trouble You implement the allow-on-failure contract, as above
Bundle weight Zero runtime dependencies, one package in your tree Zero packages, roughly forty lines of your own code
API changes Absorbed by SDK updates You patch your parser if fields evolve

My honest read: the SDK is the default for the same reason you use a database driver instead of writing wire protocol, and the raw path is entirely legitimate for teams with a hard no-dependencies posture or an unusual runtime. What you should not do is agonize; the interface between your app and this decision is one exported function, and you can swap the implementation any afternoon.

What happens when the detection service is unreachable?

Your signups keep working, because every layer of this setup is built to fail open, and this is the section worth reading twice. A disposable check is a nice-to-have on each individual request; your signup route is not. The design goal is that no failure in the check, a timeout, an outage, an exhausted quota, a revoked key, can ever surface to the user as a broken form.

The layers, from the inside out. The service itself fails open by design: conditions like an exceeded quota come back as a normal response with checked: false and action: "allow" rather than an error. The SDK fails open next: with failOpen true, which is the default, network trouble resolves to that same safe allow result instead of a rejection. The raw helper implements the identical contract by hand, which is what ALLOW_ON_FAILURE and the catch-everything shape are for. And the helper's never-throw promise means the middleware cannot blow up either.

That last layer has an Express-specific payoff worth spelling out. In Express 4, an async middleware that throws does not forward the error to your error handler; the promise rejects into nowhere and the request hangs until the client gives up. Express 5 finally forwards rejected promises to the error-handling chain. Because checkEmail never rejects, the middleware above behaves identically on both majors, and you do not need to remember which one you are on or wrap the call in a defensive try/catch of your own. If you modify the helper, preserve the contract; it is carrying more weight than it looks.

One operational note: fail open does not mean fail silently. Both helpers log every failure with console.error, and those log lines are your early warning for a misconfigured key or a network problem. Allowing a few unchecked signups during a blip is the correct trade; not noticing for a month is not.

Where should the check run, client or server?

Server-side, at the signup handler, always. The middleware above is the authoritative check precisely because it runs where nobody can skip it: anyone can bypass your frontend entirely and POST straight to /signup with curl, and for the abuse cases that make disposable emails worth blocking, trial farming, referral fraud, vote stuffing, that is exactly what the scripted attacker does. A check that lives only in the browser is a suggestion, not a gate. This is a rule for every stack, not just Node.js; the Next.js guide makes the same point with Server Actions, and it applies to a plain Express app word for word.

Client-side checking still has a place, as user experience rather than enforcement. Telling someone their throwaway address will be refused while they are still on the form is kinder than letting them submit and bounce off a 422. If you want that, call the same API from the browser with a pk_ publishable key, which is safe to expose because it only works from origins you approve, or use the drop-in snippet described in the docs. Just keep the mental model straight: the browser check saves honest users a round trip, and the Express middleware is the part that actually protects you.

Should you block relay addresses like Apple Hide My Email?

No. Relay and alias services, Apple Hide My Email, Firefox Relay, SimpleLogin, addy.io, DuckDuckGo Email Protection, Proton aliases, are not disposable, and the difference is the whole ballgame: a disposable address is a throwaway inbox that will be gone in an hour, while a relay address forwards to a real, permanent inbox that a real person reads. Relay users are privacy-conscious, frequently paying customers; Apple Hide My Email users are iCloud+ subscribers by definition. Blocking them to stop throwaways turns away exactly the people you want.

This is why the API reports relay as its own signal instead of folding it into disposable, and why the recommended action never says block for a relay address alone. The middleware in this guide inherits that behavior automatically by branching on action, which means the correct relay handling is what you get by default, with the relay boolean still on res.locals.emailCheck if you want to tag those accounts for analytics. If you want the full reasoning, including what relay adoption looks like from the receiving side, it is covered in the relay section of The Complete Guide to Disposable Email Detection. The one-line version for your code review: if someone proposes if (result.relay) block, send them that link.

How do you test the whole flow?

From the outside, with curl, the same way an attacker would arrive. Start the server, then post a signup with an address at a known disposable domain:

curl -X POST http://localhost:3000/signup \
  -H "Content-Type: application/json" \
  -d '{"email":"[email protected]"}'

You should get the 422 and the message from the middleware. Then confirm the happy path with an address at a real domain, which should return the 201 from your handler:

curl -X POST http://localhost:3000/signup \
  -H "Content-Type: application/json" \
  -d '{"email":"[email protected]"}'

If you want to look one layer down and see the raw verdict rather than your route's translation of it, hit the API directly with your key:

curl -X POST https://api.isitdisposable.com/v1/check \
  -H "Authorization: Bearer $ISITDISPOSABLE_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"email":"[email protected]"}'

Finally, test the failure mode on purpose, because fail open is a claim you should verify rather than trust: set ISITDISPOSABLE_API_KEY to garbage, restart, and post a signup. The correct behavior is a logged error on the server and a normal 201 for the user, the check quietly stepping aside instead of taking your form down with it. If that is what you see, the contract holds end to end.

A last word on what you are protecting. The cost of the throwaway signups you fail to block does not show up as a Gmail reputation problem; mailbox providers score you on how their own users treat your mail, and a dead throwaway domain has no users there. It shows up weeks later at your ESP, when the abandoned domains go dark, your campaign posts a wave of hard bounces, and an automated list-quality review lands on your account. Blocking disposables at POST /signup is cheap insurance against that specific, unglamorous failure.

If you would rather not run your own list to do it: this guide's checks are backed by isitdisposable.com, a live dataset of more than 217,000 domains drawn from six upstream sources, validated against live MX records and refreshed daily, with relay detection built in and fail-open behavior by design. The free plan includes 250 lookups per month, and there is a 14-day full-access trial with no credit card, which is enough to wire the middleware into your Express app and watch what your own signup traffic is actually made of.

About the author

Richelo Killian

Founder

Founder of isitdisposable.com and the SenderWorx email tool suite. Builds email infrastructure and anti-abuse tooling.