A fake signup on CogniPrep is not free. Creating an account provisions a Stripe customer, and the account then sits in every audience query our lifecycle email cron runs. Ten of them is noise. A few thousand is a data-quality problem you have to write a migration to fix.
So signup rejects disposable email providers. The implementation is about forty lines, and almost all of the thinking is in where those lines run and which flows they apply to.
The list is enormous
We use the disposable-email-domains package, which is two files:
node_modules/disposable-email-domains/index.json 2.3M 121,570 domains
node_modules/disposable-email-domains/wildcard.json 8.0K 399 bases
121,570 exact domains. That number is the entire design constraint. It is not something you send to a browser so it can grey out a Sign up button.
Two match modes, because a blocklist of exact strings is not enough
index.json is exact matches. wildcard.json is base domains where any subdomain also counts, which is how services like Mailinator hand out addresses at inbox.mailinator.com, team.mailinator.com and anything else somebody types.
const exactDomains = new Set<string>(
(disposableList as unknown as string[]).map((d) => d.toLowerCase())
);
const wildcardDomains = (wildcardList as string[]).map((d) => d.toLowerCase());
export function isDisposableEmailDomain(email: string): boolean {
const domain = getEmailDomain(email);
if (!domain) return false;
if (exactDomains.has(domain)) return true;
return wildcardDomains.some((base) => domain === base || domain.endsWith(`.${base}`));
}
The Set is built once at module load, so the exact check is O(1) across 121,570 entries. The wildcard pass is a linear scan of 399, which is cheap enough not to bother optimising.
The subtle line is domain.endsWith('.' + base) rather than domain.endsWith(base). Without the dot, xmailinator.com ends with mailinator.com and gets blocked, even though it is an entirely different registration that has nothing to do with the disposable service. Suffix matching on domains without a label boundary is the same class of bug as matching CSS classes by substring: it works on all your examples and quietly over-matches in production. There is a test for exactly that case.
Parsing the domain is where the other bug lives
export function getEmailDomain(email: string): string | undefined {
const at = email.lastIndexOf('@');
if (at === -1) return undefined;
const domain = email.slice(at + 1).trim().toLowerCase();
return domain.length > 0 ? domain : undefined;
}
lastIndexOf, not indexOf, and not split('@')[1]. Local parts can contain an @ when quoted, so weird@name@mailinator.com has a domain of mailinator.com, and a naive split hands you name and lets it straight through.
Note also what this function does not do: it does not validate the email. A malformed input returns undefined and isDisposableEmailDomain returns false. Rejecting malformed addresses is the base email validator's job, one layer up. A predicate that answers two questions is a predicate whose false means two different things.
Signup only, and that is a deliberate asymmetry
/**
* Email schema for account creation. Extends the base email validation with a
* disposable-/throwaway-domain blocklist so fake signups (which also create a
* Stripe customer) are rejected up front. Kept separate from `emailSchema` so
* login and password-reset flows are not affected by the blocklist.
*/
export const signupEmailSchema = emailSchema.refine(
(email) => !isDisposableEmailDomain(email),
'Please use a permanent email address. Disposable email addresses are not allowed.'
);
signupSchema uses signupEmailSchema. loginSchema and forgotPasswordSchema use the plain emailSchema.
This matters more than it looks. The upstream blocklist gets updated. A domain that was fine in March can be listed in September. If the blocklist applied at login, that update would silently lock out people who registered legitimately and have been paying us since March, and they would have no way to recover because password reset would reject them too.
A blocklist that grows over time must only ever gate the moment of creation. Applying it to every authentication is how you turn a dependency update into a support queue.
Nothing here reaches the client
Two layers keep it that way, and they were separated for two different reasons.
The blocklist itself is server-only, imported by lib/api/validation-schemas.ts which is imported by API routes and server actions. The signup server action surfaces the rejection message to the form, so the user sees a proper error without the browser ever holding the list.
Underneath that there is a second split, which came out of a bundle audit:
lib/validation-rules.ts no imports at all. Plain functions. Forms use this.
lib/validation-client.ts the same rules as zod schemas. Server composes these.
lib/api/validation-schemas.ts request bodies + the disposable blocklist.
The login and signup forms used to import the zod version for their as-you-type validation, which pulled roughly 280 KB of zod into the client bundle of /login and /signup to check that an email has an @ in it. It was the largest single thing on either page.
The rules now live in a file with zero imports, and the zod schemas delegate to those same functions rather than restating them:
function schemaFor(rule: (value: string) => string | undefined) {
return z.string().superRefine((value, ctx) => {
const message = rule(value);
// ...
});
}
That delegation is what keeps the split honest. There is no second copy of "a password needs a digit" waiting to drift, and the message the form shows as you type is character for character the message the API returns when you submit.
What this does not solve
Disposable-domain blocklists are a filter, not a boundary. New throwaway domains appear faster than any list tracks them, and anyone determined can register their own. This raises the cost of a fake signup from zero to slightly above zero, which is enough to stop the drive-by case and nothing more. Real abuse control lives in rate limits, email verification and the payment flow.
It is worth doing anyway because it is forty lines, it runs in microseconds, and the alternative is paying Stripe to store customers for addresses that stopped existing ten minutes after signup.
See it
Go to cogniprep.app/signup and put test@mailinator.com in the email field. You get "Please use a permanent email address."
Now try test@inbox.mailinator.com. Same rejection, and that one is the wildcard list doing its job on a subdomain that is not in the 121,570.
Then head to cogniprep.app/login and type the same disposable address there. You will get told the credentials are wrong, which is the expected answer for an address with no account, but you will never be told the domain is not allowed. Login does not carry the blocklist. That difference is the asymmetry described above, and you can watch it from the outside.
Top comments (0)