How to block disposable email at signup
Check one flag at submit, choose your policy, and know exactly why a blocklist can't be your only line of defense.
Contents
You want to block disposable email at signup — the throwaway @mailinator.com, the ten-minute inbox a user grabs to claim your free tier and then abandons before your first onboarding email lands. This is a how-to. It comes down to reading one field at submit time, deciding what to do with it, and handling the part nobody puts on a pricing page: the list is never finished. If you want the background on why these domains exist, see what is a disposable email address.
Start with what you already have
Before you reach for an API, an off-the-shelf library already does the cheap 80 percent. Syntax validation catches typos; a DNS lookup tells you whether the domain can receive mail at all. In Python that is two lines:
from email_validator import validate_email, EmailNotValidError
try:
validate_email("newuser@mailinator.com", check_deliverability=True)
except EmailNotValidError as e:
print(e) # raised on bad syntax or no deliverable MX
For a low-stakes contact form, that is often all you need, and you should not pay for more. But run the snippet above on newuser@mailinator.com and it passes: the syntax is fine and the MX records resolve. Deliverability is not the question you are actually asking. The question is whether this is a permanent address, and answering it requires a maintained list of disposable domains. That is the layer past the standard library.
The one field that matters: disposable
Send the address to the API and read the response:
curl -s https://api.boundstone.io/v1/verify/email \
-H "Authorization: Bearer bs_live_YOUR_KEY" \
-H "Content-Type: application/json" \
-d '{"email":"newuser@mailinator.com"}'
{
"valid_syntax": true,
"domain": "mailinator.com",
"mx_found": true,
"disposable": true,
"role_account": false,
"free_provider": false,
"checks": {
"performed": ["syntax", "mx", "disposable_list", "role_list"],
"not_performed": ["smtp_mailbox", "catch_all"]
}
}
disposable is the boolean you branch on. Three neighbouring fields make the decision sharper: mx_found (the domain can actually receive mail), role_account (support@, admin@ — a shared inbox, not a person), and free_provider (a consumer provider like Gmail; a flag you can read, but note it is not a member of checks.performed).
The checks object is the point of the whole product. performed lists exactly what was tested; not_performed lists what was not. This response says plainly that no SMTP mailbox probe and no catch-all detection happened — because Boundstone does not do those today. That is why a disposable: true here is worth trusting: it is a narrow, list-backed claim, not a guess dressed up as certainty.
A signup handler that branches on policy
Whether a disposable address should be blocked, reviewed, or merely tagged is a business decision, not the API's. Keep that decision in one function so your policy is legible and easy to change:
function decide(check) {
if (!check.mx_found) return { action: "reject", reason: "undeliverable_domain" };
if (check.disposable) return { action: "reject", reason: "disposable_email" }; // hard block
if (check.role_account) return { action: "review", reason: "role_account" }; // flag
return { action: "allow", reason: null }; // free_provider left for analytics
}
Then the Express handler stays thin:
app.post("/signup", async (req, res) => {
const { email, password } = req.body;
const r = await fetch("https://api.boundstone.io/v1/verify/email", {
method: "POST",
headers: {
"Authorization": `Bearer ${process.env.BOUNDSTONE_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ email }),
});
const decision = decide(await r.json());
if (decision.action === "reject") {
return res.status(422).json({ error: "Please use a permanent email address." });
}
await createUser({ email, password, needsReview: decision.action === "review" });
return res.status(201).json({ ok: true });
});
Three policies, one flag. Hard block returns 422 at submit — right for a free tier that abuse targets. Flag for review lets the signup through but marks it, so a human or a downstream rule decides — right when a false positive costs you a real customer. Tag for analytics stores the flag and does nothing else — right when you would rather measure disposable signups than fight them. The same decide function works identically whether you call it from Node, Python, or Go, because the response contract does not change across clients.
Blocklists are never exhaustive
Here is the honest limit. New disposable domains appear faster than any list absorbs them, so disposable: false is not proof of good faith — it means not on today's list. Do not build a wall out of one brick.
Pair the signals you already have in the same response. mx_found: false means the address is undeliverable no matter what, which is why the handler above rejects on it first. role_account: true means a shared inbox that may warrant a different flow. And treat what Boundstone does not do as the reason to trust what it does: smtp_mailbox and catch_all sit in not_performed because verifying that a specific mailbox exists is unreliable and we will not fake it. A passing result from Boundstone is deliberately narrow, and narrow is what makes it dependable. For the wider picture — velocity limits, IP signals, disposable filtering working together — see stopping signup fraud.
The short version
- Read
disposablefromPOST /v1/verify/emailat submit. It is one boolean. - Pick a policy per field: block on
disposable, reject on!mx_found, review onrole_account, tagfree_providerfor analytics. - Keep the decision in one function so changing policy is a one-line edit, not a rewrite.
- Never rely on the list alone — it trails reality by design; combine it with MX and role signals.
- Trust the
not_performedlist. SMTP and catch-all are not done, so a passing result claims only what was actually checked.
Test any address free, no signup, with the email validator tool.