Block disposable emails in Node.js and Express
· Updated · 7 min read · Disposable email
To block disposable emails in Node.js, load the open disposable-email-domains list into a Set at startup, refresh it every day, reduce each signup address to its registrable domain with tldts, and reject matches in an Express middleware before you create the user. That is about 60 lines of code and no paid service. If you'd rather not run the refresh loop yourself, the same middleware can call a lookup API instead; both versions are below.
Everything here targets Express 5 and Node 18 or newer (for global fetch and AbortSignal.timeout). The detection logic is the same as in how to detect disposable email addresses at signup; this article is the Node implementation.
Why not just npm install a list?
You can, but check the publish date first. The most obvious package name on npm, disposable-email-domains, bundles a list of about 121,000 domains and its latest version was published in September 2022. That is a different dataset from the community list on GitHub, disposable-email-domains, had about 9,900 entries in October 2026 and was committed to several times a week.
Any package that bundles a snapshot has the same issue: it is frozen until someone publishes and you upgrade. The more durable approach is to fetch the upstream file at runtime and keep a bundled copy only as a fallback for cold starts when GitHub is unreachable. Why lists decay and how quickly is covered in why disposable email blocklists go stale.
Step 1: a self-refreshing blocklist module
npm install express@5 tldts
curl -sO https://raw.githubusercontent.com/disposable-email-domains/disposable-email-domains/main/disposable_email_blocklist.conf
The curl gives you a fallback copy to commit. Then:
// disposable.js
import { readFile } from 'node:fs/promises';
import { domainToASCII } from 'node:url';
import { getDomain } from 'tldts';
const LIST_URL =
process.env.DISPOSABLE_LIST_URL ??
'https://raw.githubusercontent.com/disposable-email-domains/disposable-email-domains/main/disposable_email_blocklist.conf';
const REFRESH_MS = 24 * 60 * 60 * 1000;
const MIN_ENTRIES = 3000; // refuse obviously broken downloads
// Domains you've decided to accept even if the upstream list has them.
const ALLOW = new Set((process.env.DISPOSABLE_ALLOWLIST ?? '').split(',').filter(Boolean));
let blocklist = new Set();
let etag = null;
function parse(text) {
return new Set(
text.split('\n').map((l) => l.trim().toLowerCase()).filter((l) => l && !l.startsWith('#')),
);
}
async function refresh() {
const res = await fetch(LIST_URL, {
headers: etag ? { 'if-none-match': etag } : {},
signal: AbortSignal.timeout(10_000),
});
if (res.status === 304) return;
if (!res.ok) throw new Error(`blocklist fetch failed: ${res.status}`);
const next = parse(await res.text());
if (next.size < MIN_ENTRIES) throw new Error(`blocklist too small: ${next.size}`);
blocklist = next;
etag = res.headers.get('etag');
}
export async function initBlocklist({ fallbackPath } = {}) {
try {
await refresh();
} catch (err) {
console.warn('using bundled blocklist:', err.message);
if (fallbackPath) blocklist = parse(await readFile(fallbackPath, 'utf8'));
}
setInterval(() => refresh().catch((e) => console.warn(e.message)), REFRESH_MS).unref();
return blocklist.size;
}
export function registrableDomain(email) {
const at = email.lastIndexOf('@');
if (at < 1) return null;
const host = domainToASCII(email.slice(at + 1).trim().replace(/\.$/, ''));
return host ? getDomain(host) : null;
}
export function isDisposable(email) {
const domain = registrableDomain(email);
if (!domain) return false; // let your normal validation reject malformed input
return blocklist.has(domain) && !ALLOW.has(domain);
}
A few decisions in there are deliberate:
domainToASCIIthengetDomain. The first converts internationalized domains to punycode (and lowercases), the second reducesmail.yopmail.frtoyopmail.frusing the Public Suffix List. The list stores registrable domains only, so skipping this lets anyone bypass you with a subdomain. eTLD+1 explained covers why this is harder than splitting on dots.- The
MIN_ENTRIESfloor. If GitHub returns an error page or an empty body with a 200, you keep the previous list instead of swapping in nothing and silently disabling the check. - ETag. GitHub's raw endpoint returns one, so the daily refresh is a cheap conditional request when nothing changed.
- The allowlist. Upstream lists occasionally contain domains that are wrong for your users. An env var lets you override one without a deploy of new code.
isDisposablereturns false on malformed input. Format validation is a separate concern. Keep this function answering one question.
If you run several instances, each one refreshes independently. That's fine at one request per day each. If you deploy to a platform where processes are short-lived, move the refresh into a scheduled job that writes to your cache or KV store and read from there.
Step 2: the Express middleware
// app.js
import express from 'express';
import { initBlocklist, isDisposable } from './disposable.js';
function rejectDisposableEmail(field = 'email') {
return (req, res, next) => {
const email = req.body?.[field];
if (typeof email === 'string' && isDisposable(email)) {
return res.status(422).json({
error: 'disposable_email',
message: "Please use a permanent email address. Temporary inboxes can't receive account notices.",
});
}
next();
};
}
export async function buildApp() {
await initBlocklist({ fallbackPath: new URL('./disposable_email_blocklist.conf', import.meta.url) });
const app = express();
app.use(express.json());
app.post('/signup', rejectDisposableEmail(), async (req, res) => {
// ...create the user, send the verification email
res.status(201).json({ ok: true });
});
app.patch('/me/email', rejectDisposableEmail(), async (req, res) => {
// ...update the address, re-verify
res.json({ ok: true });
});
return app;
}
if (import.meta.url === `file://${process.argv[1]}`) {
const app = await buildApp();
app.listen(3000, () => console.log('listening on :3000'));
}
Add "type": "module" to package.json for the import syntax.
Note the second route. The email-change endpoint is the bypass people forget: sign up with a real address, then switch to a throwaway one. Put the middleware on every route that writes an email address, including invites if invited users get free seats.
On the error message: tell the user what to do, not what you detected. "Please use a permanent email address" is clear without lecturing anyone. Whether you should block at all, or just flag, depends on what a fake account costs you; should you block disposable emails or just flag them goes through that decision.
Step 3: test it
Node's built-in test runner is enough. No supertest needed: listen(0) picks a free port and global fetch can call it.
// signup.test.js (run with: node --test)
import { test, before, after } from 'node:test';
import assert from 'node:assert/strict';
import { buildApp } from './app.js';
let server, base;
before(async () => {
server = (await buildApp()).listen(0);
base = `http://127.0.0.1:${server.address().port}`;
});
after(() => server.close());
const signup = (email) =>
fetch(`${base}/signup`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ email }),
});
test('rejects a listed domain, including subdomains and odd casing', async () => {
assert.equal((await signup('a@mailinator.com')).status, 422);
assert.equal((await signup('a@Mail.YOPMAIL.fr')).status, 422);
});
test('accepts normal addresses', async () => {
assert.equal((await signup('jane@gmail.com')).status, 201);
assert.equal((await signup('jane@stripe.com')).status, 201);
});
This test hits GitHub on startup. To exercise the fallback path in CI, set DISPOSABLE_LIST_URL to an unreachable address and run it again. If the fallback file isn't committed, the module ends up with an empty set and the first test fails, which is exactly the failure you want CI to catch rather than production.
Alternative: call an API instead of hosting the list
If you don't want a refresh loop, PSL handling and a fallback file, call a lookup service and keep the middleware shape. With pingutil, skip_enrichment: true returns the category and verdict without the company-enrichment step, which keeps the call fast. It also flags machine-generated local parts like ccjxx90sze@ as spam, which a domain list can't do.
// pingutil.js
const ENDPOINT = 'https://pingutil.com/v1/lookup';
export async function screenEmail(email) {
try {
const res = await fetch(ENDPOINT, {
method: 'POST',
headers: {
authorization: `Bearer ${process.env.PINGUTIL_KEY}`,
'content-type': 'application/json',
},
body: JSON.stringify({ input: email, skip_enrichment: true }),
signal: AbortSignal.timeout(2000),
});
if (res.status === 400) return { block: false, invalid: true };
if (!res.ok) return { block: false, degraded: `http_${res.status}` }; // 401, 429, 5xx: fail open
const { verdict, category } = await res.json();
return { block: verdict.is_spam, reason: verdict.reason, lowTrust: verdict.low_trust, category };
} catch (err) {
return { block: false, degraded: err.name }; // timeout or network error: fail open
}
}
// async middleware: Express 5 forwards rejections to the error handler,
// but screenEmail never throws, so the signup is never blocked by an outage.
app.post('/signup', async (req, res, next) => {
const result = await screenEmail(req.body.email);
if (result.block) {
return res.status(422).json({ error: 'disposable_email', message: 'Please use a permanent email address.' });
}
req.emailScreen = result; // keep lowTrust/reason for review queues
next();
}, createUser);
Limits to know: keys are limited to 60 requests per minute, so if you get signup bursts larger than that, the 429 path fails open and you should log it. There is no bulk endpoint, so this suits per-signup checks rather than cleaning an existing database. Field details are in the API reference.
Express 5's migration guide documents that rejected promises from async handlers go to your error middleware, so you no longer need a wrapper library for async routes. The design above doesn't rely on that for correctness, though. A screening dependency failing should mean "let them in and log it", never "500 on the signup page".
Before you ship
- Middleware is on signup, email change and any invite route that grants free usage.
- Fallback list is committed and loads when the network fetch fails.
- Refresh failures are logged somewhere you will see them.
- Your own QA test domains are in
DISPOSABLE_ALLOWLISTif they're on the list. - The check runs server side. Client hints are a bonus, not the control.
Using Python instead? The same design in Django and FastAPI is in block disposable emails in Python.
Questions people ask
- Which npm package should I use to block disposable emails?
- Check the publish date before installing anything. Lists change daily, so a package that bundles a snapshot is only as fresh as its last release. Loading the upstream list at runtime avoids the problem.
- What HTTP status should a disposable email rejection return?
- 422 Unprocessable Content with a clear message is the common choice, because the request is well-formed but the value fails a business rule. 400 also works if that is what the rest of your API uses.
- Does this work with Fastify, Koa or Next.js?
- Yes. The isDisposable function has no Express dependency. Only the small middleware wrapper is Express-specific, so call the function from whatever request hook your framework provides.