Open your users table and sort by email. Somewhere in there is a cluster like [email protected], [email protected], [email protected] and [email protected]. To your uniqueness constraint they are four people. To Google they are one inbox, and every one of your verification emails landed in it.
This is the cheapest form of repeat signup there is. It takes no tooling, no proxies and no second account, only a keyboard. Fixing it takes a normalization function and an extra column. Understanding why that fix is incomplete takes a little longer.
Why these addresses collapse
Two separate features are at work.
Plus addressing (also called subaddressing) lets a user append +anything to the local part. Mail for [email protected] is delivered to [email protected]. People use it legitimately to filter mail and track who leaks their address. Abusers use it because each variant passes a unique-email check.
Dot insensitivity is Gmail-specific. For consumer gmail.com addresses, Google ignores periods in the local part, so johndoe, john.doe and j.o.h.n.d.o.e are the same account. googlemail.com is an alias of gmail.com. A local part of eight letters has 2⁷ = 128 dot placements, before you add any plus tag.
Neither feature is a bug. Both are documented and useful. They just mean that the string a user types is not the identity of the mailbox.
Provider-specific rules
Normalization has to be provider-aware, because the rules differ and getting them wrong merges real, distinct people.
| Provider | Dots in local part | Plus addressing | Normalize to |
|---|---|---|---|
| gmail.com, googlemail.com | Ignored | Supported | Remove dots, strip +tag, domain gmail.com |
| Google Workspace (custom domain) | Significant | Supported | Strip +tag only |
| Outlook.com, Hotmail, Live | Significant | Supported | Strip +tag only |
| Fastmail, Proton | Significant | Supported | Strip +tag only |
| Most custom domains | Significant | Unknown | Lowercase only |
A few cautions about that table. Google Workspace domains run on Gmail infrastructure, but Google describes dot-insensitivity for consumer Gmail addresses, so the safe policy is to treat dots as significant on custom domains; you also cannot tell a Workspace domain from its name without looking up MX records. Some providers support other separators, and a few let the user configure them. Treat the table as a starting policy you can extend, not a law, and keep the list of providers you normalize explicit.
A normalization function
Store the address exactly as the user typed it, since that is where you send mail, and store a normalized form next to it for matching.
const DOT_INSENSITIVE = new Set(['gmail.com', 'googlemail.com']);
const PLUS_PROVIDERS = new Set([
'gmail.com', 'googlemail.com',
'outlook.com', 'hotmail.com', 'live.com',
'fastmail.com', 'proton.me', 'protonmail.com',
]);
export function normalizeEmail(raw: string): string {
const email = raw.trim().toLowerCase();
const at = email.lastIndexOf('@');
if (at < 1) return email;
let local = email.slice(0, at);
let domain = email.slice(at + 1);
if (domain === 'googlemail.com') domain = 'gmail.com';
if (PLUS_PROVIDERS.has(domain)) local = local.split('+')[0];
if (DOT_INSENSITIVE.has(domain)) local = local.replace(/\./g, '');
return `${local}@${domain}`;
}
Lowercasing the local part is technically outside the email spec, which allows case-sensitive local parts, but virtually every mailbox provider treats them case-insensitively, and so should a fraud check.
Then use the normalized form for the decisions that care about repeat identity: trial eligibility, referral rewards, promo redemption. Do not use it as the login identifier or as a hard unique constraint on accounts without thinking through the support cases, because a user who legitimately signs up twice with an alias and later wants to merge needs a path.
ALTER TABLE users ADD COLUMN email_normalized text;
CREATE INDEX users_email_normalized_idx ON users (email_normalized);
At signup: if a user with the same email_normalized already had a trial, give the new account no trial, or ask them to sign in instead.
Where normalization stops working
Normalization only collapses variants of one mailbox. Determined repeat signups simply use different mailboxes, and there are plenty of cheap ways to get them.
- Catch-all domains. Anyone with a domain can route
[email protected]to one inbox. Every address is genuinely different, and there is no rule to normalize them. - Relay and alias services. Privacy relays generate random forwarding addresses. They are used by privacy-conscious people for good reasons, so you cannot block them outright, and you cannot map them back to an owner.
- Disposable inboxes. Throwaway providers hand out addresses that live for minutes. Domain lists help, though they lag behind new domains; see disposable email detection at signup.
- New free accounts. Creating another free webmail account takes a few minutes, which is a small price for another month of your product.
Each of these is a new, real inbox. Email-level logic has nothing left to compare.
Put a device check behind it
What does not change across those mailboxes is usually the device the person signs up from. That is the layer that catches what normalization misses: identify the browser at signup, verify the event server-side, and look at accountsOnDevice, which lists every account you have attached to that device.
const event = await prynt.getEvent(requestId); // @prynt/node, secret key
const emailRepeat = await db.users.exists({ email_normalized: normalizeEmail(email) });
const deviceRepeat = event.accountsOnDevice.count >= 1;
if (emailRepeat || deviceRepeat) grantTrial = false; // account yes, trial no
After you create the user, updateEvent(requestId, { linkedId: String(user.id) }) attaches the account, so the next signup from that device, with whatever address, sees it. The emailHygiene Smart Signal and the EMAIL_DISPOSABLE reason code add a third view focused on the address itself.
The two checks are complementary. Normalization is free, instant and needs no JavaScript, so it still covers visitors whose identification is missing. The device check covers the cases where the address genuinely changed. Together they catch the lazy repeat and the careful one. The posts on multi-accounting detection and throwaway account farm signals go further into the patterns behind organized repeats.
Handle the false positives kindly
Normalization occasionally merges people who are not the same: a shared family address, or a Workspace domain your list treated as Gmail. A device check occasionally sees a shared laptop. Neither is a reason to refuse the account. The gentlest effective response is to create the account without a new trial and show a clear path forward: sign in to the existing account, or choose a paid plan.
Add the normalized column today; it is an afternoon of work. Then try a few aliases from one browser on the playground to see how the device layer treats them, and read the form protection page if bots are filling your signup form as well.
Try it free
Prynt is device intelligence with a free tier — visitor IDs, bot & fraud Smart Signals, and behavioral biometrics, powered by a cross-site network. Start free.