CogniPrep refuses signups from throwaway email providers. The list it checks against is disposable-email-domains, and when I actually measured it:
const exact = require('disposable-email-domains'); // 121,570 domains
const wildcard = require('disposable-email-domains/wildcard.json'); // 399 base domains
JSON.stringify(exact).length // 2,095,371 bytes
Two megabytes. That is larger than the entire JavaScript payload of the page the check protects, and there is exactly one wrong place to put it.
The two match modes
The package ships two files because domain blocking needs two different answers.
index.json is a flat list of 121,570 domains to match verbatim. wildcard.json is 399 base domains where any subdomain counts too, which matters because several of these services hand out addresses like anything.mailinator.com and a verbatim list can never enumerate them.
So the check is a Set lookup followed by a suffix scan:
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 ordering is not an accident. The Set is O(1) and catches the overwhelming majority, so the 399-element linear scan only runs on addresses that are about to be accepted anyway. Lowercasing happens once at module load for both lists, not per call.
getEmailDomain returns undefined for anything with no @, and this function returns false for that case rather than true. A malformed address is the base email validator's job. A blocklist that also rejects malformed input would report "please use a permanent email address" to someone who typed their name in the wrong field, which is the kind of error message that makes people close the tab.
It applies to signup and nowhere else
export const signupEmailSchema = emailSchema.refine(
(email) => !isDisposableEmailDomain(email),
'Please use a permanent email address. Disposable email addresses are not allowed.'
);
signupEmailSchema extends the base emailSchema rather than replacing it, and only the signup schema uses it. Login and password reset still use the plain one.
That is deliberate, and it is the part people skip. The list grows. A domain that was fine when a user registered can be on it two years later, either because the provider genuinely turned into a throwaway service or because a crowd-sourced list got it wrong. If the blocklist guarded login too, the day a domain gets added is the day its existing users are locked out of accounts they paid for, with an error message about signing up.
Blocklists belong on the door, not on the hallway.
The reason the door is guarded at all is cheaper than abuse prevention in the abstract: a successful signup also creates a Stripe customer. Fake accounts are not just rows, they are rows in someone else's billing system.
The import boundary, and why tree shaking is not the plan
Here is where the two megabytes come in.
The forms are client components. If the schema they import can reach isDisposableEmailDomain, the list is in the client graph, and then you are relying on a bundler to notice that a Set built at module scope from a 2MB JSON import is dead code. It will not notice, because it is not dead: the module-level new Set(...) has run by the time anything imports the file.
So the field-level rules live in their own module with no server imports at all, and the forms import that one directly:
// The field-level rules below live in lib/validation-client.ts and are re-exported
// here rather than redeclared. The split exists because this module imports
// apiError and GAME_LIBRARY, which pull server-only code into anything that
// touches it, so the sign-up and login forms import the client copy directly.
The blocklist sits on the server side of that line, alongside apiError and the game catalogue. The rejection message still reaches the user, because the signup server action returns it to the form. Enforcement without the payload.
The general shape: when a module has a huge data dependency and a small API over it, the way to keep the data out of a bundle is to make the import graph physically unable to reach it. "Tree shaking will handle it" is a hope. A file with no path to the data is a guarantee.
See it
This one you can verify yourself in about thirty seconds, and I would rather you did than take my word for it.
Open cogniprep.app/signup signed out, open DevTools, and in the Network tab filter to JS. You will see 19 chunks, about 1.0MB in total. Now open the Search panel (the magnifying glass in the DevTools drawer, not the filter box) and search all loaded resources for mailinator.
Zero results. I ran the same check from the command line against every script tag on that page, for mailinator.com, guerrillamail.com, 10minutemail.com and a wildcard entry, and got zero for each. The check is real, the list is 2MB, and the page that enforces it does not contain it.
Then type you@mailinator.com into the form and submit, and read the message that comes back from the server. The whole point is that those two observations are both true at once.
Locally the pair of test files covering the matcher and the signup schema is 42 assertions, which is more than it sounds like for a 20-line function: most of them are the edge cases above, the ones where returning true would have been easy and wrong.
Pricing, if you are curious what the account is for, is at cogniprep.app/pricing.
Top comments (0)