Your signup form has a uniqueness check on email. Every one of these passes it, and every one of them lands in the same inbox:
shadab@example.com
shadab+abc@example.com subaddressing (RFC 5233)
Shadab@Example.COM case
s.h.a.d.a.b@gmail.com Gmail ignores dots
shadab@googlemail.com Gmail's other domain
shadab@example.com. trailing root dot
shadab@example.com zero-width space, invisible in the field
Whatever a free tier, a trial, a rate limit or a referral bonus is worth, it is worth that many times over.
Where each variant comes from
Plus addressing is RFC 5233 subaddressing. Everything after the + in the local part is a tag the receiving server discards for routing. Gmail, Outlook, iCloud, Fastmail, Proton and Zoho all honour it, as do most self-hosted setups.
Gmail ignores dots in the local part entirely — a Google-specific behaviour, not an email one. s.h.a.d.a.b@gmail.com and shadab@gmail.com are the same account.
googlemail.com is a historical alias of gmail.com, still live for the same reason old domains always are.
Case is only formally insensitive in the domain. Every provider of consequence treats the local part the same way in practice.
A trailing dot on the domain is a valid fully-qualified name. example.com. and example.com resolve identically.
Invisible characters — zero-width space, zero-width non-joiner, soft hyphen, BOM — render as nothing in a form field and compare as different in a database. Someone signing up twice with a visually identical address is not making a mistake.
The fix that causes a worse bug
The tempting move is to write one normaliser: lowercase everything, strip the +tag, remove all dots, done.
Do not remove dots outside Gmail.
At most providers john.smith@company.com and johnsmith@company.com are two different colleagues. A universal dot-strip means the second person to sign up is told an account already exists — and they cannot fix it, because it is their real address and there is nothing to change.
Locking a legitimate user out of an account they are entitled to is a worse failure than the duplicate-account problem you started with. It is also much harder to notice: duplicates show up in your metrics, and the person who quietly gave up at your signup form does not.
Apply the dot rule only to the domains where it is true.
What to store
Compute a canonical identity and store it beside the address, in its own column:
- Strip invisible characters, then trim.
- Split on the last
@. Reject anything with a different number of them — a second@in an unquoted local part means the input is not one address. - Lowercase the domain, drop any trailing dots, and fold known aliases (
googlemail.com→gmail.com). - Lowercase the local part. If it is quoted (
"odd+name"@example.com), stop here: the+is part of the literal address, not a tag. - Otherwise cut the local part at the first
+. - Only if the domain is dot-insensitive, remove the dots.
The canonical form is for comparison only. Never mail to it, never show it to the user, and never overwrite what they typed — that is the address they own and the one your mail provider needs.
Store it rather than computing it at query time, because the mapping is one-way. Given shadab@example.com you cannot enumerate the +tag rows that would collide with it. There is nothing to look up unless it was written down.
Derive it in a database trigger or an ORM lifecycle hook, not at each call site. Most products have half a dozen places that create a user — signup, admin invite, SSO JIT provisioning, an import, a seed script — and the seventh will be added by someone who has never heard of this problem.
Two decisions to make deliberately
Reject tagged addresses, or accept and canonicalise them?
Rejecting is stricter and worth considering, because it also stops someone registering name+a@ and name+b@ before either exists — there is nothing in the database to collide with yet. If you reject, say why and what to do instead; "invalid email" sends the user to support.
Should the unique constraint live in the database?
Tempting, and a trap if you are retrofitting. Any accounts that already collide were created before the column existed, and a unique index that fails to build takes the service down at startup. That turns a data-quality problem into an outage. Enforce uniqueness in the signup path, where it can produce a message a person can act on, and have the migration report pre-existing collisions rather than destroying them. Which of two real accounts to keep is a decision for a human.
What this does not solve
Canonicalisation catches variants of one mailbox. It does nothing about thousands of genuinely separate mailboxes at disposable-email domains, which is the other half of trial abuse and needs a domain list and a policy decision — reject, or allow and flag.
It also does not catch someone with two real inboxes, and it should not try. At some point "is this the same person" stops being an email problem and becomes a fraud-detection one, with a much higher false-positive cost.
Test the negatives
The tests that matter most are the ones asserting addresses stay separate:
john.smith@company.comandjohnsmith@company.comare two people."odd+name"@example.comkeeps its plus.- Two malformed inputs are not "the same mailbox" — if unparseable compares equal to unparseable, the first bad address blocks every one after it.
A deduplication rule is only as good as the things it refuses to merge.
Related reading: account security fundamentals and our writing on supply chain identity and provenance.