DEV Community

Daniel Pertu
Daniel Pertu

Posted on

We took zod out of our login page, and kept exactly one definition of a valid password

Our login and sign-up forms validate as you type. They used to do it by importing the same zod schemas the API routes use, which is the tidy answer: one schema, one set of messages, no drift.

It also put the whole of zod into the client bundle of /login and /signup. Roughly 280 KB uncompressed, to check that an email address contains an "@" and a password contains a digit. It was the largest single item on either page.

The fix is not "duplicate the rules on the client". That is how a form starts telling you a password is fine while the API disagrees. The fix is to notice that this is a packaging problem, not a logic problem.

One rule set, two shapes

lib/validation-rules.ts now holds the rules as ordinary functions with no imports at all. Every function returns an error message or undefined:

export function validatePassword(password: string): string | undefined {
  if (password.length < PASSWORD_MIN_LENGTH) return VALIDATION_MESSAGES.passwordTooShort;
  if (!/[a-z]/.test(password)) return VALIDATION_MESSAGES.passwordNeedsLowercase;
  if (!/[A-Z]/.test(password)) return VALIDATION_MESSAGES.passwordNeedsUppercase;
  if (!/[0-9]/.test(password)) return VALIDATION_MESSAGES.passwordNeedsNumber;
  if (strengthScore(password) < PASSWORD_MIN_STRENGTH) return VALIDATION_MESSAGES.passwordTooWeak;

  return undefined;
}
Enter fullscreen mode Exit fullscreen mode

The server still wants schemas, because it genuinely benefits from zod: composing request bodies, parsing, error paths. So lib/validation-client.ts wraps the functions rather than restating them:

function schemaFor(rule: (value: string) => string | undefined) {
  return z.string().superRefine((value, ctx) => {
    const message = rule(value);
    if (message) ctx.addIssue({ code: z.ZodIssueCode.custom, message });
  });
}

export const emailSchema = schemaFor(validateEmail);
export const passwordSchema = schemaFor(validatePassword);
export const nameSchema = schemaFor(validateName);
Enter fullscreen mode Exit fullscreen mode

That is the whole trick. There is no second copy of "a password needs a digit" to fall out of step, and the message the form shows is character for character the message the API returns. A rule change edits one file and changes both sides at once.

The forms import validation-rules directly. zod appears in the client graph of neither page.

Two details that stopped this being a silent behaviour change

The email pattern is copied out of zod verbatim.

const EMAIL_PATTERN =
  /^(?!\.)(?!.*\.\.)([A-Za-z0-9_'+\-.]*)[A-Za-z0-9_+-]@([A-Za-z0-9][A-Za-z0-9-]*\.)+[A-Za-z]{2,}$/;
Enter fullscreen mode Exit fullscreen mode

Writing a "good enough" email regex by hand would have quietly changed which addresses the product accepts. Lifting zod's own pattern means the set of valid addresses is identical to the day before the refactor. It requires a dotted domain, which is what makes a@b invalid.

The order of the checks is preserved, including one that looks wrong.

export function validateEmail(email: string): string | undefined {
  if (!EMAIL_PATTERN.test(email)) return VALIDATION_MESSAGES.emailInvalid;
  if (email.length < 1) return VALIDATION_MESSAGES.emailRequired;
  return undefined;
}
Enter fullscreen mode Exit fullscreen mode

An empty string fails the pattern first, so an empty field reports "Invalid email address" and never reaches "Email is required". That looks like a bug and it is not what I would write from scratch. It is what the schema it replaced did, and the point of the refactor was that no user-visible string moved. Fixing it is a separate, deliberate change.

The strength meter, and the passphrase we reject

Password strength is a 0 to 4 score. Length contributes up to two points in half steps, each character class present contributes one, and the raw total is divided by 1.25 and floored, which maps five raw points onto four labels.

You can watch the labels on cogniprep.app/signup: type into the password field and the bar and the label change as the score does. Here is the table the meter is rendering, generated by calling the functions directly:

abc                        len= 3 score=0 label=(none)  error=Password must be at least 8 characters
abcdefgh                   len= 8 score=1 label=Weak    error=Password must contain an uppercase letter
Abcdefgh                   len= 8 score=2 label=Fair    error=Password must contain a number
Abcdefg1                   len= 8 score=3 label=Good    error=none
Abcdefgh12                 len=10 score=3 label=Good    error=none
Abcdefghij12               len=12 score=4 label=Strong  error=none
Abcdefghij12!              len=13 score=4 label=Strong  error=none
correcthorsebatterystaple  len=25 score=2 label=Fair    error=Password must contain an uppercase letter
Enter fullscreen mode Exit fullscreen mode

Read the last line. A 25 character passphrase is rated "Fair" and rejected, while Abcdefg1 sails through as "Good". Every composition rule in that list is a rule NIST stopped recommending years ago, and the entropy ordering of those two strings is not close.

I am not going to pretend that is a design. It is what happens when a strength meter is built out of character-class checks because they are easy to write, and it is the part of this module I would change next. The refactor above is what makes that change cheap: the score lives in one function, with no zod in the client bundle riding on it, and moving to a length-weighted score is now an edit to strengthScore rather than a negotiation between two copies of the rules.

The reusable bit

If a validation library is in your client bundle for a login form, check what it is actually doing there. Ours was running four regexes.

Splitting the packaging and keeping one definition works because the rule functions are the primitive and the schemas are a wrapper, not the other way round. Try it the other way and you get two definitions on day one and a support ticket on day three.

Both pages are live if you want to look at what they load: cogniprep.app/login and cogniprep.app/signup. Open a Network panel, filter to JS, and compare the totals with any page in your own app that renders a form behind a schema library.

Top comments (1)

Collapse
 
elijahbrown profile image
Elijah Brown •

Keeping one rule set and two packagings is a nice way out of the drift problem. One thing I'd add on the server side only, so the client bundle stays small: the pattern passes anything shaped right, including addresses on domains that don't resolve at all or that publish a Null MX saying they accept no mail. A DNS lookup at signup catches those, and I'd let a timeout through rather than show the person an error.