A signup form has two jobs that pull in opposite directions. It should let every real person in with as little friction as possible, and it should keep out typos, throwaway inboxes and bots that later bounce your welcome emails. If you've ever seen one person make 100 accounts on the same throwaway domain, or gotten a warning from your email provider about bounce rates, you know the second job matters.
This post is how I'd set up real-time email validation on a signup form today: what to check, when to check it, what to show the user, and what to do when the check itself fails.
Already using Better Auth? The MailRambo plugin setup guide shows how to screen email/password signup without creating a separate check endpoint. It uses free fast mode, a two-second remote timeout and no automatic retries, with signup continuing on runtime errors.
Why not just run a full verification at signup?
A full verification talks to the recipient's mail server over SMTP. That usually takes 1-5 seconds, sometimes longer when a server greylists or stalls. Nobody wants a spinner on a signup button for five seconds, and a timeout on a slow server shouldn't block a real customer.
The good news is that most bad signup addresses don't need a mailbox probe to be caught. Typos (gmial.com), domains with no mail server, disposable providers and malformed input can all be detected from syntax and DNS in well under a second. That's what MailRambo's mode=fast does, and it's free, so you can call it on every form submission.
| Problem | Caught by fast check? | Caught by full check? |
|---|---|---|
Malformed address (jane@) |
Yes | Yes |
Typo of a big provider (gmial.com) |
Yes, with suggestion | Not as a typo |
| Domain with no mail server | Yes | Yes |
| Disposable / throwaway inbox | Yes | Yes |
| Mailbox doesn't exist at a real domain | No | Yes |
| Catch-all domain | No | Yes |
The fast check handles the first four instantly. The last two need a full check, which you can run after signup where latency doesn't matter.
What the fast check returns
Here is the real response shape from the API docs for a typo:
{
"email": "jane@gmial.com",
"mode": "fast",
"deliverable": false,
"reason": "possible_typo",
"suggestion": "jane@gmail.com",
"checks": { "mx": true, "disposable": true, "role_account": false, "free_email": true },
"credits_remaining": null
}
The rules are simple:
deliverable: falsemeans a check failed. Thereasonis one ofinvalid_syntax,possible_typo,disposableorno_mx.deliverable: nullwithreason: "fast_check_passed"means nothing was wrong. The mailbox itself wasn't checked, so this isn't a "yes", it's "no problem found".- Fast mode never returns
true. suggestionis present for typos. Show it as "Did you mean...?".credits_remainingisnullbecause fast checks are free.
When to validate: blur, submit, or both
I've tried a few patterns. What works:
- On blur of the email field, run the check and show a hint if there's a typo suggestion. Don't show a red error while the user is still typing. Validating on every keystroke is noisy and wastes requests.
- On submit, validate again on the server. This is the check that counts. The browser check is only UX; anyone can skip it.
- Debounce and cache. If the user tabs back and forth, don't re-check the same value. MailRambo also answers repeat checks of the same address within 24 hours from cache, but it's still cleaner not to send them.
UX patterns that don't annoy real users
The wording matters as much as the check:
- Typos: suggest, don't block. "Did you mean jane@gmail.com?" with a one-click fix. Some people really do have addresses at odd domains, so let them keep what they typed if they insist.
- Disposable: block with a clear reason. "Please use a permanent email address. We send your login link and receipts here." Explaining why reduces support tickets.
- No mail server: block. "We couldn't find a mail server for acme.con. Check the part after the @."
- Invalid syntax: block, but keep the input so the user can fix it.
- Never say "your email is invalid" for a passed check.
fast_check_passedjust means go ahead.
That typo-override advice is for the custom form in this article. The Better Auth plugin's fixed 0.1.0 policy instead rejects possible_typo and does not expose an override option; choose the documented plugin behavior deliberately.
The browser side
Your API key must never reach the browser. The browser calls your own backend, which calls MailRambo. A small vanilla JS example:
const input = document.querySelector("#email");
const hint = document.querySelector("#email-hint");
let lastChecked = "";
input.addEventListener("blur", async () => {
const email = input.value.trim();
if (!email || email === lastChecked) return;
lastChecked = email;
try {
const res = await fetch("/api/check-email", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email }),
});
const data = await res.json();
showHint(data);
} catch {
hint.textContent = ""; // network problem: say nothing, let the server decide on submit
}
});
function showHint({ ok, reason, suggestion }) {
hint.textContent = "";
if (suggestion) {
hint.innerHTML = `Did you mean <button type="button" id="fix">${suggestion}</button>?`;
document.querySelector("#fix").onclick = () => {
input.value = suggestion;
hint.textContent = "";
};
} else if (!ok && reason === "disposable") {
hint.textContent = "Please use a permanent email address.";
} else if (!ok && reason === "no_mx") {
hint.textContent = "That domain can't receive email. Check the part after the @.";
} else if (!ok && reason === "invalid_syntax") {
hint.textContent = "That doesn't look like an email address.";
}
}
In a real app I'd build the suggestion button with textContent rather than innerHTML, but the idea is the same.
The server side (and why it fails open)
The server endpoint calls POST /v1/verify with "mode": "fast" in the body. Here it is in Node 18+ with Express:
app.post("/api/check-email", express.json(), async (req, res) => {
const email = String(req.body.email || "").trim();
const result = await fastCheck(email);
res.json(result);
});
async function fastCheck(email) {
try {
const r = await fetch("https://www.mailrambo.com/v1/verify", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.MAILRAMBO_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ email, mode: "fast" }),
signal: AbortSignal.timeout(2000),
});
if (!r.ok) return { ok: true, reason: "check_skipped" }; // fail open
const body = await r.json();
return {
ok: body.deliverable !== false,
reason: body.reason,
suggestion: body.suggestion || null,
};
} catch {
return { ok: true, reason: "check_skipped" }; // timeout or network error: fail open
}
}
In your real signup handler, call fastCheck() again and reject when ok is false.
That catch is deliberate. At signup, I fail open: if the verification call times out, gets rate limited (429), or the service is down (503), the user gets in. Losing a real customer because a third-party API hiccuped is worse than letting one bad address through, and you'll catch that address in the full check later anyway.
This might sound odd coming from me, because MailRambo's full check is fail-closed: uncertain mailboxes are a "no". But those are different questions. The full check asks "is it safe to send a campaign here?", where a false "yes" costs you reputation. The signup check asks "should this person be allowed to create an account?", where a false "no" costs you a customer. Fail-closed for sending, fail-open for signup.
After signup: the optional full check
Once the account exists, you have time. Queue a full check in the background, for example right after signup or before the first marketing email:
curl -X POST https://www.mailrambo.com/v1/verify \
-H "Authorization: Bearer $MAILRAMBO_KEY" \
-H "Content-Type: application/json" \
-d '{"email": "jane@acme.com"}'
It costs 1 credit and returns a strict deliverable: true or false with a reason like mailbox_exists, mailbox_not_found or catch_all. What you do with a false depends on your product. I'd typically keep the account, stop marketing sends to it, and ask the user to confirm their address in-app. Transactional mail like password resets should still go out; the user asked for it.
Checklist
- [ ] Browser calls your backend, never the MailRambo API directly
- [ ] Fast check on blur, again on the server at submit
- [ ] Typos suggest a fix; disposable, no-MX and invalid syntax block with a clear message
- [ ] Timeouts, 429 and 503 fail open at signup
- [ ] Full check queued after signup or before the first bulk send
- [ ] Email confirmation (double opt-in) still in place for anything important
Framework-specific guides
If you're on a specific stack, these posts show the same pattern wired into the auth provider:
- Block fake signups in Supabase Auth
- Block disposable emails in Firebase Auth
- Stop fake signups in Clerk
- Block disposable emails on a Next.js signup
Try it
Fast checks are free on every plan, and the Free plan includes 100 free checks a month for full verification. You can try single addresses in the free email verifier, and the developer docs have SDKs for Node and Python plus free mr_test_ keys for building your integration.