Skip to content
Login Form Validation: Why You Check Twice
Milos KnezevicMilos Knezevic23 Oct 2026 · 19 min read

Login Form Validation: Why You Check Twice

Your login form checks the email, yet your server still takes anything. Learn what browser and server validation each do, and build both, with a live demo.

Here's the short answer, so you have it before anything else. Form validation runs twice on purpose. The check in the browser is for the person filling in the form: fast, friendly feedback before anything is sent. The check on the server is for your app: it's the only one you can trust, because anyone can skip the browser and talk to your server directly.

That's it. Everything else in this article is the why and the how. If you're a beginner, read it top to bottom. If you already write client-side validation every day, jump to the section about the browser check being a courtesy, not a lock. If you're the person who designs auth for a team, the later sections on error messages, password rules and old accounts are for you.

The form works, so why is that not enough

Let me describe a form I think most of us have built. Two fields, email and password. A button. A bit of JavaScript that says "enter a valid email" when you type nonsense. You test it, the messages appear, and it feels finished.

And it is finished, for the happy path. Your form is well behaved. It only ever sends what you allowed it to send. The problem is that your form is not the only thing that can talk to your server. A script can. A mobile app someone builds next year can. A curious person with a terminal can. None of them ever see your nice red messages.

When I started, I didn't think about any of this. I built things from my head until they looked right on screen, and "looks right" meant the form behaved when I clicked around in it. I never asked what happens with a thousand real users, and what happens when one of them is not friendly. That question is the whole point of today.

Why validation exists in the first place

Validation means checking that data has the shape you expect before you do anything with it. Is the email field filled in? Does it look like an email? Is the password long enough to be a real password? You check the shape first, and only then you ask the expensive questions, like whether this person actually has an account.

Think about a post office counter. The clerk looks at your envelope before you leave: no address, no stamp, they hand it back right away, and you fix it in ten seconds. That's helpful. But the sorting center still checks every letter again, because letters also arrive from street mailboxes, where no clerk ever looked at them. The counter check is a service for you. The sorting center check is what protects the system.

Your browser is the counter. Your server is the sorting center. Both checks are real, both are useful, and they have completely different jobs.

  • The browser check is about the person. It saves them a round trip, tells them exactly what's wrong next to the field, and keeps the experience calm.
  • The server check is about your data and your users' safety. It decides what is allowed into your database and into your auth logic. Nothing gets past it.
  • If you have to pick only one, pick the server. A form without browser checks is annoying. A server without checks is a hole.

info

Terms in plain words. Client means the browser, the part running on the user's device. Server means your code running on a machine you control. A request is the message the browser sends when the form is submitted. Validation is checking the shape of data. Authentication is checking who someone is. They are different jobs, and mixing them up causes a lot of the bugs in this article.

Build the browser check: a login form you can try right now

Before reading the code, play with it. Try each of these five things in order: submit with both fields empty, type an email like emily@ with nothing after it, type a password shorter than eight characters, use a real-looking email with the wrong password, and finally sign in with the demo account shown in the form.

Login form with validation (front-end demo)

You saw five different outcomes, and each one has a reason. Let's walk through the code tab by tab.

The HTML does more work than you think

Look at the HTML tab. Every input has a label tied to it with the for and id attributes. That's not decoration. Click the word Email and the cursor jumps into the field, and a screen reader announces the label when the field gets focus. Remove the label and both of those break.

The email input uses type="email". That one word gives you three things for free: phones show a keyboard with the @ sign, password managers know what goes there, and the browser itself can judge whether the text looks like an email. The required attribute marks the field as mandatory, and minlength="8" on the password says the same thing about length.

The autocomplete values matter more than people think. autocomplete="username" on the email and autocomplete="current-password" on the password tell password managers exactly what this form is: a sign-in form, not a sign-up form. On a sign-up form you would use new-password instead, so the manager offers to generate one. If you get these wrong, the manager guesses, and it often guesses badly.

Then there's novalidate on the form. This one confuses people, so slow down here. It does not switch validation off. It switches off the browser's own popup bubbles, the grey tooltips that look different in every browser, so that our JavaScript can show its own messages under each field. The rules from type, required and minlength are still there, and our code still reads them.

Each input also has aria-describedby pointing at the paragraph under it, where the error will appear. A screen reader reads that paragraph together with the field, so a blind user hears the error message along with the field name, instead of just "Email" and silence.

The JavaScript, one piece at a time

Now the JS tab. The first five lines just grab the elements we need: the form, the two inputs, the status line at the bottom, and the button. Nothing clever.

checkEmail is a small function that returns a message, or an empty string when everything is fine. First it trims the value and checks for empty, because a field with three spaces in it is still an empty field. Then it asks email.validity.typeMismatch. That's the browser telling us whether the text matches its idea of an email address. We don't write our own email regex, and that's on purpose: the browser's rule comes from the HTML standard, it's the same rule everywhere, and it's simple enough to accept odd but real addresses.

checkPassword does the same for the password: empty first, then the length. You might ask why I don't use password.validity.tooShort here too. Good question. Browsers only raise tooShort after the user has edited the field, not when a script filled in the value. Checking the length directly is one line and behaves the same every time.

show is the function that touches the page. It writes the message into the right error paragraph, sets aria-invalid to true or false, and returns true when the field is fine. The aria-invalid attribute does double duty: screen readers announce it, and our CSS uses it to paint the border red. One source of truth for both.

Then come four event listeners. On blur, which means when you leave a field, we check it, but only if you actually typed something. Nothing is more annoying than tabbing through a form and getting yelled at before you've even started. On input, which fires on every keystroke, we re-check only if the field is already marked invalid. So the red message appears when you leave the field, and it disappears the moment you fix it. That rhythm, late to complain and quick to forgive, is what makes a form feel kind.

The submit handler starts with event.preventDefault(). Without it the browser does its default thing, which is to send the form and reload the page, and all our checks would flash and vanish. Then it clears the old status, runs both checks, and if anything failed it moves the keyboard focus to the first broken field, so the user lands exactly where they need to type.

If both checks pass, we disable the button and change its text to "Signing in...". That stops double clicks from sending two requests, and it tells the user something is happening. Then we wait for the answer and switch the button back.

Now notice fakeServerLogin. It's labelled as a front-end demo for a reason: checking a password in the browser is not authentication, it's theatre. In this demo it stands in for the request your real app sends to the server. The real check, the one that looks up the account and compares a hashed password, can only ever happen on the server. Which brings us to the most important part.

The browser check is a courtesy, not a lock

Here's the moment that changes how you see forms. Everything we just built lives on the user's machine. They can open developer tools and delete novalidate, required and the whole script. Or they can skip the page entirely. This is all it takes to send your server a login request with no form at all:

terminal
curl -X POST http://localhost:3000/api/login \
  -H "Content-Type: application/json" \
  -d '{"email":"not-an-email","password":"1"}'

Read it line by line. curl is a command-line tool that sends HTTP requests, the same kind your browser sends. -X POST says which method to use, the same as method="post" on a form. The -H line adds a header telling the server the body is JSON. The -d line is the body itself: an email that is not an email and a one-character password. Your form would never send this. curl doesn't care about your form.

So what's the point of the browser check, if it can be skipped this easily? The point is the person. Honest users never try to break your form, they just make typos. The browser check helps them. The server check handles everyone else. MDN, the reference most of us learn from, says it directly on its page about constraint validation: browser validation doesn't remove the need for validation on the server side.

So that's all it is. Two checks, two audiences. The browser check is a polite clerk. The server check is the lock on the door. You need the lock even if the clerk is excellent.

Build the server check so it says the same thing

Now the real check. I'll use Zod, a library that describes the shape of data and then checks real values against that description. If you've never met it, the article on validating data where it enters your app explains why it belongs at every boundary, not just forms. Here we only need a small piece of it.

lib/login-schema.ts
import { z } from "zod";

export const LoginSchema = z.object({
  email: z
    .string()
    .trim()
    .toLowerCase()
    .pipe(z.email("Enter a valid email address.")),
  password: z
    .string()
    .min(8, "Password must be at least 8 characters.")
    .max(128, "Password is too long."),
});

export type LoginInput = z.infer<typeof LoginSchema>;

Line by line. The import brings in z, which is how Zod is used. LoginSchema describes an object with two fields. For email, we start with z.string(), so anything that isn't text fails right away: numbers, objects, missing values. Then .trim() removes spaces at the ends, and .toLowerCase() makes Emily@Example.com and emily@example.com the same account. Then .pipe(z.email(...)) checks the cleaned-up text against Zod's email format and gives our own friendly message when it fails.

For password we check that it's a string of at least 8 and at most 128 characters. Notice that we do not trim the password. A space can be a real part of someone's password, and silently removing it means they can never sign in again. The maximum is there so nobody can send you a ten-megabyte password and make your server spend time hashing it.

The last line, z.infer, turns the schema into a TypeScript type. You describe the shape once, and you get both the runtime check and the type. If you change the schema, every place that uses LoginInput updates with it.

Now the route handler that uses it, in a Next.js App Router project:

app/api/login/route.ts
import { z } from "zod";
import { LoginSchema } from "@/lib/login-schema";
import { verifyCredentials } from "@/lib/auth";

export async function POST(request: Request) {
  const body = await request.json().catch(() => null);
  const result = LoginSchema.safeParse(body);

  if (!result.success) {
    return Response.json(
      { error: "VALIDATION_ERROR", fields: z.flattenError(result.error).fieldErrors },
      { status: 400 },
    );
  }

  const user = await verifyCredentials(result.data.email, result.data.password);
  if (!user) {
    return Response.json({ error: "Email or password is incorrect." }, { status: 401 });
  }

  return Response.json({ ok: true });
}

Let's go through it. POST is the function Next.js calls when a POST request arrives at /api/login. The first line reads the body as JSON. The .catch(() => null) part matters: if someone sends garbage that is not JSON at all, request.json() throws, and without the catch your server would answer with a 500 error. With it, garbage simply becomes null, and null fails validation like any other bad input.

safeParse runs the schema. Unlike parse, it never throws. It hands back an object with success set to true or false, so we decide what happens instead of an exception deciding for us. When it fails, we answer with status 400, which means bad request, and z.flattenError turns Zod's detailed error into a simple object: field name, list of messages. Here is what the server answered when I sent it the curl request from above:

response (400)
{
  "error": "VALIDATION_ERROR",
  "fields": {
    "email": ["Enter a valid email address."],
    "password": ["Password must be at least 8 characters."]
  }
}

Those are the same messages the browser shows. That's not a coincidence, it's the design. The person and the server say the same thing in the same words, so when the browser check is skipped or out of date, the user still gets an answer that makes sense. In a bigger app you would import LoginSchema in the client code too, so both sides read the rules from one file. If you change the minimum length, you change it once.

Only after the shape is valid do we call verifyCredentials. That's where the real authentication happens: find the user by email, compare the password against the stored hash with a slow hashing function like bcrypt or Argon2, return the user or nothing. I'm keeping that function out of this article on purpose, because it belongs to your auth setup. If you use Auth.js, the NextAuth v5 guide shows where it lives, and the comparison of NextAuth, Lucia and Better Auth helps you pick a library in the first place.

tip

Order matters. Validate the shape first, then do the expensive work. A request with a broken email should never reach your database or your password hashing. Cheap checks first, expensive checks last. That's how you keep bad requests from costing you anything.

A wrong password is a different kind of error

Go back to the preview and look at the fourth case, the wrong password. The message was "Email or password is incorrect." Not "this email doesn't exist", not "wrong password". That vagueness is deliberate, and it's one of those details that separates a form that works from a form that's safe.

If your login says "no account with this email", you just built a free tool that tells anyone whether a given person has an account on your site. Feed it a list of emails and it sorts them for you. That's called user enumeration. The OWASP Authentication Cheat Sheet tells you to answer with a generic message whether the email is unknown, the password is wrong, or the account is locked, so an attacker can't tell the cases apart.

So there are two kinds of errors in a login form, and they're treated differently on purpose:

  • Validation errors are about the shape of what you typed: empty field, not an email, too short. They are specific, they sit next to the field, and they're safe to show, because they only describe what the user already typed.
  • Authentication errors are about whether the account and the password match. They are generic, they sit in one place for the whole form, and they never reveal which half was wrong. The server answers 401, which means not authenticated.

Notice one more small thing in the demo: after a failed sign-in, the password field is cleared and gets focus, while the email stays. The user most likely mistyped the password, so we put them right back where they need to type, without making them retype their email.

And honestly, a generic message is only half of it. Someone can still try a thousand passwords for one account. That's why a real login also needs rate limiting, which slows down repeated attempts from the same source. The rate limiting article shows how to add it to exactly this kind of route.

Mistakes I see most, and the Plan B

This is where forms usually go wrong once they meet real people. I learned most of what I know by trying things and getting them wrong first, and forms were no exception.

Rules that are stricter than they need to be

The email check should be loose. People have addresses with plus signs, long domains, dots in odd places. If your own regex rejects a real address, that person can't sign in and they'll never tell you why they left. Let the browser and the library do the format check, and treat the email as proven only when the user clicks a confirmation link you sent to it.

Passwords are similar. Rules like "one uppercase, one number, one symbol" feel secure but mostly produce passwords like Password1 with a symbol stuck on the end. The current US government guidance for digital identity, NIST SP 800-63B, says verifiers shall not impose those composition rules. What it asks for instead is length, a check against lists of common and leaked passwords, and room for long passwords: at least 64 characters should be allowed.

Changing the rule and locking out old accounts

warning

This one is for the people designing auth. The current revision of NIST SP 800-63B asks for at least 15 characters when a password is the only factor, and at least 8 when it's part of multi-factor sign-in. If you raise your sign-up rule from 8 to 15, do NOT raise the login check with it. Every account created under the old rule still has a shorter password, and a login form that rejects it before it reaches the server has just locked those users out. Length rules belong on sign-up and password change. On login, only check what can never be a valid password.

That's also why the demo checks 8 characters, not 15. Think of it as the oldest rule any account in that app was ever created under.

Validation that only exists in one place

Browser-only validation is the hole we covered. But the opposite also happens: a team writes the rules twice, once in the form and once on the server, by hand, in different words. A month later somebody changes one side. Now the browser says 8 and the server says 10, and users get a green form followed by a server error they can't understand. One schema, imported on both sides, is the cure.

When AI writes the form for you

If you generate forms with an AI assistant, look for exactly these things. I check everything an AI writes for me as far as I can, and forms are where I look hardest. It's very common to get a beautiful form with careful browser checks and a route handler that trusts the body completely, or one that answers "user not found" because that's what the prompt literally asked for. The AI isn't being careless. It can't know your security rules unless you tell it. Ask for server validation and a generic login error explicitly, then read the route before you ship it.

Plan B: when JavaScript doesn't run

Scripts fail. A slow network, a browser extension, a bug in an unrelated part of your bundle. When the script is gone, the form falls back to plain HTML behavior, and here's the good news: because the server checks everything, nothing unsafe gets through. The user just gets a less pleasant answer. In Next.js you can go further and submit the form with a server action, so it works even without JavaScript, but that's a separate topic. The point is, if your plan B is "hope the script loads", that's not a plan B. The server check is the plan B.

Testing both sides takes ten minutes

You saw me try five cases in the preview. Those same five cases are your test list, and they're worth automating, because forms break quietly. For the server, call the POST function directly with a Request object and check the status code and body for each case: empty, bad email, short password, wrong password, correct. For the browser side, a component test that types into the fields and checks the messages covers the rest. The guide to testing React applications shows how to set that up.

Add one test that a lot of people forget: a body that isn't JSON at all. It should get a 400, not a 500. That single case catches a missing catch around request.json(), and it's the kind of bug that only shows up when someone is poking at your API on purpose.

What to do next

Take a form you already have, any form, and ask it two questions. First, if I delete the browser checks, what does my server accept? Run the curl command against it and find out. Second, do the browser and the server say the same thing in the same words? If they don't, move the rules into one schema and import it on both sides.

Then go one level deeper. Read Zod at the trust boundary to see that forms are only one of the places where outside data enters your app, and look at the production-grade code checklist for the rest of the things that separate "works on my machine" from "safe with real users".

A login form looks like the simplest thing on a website. It's also the front door. Build it like one. You can do this, and once you've built one properly, you won't want to go back to the old way.

Tested with: Node.js 24, Zod 4.3 and a current Chromium-based browser, October 2026.