Creation Rules Must Never Gate Authentication
The tidy refactor that would have locked out 15,952 people
We were rebuilding a login endpoint as part of replacing a legacy PHP system. The new codebase has strong value types: a Username type that validates on construction, so invalid usernames can’t exist past the boundary. The login payload accepted a bare string. The obvious cleanup was to type it as Username.
It would have been a disaster. Username carries the creation rule — the charset and length a new account is allowed to choose today. Run against the existing accounts on a single development snapshot, 15,952 usernames failed it, mostly for a . or @ the legacy system had allowed since before 2021. Typing the login field with the creation rule would have rejected every one of those people at the door of their own account — forever, since they can’t change a username they can’t log in to use.
Two rules that look like one
It’s natural to think of “a valid username” as one concept. It’s two:
- The creation rule answers: what may a new username look like? It’s a policy, and policies tighten over time — you ban characters that cause trouble, you shorten maximums, you add reserved words.
- The lookup rule answers: what could a stored username possibly be? It’s a description of history — every value any past version of the creation rule ever accepted, plus whatever got in through admin tools, imports and bugs.
The creation rule only ever gets stricter. The lookup rule can only ever get looser, because history doesn’t shrink. Gate login on the creation rule and every tightening of policy silently becomes a lockout of the people who signed up before it.
So login got its own type, LoginUsername, which enforces only what a lookup can mean: non-empty, and no longer than the column. That second check matters — the old payload accepted a 65,535-character string to look up against a varchar(32) column — but it’s a bound on the query, not an opinion about the account.
Whitespace, collation, and the twelve accounts
The creation type also trims whitespace, which is reasonable for new input. The lookup type deliberately does not, and the reason is MySQL.
Under the default collation, comparison is not symmetric about whitespace. A trailing space is ignored ('abc' = 'abc ' is true), but a leading space is significant. Twelve active accounts had usernames stored with a leading space. Trim at login and their stored value can never match what they type — or what they type with the space, because you trimmed it. Twelve people, permanently locked out by a trim() that looks like hygiene.
Twelve is a small number. It’s also twelve support tickets with no visible cause, from users who did nothing wrong.
Measure the deviation before you defend it
There was a related case going the other way. The new registration endpoint had a stricter username charset than legacy — a deliberate, documented choice. What wasn’t documented was its cost. When we measured it, the stricter charset was rejecting 2.18% of registration attempts per day that legacy would have accepted.
That number is what turned a refactoring detail into a product decision, and put it in front of someone who owned the trade-off. The answer was to match legacy. Without the measurement, it would have been settled by whoever happened to be writing the validator.
The general shape
This isn’t only about usernames. Anywhere a system both creates and accepts a value, there are two rules, and the accepting rule has to cover everything the creating rule has ever produced:
- Passwords. Raise the minimum length, and the login form must still accept the old short ones until they’re rotated.
- Email addresses. Tighten your validation regex, and existing addresses that fail it still need to receive password resets.
- API identifiers and enum values. A new client that rejects an unrecognized enum value from an old record has made that record unreadable.
- File formats. A writer can drop support for an old version. A reader that does the same has orphaned every file in it.
Being strict about what you create and lenient about what you accept is an old idea. The useful part is making it structural: two separate types, so it’s impossible to reuse one where the other belongs.
How to check
- Run the creation rule against production data before you reuse it anywhere near authentication. Count failures. If the count isn’t zero, you’ve found a lockout.
- Check what your database considers equal, not what your application code considers equal. Collations, case folding and trailing whitespace all differ, and the database wins.
- Make the lookup type describe the column, not the policy: non-empty, within the column’s width, valid encoding.
- Measure the cost of any intentional deviation from legacy in rejections per day, and put that number in front of the person who owns the decision.
The principle
Creation rules describe the future; lookup rules have to describe the past. Policies get stricter, history doesn’t get smaller, and authentication is the one place where getting this wrong turns a validation rule into a locked door. Give them separate types, and never let the stricter one guard the way in.