A signup form that hard-rejects @mailinator.com will, with the same regex, reject a paying customer arriving on @relay.firefox.com. And while your filter congratulates itself, roughly forty disposable domains registered this morning sail straight through, because your blocklist has not seen them yet and will not for another week.
We operate a temp-mail service. We register domains in bulk, rotate them behind wildcard MX, and retire them faster than any community list can index them. So take this as a confession and a blueprint: the naive blocklist is simultaneously too aggressive against real people and too porous against actual throwaways. If you want to stop disposable signups without bleeding revenue, stop treating it as a binary at the point of signup and start scoring it.
Why a static blocklist is a treadmill you lose
The economics are lopsided, and they favor the disposable side. A provider buys a hundred domains on a cheap registrar under privacy protection, points them all at one or two MX hosts with a wildcard record, and serves millions of inboxes. By the time a domain shows up in github.com/disposable-email-domains/disposable-email-domains, we have usually already moved traffic to the next batch. That list carries roughly 4,000 domains; FGRibreau/mailchecker carries 55,000+ across many locales. Both are excellent, and both go stale within days of any active provider rotating.
The naming is adversarial too. DGA-style domains (tmpmail-xk29.com, inbox-relay7.net) are generated in bulk, so pattern-matching on the string buys you almost nothing. The one thing rotation cannot cheaply change is infrastructure: the MX target, the nameservers, the hosting ASN. A hundred throwaway domains fronted by two MX hosts is a hundred string variations and two fingerprints. That asymmetry is the entire basis of a filter that actually works.
The response has to be graded, not binary. No single signal should ever produce a hard rejection on its own. Signals feed a score; the score picks the action.
Normalize the address before you judge it
Half of the "weird" addresses that trip naive filters are legitimate, and most of that weirdness lives in the local part. Canonicalize before any judgment.
Gmail is dot-insensitive and ignores everything after +, so [email protected] and [email protected] are the same mailbox — RFC 5233 subaddressing generalized to Gmail's dot quirk. Lowercase the domain (it is case-insensitive; the local part technically is not, but treat it as such for deduping). Convert IDN domains to punycode with a real IDNA library, not a hand-rolled Unicode map, so café.fr and xn--caf-dma.fr collapse to one key.
The trap: do not strip +tag from non-Gmail addresses when you compute the account-uniqueness key. Plenty of people run their own domain and rely on [email protected] to route and filter mail. Strip it and you either merge distinct users or break their intended routing.
function normalizeEmail(raw: string): { local: string; domain: string; key: string } {
const trimmed = raw.trim().toLowerCase();
const at = trimmed.lastIndexOf("@");
const local = trimmed.slice(0, at);
const domain = toASCII(trimmed.slice(at + 1)); // punycode via a real IDNA lib
// Gmail-family only: fold dots and drop +tag for the *dedupe key*.
const isGmail = domain === "gmail.com" || domain === "googlemail.com";
const keyLocal = isGmail
? local.split("+")[0].replace(/\./g, "")
: local; // keep +tags for everyone else
return { local, domain, key: `${keyLocal}@${domain}` };
}Store the original address for sending; use key only to detect duplicate accounts.
The DNS signals that actually separate real from throwaway
This is where you outrun the list. Start with deliverability: does the domain publish MX at all?
dig +short MX proton.me
# 10 mail.proton.me.
dig +short MX some-fresh-throwaway-xk29.com
# (empty)An empty MX result is not an automatic reject — RFC 5321 §5.1 says a sender falls back to the domain's A/AAAA record as an implicit MX. So check A/AAAA before penalizing. No MX and no A means the domain cannot receive mail; that is a strong signal, worth serious points, but it still only feeds the score.
The single strongest heuristic is shared-MX fingerprinting. Cluster domains by their MX target and nameservers, because disposable farms reuse infrastructure they cannot cheaply rotate.
dig +short MX mailinator.com # mail2.mailinator.com.
dig +short NS mailinator.comWhen five hundred domains you have never seen all resolve to the same two MX hosts and the same nameservers as a known disposable provider, the individual domain names are irrelevant. You are matching the farm, not the field.
Domain age helps too. Use RDAP (https://rdap.org/domain/<domain>) rather than scraping legacy WHOIS — structured JSON, consistent fields, no per-registrar parsing hell. Treat anything under seven days old as elevated risk, never an auto-block; plenty of real businesses register a domain and immediately start onboarding.
One thing you must not do: live SMTP RCPT TO: probing. It looks clever and it backfires. Servers greylist you with a temporary 4xx, so the probe is inconclusive anyway; repeated RCPT TO across many addresses matches dictionary-attack patterns and gets your IP onto blocklists; and catch-all domains answer 250 for every mailbox, so a "valid" response tells you nothing. Skip it.
Feed a score, not an if-statement
Each signal contributes points. Bands map to actions. Cache every DNS and RDAP lookup in Redis — your signup latency budget is roughly 50–150ms, and you cannot afford a cold DNS round-trip on the request path. MX at 6–24h TTL, RDAP at 7d.
async function assessEmailRisk(email: string): Promise<{ score: number; action: string }> {
const { domain } = normalizeEmail(email);
let score = 0;
if (await onMergedBlocklist(domain)) score += 40;
const mx = await cachedMx(domain); // Redis, 6–24h TTL
if (mx.length === 0 && !(await cachedA(domain))) score += 35;
if (await sharedDisposableMxCluster(mx)) score += 30; // MX/NS fingerprint match
if (await domainAgeDays(domain) < 7) score += 20; // RDAP, 7d TTL
if (await asnSignupVelocityHigh(email)) score += 15; // behavioral
if (isPrivacyForwarder(domain)) score -= 20; // protective, see below
const action = score >= 70 ? "block" : score >= 30 ? "verify" : "allow";
return { score: Math.max(0, score), action };
}The output is { score, action } — never a raw boolean. And the verify band does most of the real work. A double opt-in (a confirmation link that must be clicked from the actual inbox) is the single most effective throwaway filter in existence, because the people spinning up a temp address to grab your free tier will not bother to click. They self-select out. You do not need to be certain a domain is disposable; you need to make disposable users do work they refuse to do.
The false-positive minefield: who you must never block
This is the section that separates engineers who keep their revenue from engineers who quietly destroy it. A whole class of addresses looks disposable to a naive filter and is overwhelmingly real, paying humans:
- Apple Hide My Email —
@privaterelay.appleid.com. Generated per-app, forwards to a real Apple ID. These are iPhone owners; some of your best customers. - Firefox Relay —
@mozmail.comand@relay.firefox.com. Same idea, Mozilla's forwarder. - addy.io (formerly AnonAddy) and SimpleLogin (
@simplelogin.complus custom alias domains) — privacy-conscious paying users. - DuckDuckGo Email Protection —
@duck.com.
These are forwarders, not disposables. Mail sent to them lands in a real, monitored inbox that the human actually reads. Maintain an explicit allowlist and treat them as real — verification is fine, a hard block is malpractice. In the scoring model above, isPrivacyForwarder() subtracts points precisely so a forwarder on a young alias domain does not accidentally climb into the block band.
The same restraint applies to corporate catch-all domains (one mailbox accepts everything for a company — legitimate), brand-new gTLDs (.app, .dev, .page are not inherently sketchy), and non-Latin IDN domains (a .рф or .中国 address is a real person in a market you probably want). None of these deserve a block on domain shape alone.
Track your false-reject rate as a first-class KPI. A filter that wrongly blocks 2% of legitimate signups almost always costs more than every throwaway it stops.
Keep the list fresh without owning the problem
Do not hand-maintain a blocklist. Pull disposable-email-domains and FGRibreau/mailchecker on a cron, merge them, and union in your own MX-cluster observations — the farms you fingerprinted yourself are the ones the public lists have not caught yet. Layer behavioral velocity on top: many signups from one ASN or /24 within minutes, identical device fingerprints across accounts, or a form filled in 400ms by something that is not a human. Every one of those escalates the score; none of them hard-blocks alone. And log every decision with its contributing signals so you can audit a false reject after a customer complains — because one will, and "the filter said no" is not an answer.
Ship-it checklist
- Normalize first: lowercase and punycode the domain; fold Gmail dots and
+tagsfor the dedupe key only. - Never strip `+tags` for non-Gmail domains — real users route on them.
- MX required, A/AAAA fallback per RFC 5321 §5.1 before penalizing "no mail."
- Cluster by MX + NS, not by domain string — infrastructure is what the farm can't cheaply rotate.
- Score, don't branch — every signal adds points; bands (
0–29 / 30–69 / 70+) pick allow / verify / block. - Double opt-in is the real gate — the confirmation click is what throwaways refuse to do.
- Allowlist privacy forwarders — Apple, Firefox Relay, addy.io, SimpleLogin, DuckDuckGo get treated as real.
- Never live-probe `RCPT TO` — greylisting, blocklist risk, catch-all
250
The goal was never to win a war against disposable domains — we register them faster than you can list them, and we will tell you that to your face. The goal is to make the throwaway path more annoying than it is worth while leaving every real user, however privacy-obsessed their address looks, a clean way in.


