How to Detect Email Typos in a Signup Form Without Auto-Correcting Anyone
TL;DR: I replaced a hand-written list of email typos with a list of the 41 real provider domains and a Damerau-Levenshtein match at distance 1, because misspellings are infinite and providers are not. The form suggests, it never rewrites, since a silent correction can send a sign-in code to a stranger who genuinely owns the corrected address. I also gated suggestions on a DNS check, then measured that gate against 30 typo domains and found 18 walk straight through it.
The short answer: how to detect email typos in a signup form
Keep a list of the real provider domains, not a list of misspellings. Compare the typed domain against that list with Damerau-Levenshtein, and only accept a distance of exactly 1. Show the result as a suggestion the user taps, never as a value you write into the field. Run it when the field loses focus, not on every keystroke. Use a DNS lookup to suppress suggestions against domains that genuinely receive mail, and know going in that this filter is weaker than it looks.
That is the shape. The rest of this post is why each piece is the way it is, and the measurement that changed my mind about the last one.
The bug: two correct systems, one lost sign-in code
A user signed up with an address at gmial.com. Fourteen hours later the bounce came back from SES: message expired, unable to deliver in 840 minutes. He never got his sign-in code, and he had no idea why.
Nothing was broken. That is what makes this interesting.
Our validator checked that the domain could receive mail. RFC 5321 section 5.1 says that when a domain publishes no MX record, the sender falls back to the domain's A record and treats it as an implicit mail exchanger. So the validator looked up gmial.com, found no MX but a live A record, and correctly concluded the address was deliverable. Our mail server then did the same thing, correctly, and spent fourteen hours trying to hand mail to a web server that was never going to accept it.
You can still watch it happen:
dig +short MX gmial.com # nothing
dig +short A gmial.com # 51.79.68.169
Two standards-compliant systems, both behaving exactly as specified, and a user locked out of his account. The spec is not wrong. It is just describing a world where a domain without MX records is usually a small server that also does mail, not a squatter parked on a misspelling of the largest mail provider on earth.
Invert the data model
My first fix was the obvious one: a map of known typos to their corrections. gmial.com to gmail.com, hotnail.com to hotmail.com, thirty entries.
Days after it shipped, a production audit turned up a signup at gmail.coms. Not in the list. So I added it, which is the moment the approach tells you what it is. Misspellings are unbounded. Every entry you add is a bug report someone already paid for, and the list can only ever describe the past.
The set on the other side of the mapping is small and stable. Gmail, Outlook, Yahoo, iCloud, Proton, the regional providers, the ISP domains. My list has 41 entries and it has not needed a change since. So enumerate that instead, and compute the relationship rather than storing it:
private function nearestProvider(string $typed, array $providers): ?string
{
foreach ($providers as $provider) {
if (levenshtein($typed, $provider) > self::PREFILTER_DISTANCE) {
continue;
}
if ($this->osaDistance($typed, $provider) === 1) {
return $provider;
}
}
return null;
}
The curated map still exists, but its job changed. It is no longer the mechanism. It is an override list for the handful of cases where the general rule gives the wrong answer, which is a much smaller thing to maintain.
Damerau-Levenshtein at 1, not Levenshtein at 2
The distance function matters more than it looks, and this is where most implementations settle for the wrong tradeoff.
gmial is gmail with two adjacent letters swapped. Under plain Levenshtein that transposition costs 2, because you pay for one substitution in each direction. Under Damerau-Levenshtein it costs 1, because a transposition is a single edit. So if you want to catch the single most common email typo in the world with plain Levenshtein, you have to set your threshold to 2.
A threshold of 2 is a much bigger net than it sounds. At distance 2 you start matching domains that are genuinely different companies, and every one of those is a false positive shown to a real user about their own address. mailcheck.js, the library almost everyone reaches for, uses a flat threshold and has an open issue where pm.me suggests gmx.de. That is what distance 2 buys you.
Damerau-Levenshtein at exactly 1 is the tighter instrument. One insertion, one deletion, one substitution, or one transposition. Anything further away is somebody else's domain and you should say nothing.
I wrote the distance function by hand rather than pulling a package. It is about forty lines, it is the standard optimal string alignment table with one extra branch for the transposition case, and owning it means the threshold is a decision in my code rather than a default in someone else's.
The prefilter that only pays off when there is no typo
That levenshtein($typed, $provider) > 2 line above is doing something worth explaining, because the reason it is correct is also the reason it is fast.
PHP's native levenshtein() is C. My hand-written Damerau version is PHP. So I use the fast one to eliminate candidates before the slow one runs. The filter is lossless: if two strings are within Damerau distance 1, they are within plain Levenshtein distance 2, always, because a transposition is the only edit the two functions price differently and it costs at most 2 in the plain version. Nothing that should match can be filtered out.
I benchmarked it against the real 41-domain list, 3000 iterations each:
| Typed domain | With prefilter | Without |
|---|---|---|
gmial.com (a typo, matches early) |
13.1 µs | 13.3 µs |
acme-logistics.example (matches nothing) |
6.4 µs | 1240.1 µs |
The prefilter does nothing for the typo, because the loop finds gmail.com and breaks almost immediately. It is worth 193x on the domain that matches nothing at all.
That is the case you actually optimize for. Almost nobody signing up has a typo. In a business product most of them are on a corporate domain that will never be within one edit of Gmail, so the full 41-domain scan is the hot path, and it is the path that costs a millisecond if you let the slow function see every candidate.
The DNS gate, and where it goes quiet
Fuzzy matching on its own is not safe enough to show a user. iclod.com is one edit from icloud.com, and it is also a real New Zealand ISP with real customers. Telling those customers they misspelled their own address is worse than saying nothing.
So the suggester only speaks when the typed domain genuinely cannot receive mail:
private function probeUndeliverable(string $domain): bool
{
$mxRecords = $this->resolver->mxRecords($domain);
$hasNullMx = collect($mxRecords)->contains(
fn (array $record): bool => $record["host"] === ".",
);
if ($hasNullMx) {
return true;
}
return $mxRecords === [] && ! $this->resolver->hasAddressRecords($domain);
}
Two signals. A null MX record, which is RFC 7505: a single record with preference 0 pointing at ., the domain owner explicitly declaring that it accepts no mail. And a domain that resolves to nothing at all, no MX and no address record, which no mail server on earth can deliver to. Note that an empty MX list on its own is not one of the signals, because of RFC 5321. That is the lesson from the original bug, encoded.
This works, and it is where I stopped for a while. Then I measured it.
I ran every one of the 30 typo domains in my curated list through dig, this week:
| Result | Count | What it means |
|---|---|---|
| Live MX records | 12 | Accepts mail. Gate stays silent. |
| No MX, live A record | 6 | Implicit MX per RFC 5321. Mail is attempted. Gate stays silent. |
| Null MX (RFC 7505) | 3 | Explicit refusal. Gate fires. |
| No DNS at all | 9 | Nothing to deliver to. Gate fires. |
Twelve of thirty trip the gate. The other eighteen do not. Sixty percent of the typos I had already collected by hand would pass a check designed to catch them.
The reason is not a bug in the check. It is the business model on the other side, and email typosquatting is a business. For the twelve that do run mail servers, the reason is that a typosquatter registers a near-miss domain precisely because people mistype it, and mail arriving by accident is the product. Paying for mail hosting is the cost of goods sold. The other six did not bother, and RFC 5321 carries their mail for free.
The clustering makes it obvious. Four of those thirty domains answer on the same mail server:
dig +short MX gmai.com ggmail.com hotmial.com oultook.com
# 1 mail.h-email.net.
# 1 mail.h-email.net.
# 5 mail.h-email.net.
# 1 mail.h-email.net.
One operator, catching misspellings of Gmail, Hotmail and Outlook, on one box. This is not a new observation in the security literature. Szurdi and Christin measured it at population scale in Email Typosquatting at IMC 2017, and found millions of registered typo domains funnelling into a small number of mail servers. WorkOS names it too, in a good post on why regex is not email validation, where they call it an honest caveat and note that "user@gmial.com may well pass an MX check".
What the measurement adds is the size of the caveat, and the design conclusion that follows from it. The gate goes quiet on exactly the domains that were registered to steal mail. So it cannot be the mechanism. It has to be a false-positive filter sitting behind a curated list, not a substitute for one, which is why the curated map bypasses it entirely. The list catches the domains that are dangerous on purpose. The fuzzy match plus the DNS gate catches the long tail of typos nobody has bothered to register yet.
If you take one thing from this post, take that ordering. A DNS check makes fuzzy matching safe to show. It does not make it complete, and the gap is not the harmless part of the distribution.
The most common email domain typos
Here is the whole curated list, with what DNS says about each one today. These are the thirty I collected from real signups, not a generated permutation set. The right-hand column is the audit from the section above, per domain, so you can see exactly which ones a DNS check would have caught.
| Typed | Meant | DNS today |
|---|---|---|
gmial.com |
gmail.com |
No MX, live A |
gmai.com |
gmail.com |
Live MX |
gmali.com |
gmail.com |
No MX, live A |
gamil.com |
gmail.com |
Live MX |
gnail.com |
gmail.com |
Live MX |
gmaill.com |
gmail.com |
No DNS |
ggmail.com |
gmail.com |
Live MX |
gmail.con |
gmail.com |
No DNS |
gmail.cm |
gmail.com |
Null MX |
gmail.comm |
gmail.com |
No DNS |
gmail.coms |
gmail.com |
No DNS |
yahooo.com |
yahoo.com |
Null MX |
yaho.com |
yahoo.com |
No MX, live A |
yaoo.com |
yahoo.com |
No MX, live A |
yahho.com |
yahoo.com |
Null MX |
yahoo.con |
yahoo.com |
No DNS |
hotnail.com |
hotmail.com |
Live MX |
hotmial.com |
hotmail.com |
Live MX |
hotmai.com |
hotmail.com |
Live MX |
hotamil.com |
hotmail.com |
No MX, live A |
hotmail.con |
hotmail.com |
No DNS |
outlok.com |
outlook.com |
No MX, live A |
outloo.com |
outlook.com |
Live MX |
outllok.com |
outlook.com |
Live MX |
oultook.com |
outlook.com |
Live MX |
outlook.con |
outlook.com |
No DNS |
iclould.com |
icloud.com |
Live MX |
icoud.com |
icloud.com |
Live MX |
icloud.con |
icloud.com |
No DNS |
protonmai.com |
protonmail.com |
No DNS |
Read the right-hand column as a warning rather than a list. Every row marked Live MX or "No MX, live A" is a domain that will happily accept a sign-in code meant for somebody else. Only the Null MX and No DNS rows are safe in the sense that the mail simply fails.
If you want to start from this list rather than build your own, take it. But do not stop at it, because it is a snapshot of what my users mistyped, and the next thing yours mistype will not be on it. That is the whole argument for computing the match instead of storing it.
Suggest, never correct
This is the part in the title, and it is a security decision rather than a UX preference.
If your form silently rewrites gmial.com to gmail.com, consider the case where the user meant a domain you decided was a typo. Now you have sent a sign-in code, a password reset, or an invoice to an address that belongs to somebody else, and that somebody else may have registered the domain for exactly this reason. The user never sees the address they typed. They see an account that does not work, and you cannot tell from your logs that anything went wrong.
The correction has to be an offer:
Did you mean @gmail.com?
Make it tappable so accepting is one action, because a suggestion that costs the user retyping their whole address is a suggestion most of them ignore. But the tap is the point. The user, not your code, decides that the address they typed was wrong.
On blur, not on every keystroke
The first version ran on every keystroke, and it was unusable in a way that is obvious in hindsight.
Somebody typing gmail.com passes through g, gm, gma, gmai. Every one of those is within one edit of something. gmai is one edit from gmail. Shorter fragments sit inside distance 1 of several unrelated providers at once. So the hint flickers between wrong answers while the user is still typing, and by the time they finish it has told them three things, two of which were nonsense.
Run it when the field loses focus. The user has finished the thought, the string is complete, and the suggestion appears once with an answer worth reading. If you want something on input as well, debounce it and require the domain to contain a dot and at least two characters after it, but blur alone is most of the value for none of the noise.
What is already out there
Worth knowing before you build your own.
mailcheck.js is the canonical implementation, 7,900 stars, 44 open issues, last push in May 2022. It uses a flat distance threshold, which is the tradeoff discussed above.
smashsend/email-spell-checker is the maintained one, the email spell checker on npm I would actually install today, a drop-in replacement that was still being pushed to last month. Its pull request #3 is worth reading on its own: the maintainers moved from sift4 to sift3 specifically to change which differences get detected. The distance-function question is not settled in this space, and anyone telling you it is has not looked.
Neither library does the DNS half, because neither can. That part has to live on your server, which means the complete version of this feature is a small endpoint rather than a script tag.
The spec did not change, the world underneath it did
RFC 5321 is not wrong. It was written for an internet where a domain with no MX record was a small machine that also handled its own mail. Falling back to the A record was the generous, correct thing to do, and it made mail work for thousands of people who had not thought carefully about DNS.
That internet is gone. Today a domain with no MX record and a live A record is more likely to be parked on a misspelling of the largest mail provider on earth than to be somebody's mail server. The fallback that used to deliver mail now spends fourteen hours trying to hand a sign-in code to a web server.
Nobody made a mistake here. The rule still says what it always said, my validator still followed it, and the standard is still the standard. What moved was the ground under it.
That is the part I keep turning over. Every stack I build sits on defaults I inherited from people solving a different version of this problem, in a friendlier place, with different adversaries. Most of those defaults are still correct. Some number of them are like this one: still correct, still followed, and quietly describing somewhere that no longer exists. I do not have a good way to find out which is which, other than the thing that worked here, which was to stop assuming and go measure.