Build a login form: code structure, state, validation, accessibility, security
Structure a login form as a controlled (or RHF-managed) component: labels tied to inputs, validation on blur + submit with errors linked via aria-describedby and aria-invalid, a loading/disabled state on submit, no secrets in client code, HTTPS-only, tokens in httpOnly cookies (not localStorage), generic error messages to avoid user enumeration, and rate limiting on the server.
A login form looks trivial but a good answer hits four axes: code structure, state, validation, accessibility, and security. Interviewers use it to see how you think across concerns.
Code structure
Keep the form component thin; push logic into a hook.
function useLoginForm() {
const [values, setValues] = useState({ email: "", password: "" });
const [errors, setErrors] = useState<Record<string, string>>({});
const [status, setStatus] = useState<"idle" | "submitting" | "error">("idle");
// ...validate, handleChange, handleSubmit
return { values, errors, status, handleChange, handleSubmit };
}In production, reach for react-hook-form + Zod — less boilerplate, schema-driven validation, fewer re-renders. But know how to hand-roll it.
State
Controlled inputs so React owns the value. Track three things: field values, field errors, and a submission status (idle / submitting / error) — the status drives the disabled button and spinner. Validate on blur (don't yell while the user is still typing) and again on submit.
Validation
function validate(v: Values) {
const e: Record<string, string> = {};
if (!v.email) e.email = "Email is required";
else if (!/^[^@]+@[^@]+\.[^@]+$/.test(v.email)) e.email = "Enter a valid email";
if (!v.password) e.password = "Password is required";
return e;
}Client-side validation is UX only — the server must re-validate everything. Never trust the client.
Accessibility
<label htmlFor="email">Email</label>
<input
id="email"
type="email"
autoComplete="username"
value={values.email}
onChange={handleChange}
aria-invalid={!!errors.email}
aria-describedby={errors.email ? "email-error" : undefined}
/>
{errors.email && <span id="email-error" role="alert">{errors.email}</span>}- Every input has a real
<label>(not just a placeholder). - Errors are linked with
aria-describedbyand flagged witharia-invalidso screen readers announce them. role="alert"on the error so it's announced when it appears.autoComplete="username"/"current-password"so password managers work.- The submit button is a real
<button type="submit">; the form submits on Enter. - On a failed submit, move focus to the first invalid field or a summary.
Security
This is where most candidates are thin — go deep:
- HTTPS only. Credentials in plaintext over HTTP is game over.
- Never store tokens in
localStorage— it's readable by any XSS. UsehttpOnly,Secure,SameSitecookies so JS can't touch the token. - Generic error messages — "Invalid email or password," never "no account with that email." Specific errors enable user enumeration.
- No secrets in client code — API keys, etc. live on the server.
- Rate limiting + lockout on the server to slow brute-force/credential-stuffing. The client can't enforce this.
- CSRF protection if using cookies — SameSite plus a CSRF token for state-changing requests.
- Don't disable paste on password fields — it breaks password managers and hurts security.
- Let the browser/password manager do its job; consider offering 2FA.
Senior framing
The signal here is breadth with depth: a junior builds the form and validates. A senior also says "validation is UX, the server is the source of truth," "tokens go in httpOnly cookies not localStorage," "error messages must be generic to prevent enumeration," and "rate limiting is a server concern the client can't own." Naming react-hook-form + Zod shows you know the practical tool, while still being able to explain the underlying mechanics.
Follow-up questions
- •Why is localStorage a bad place to store an auth token?
- •Why should login error messages be generic?
- •What's the difference between client-side and server-side validation responsibilities?
- •How do httpOnly, Secure, and SameSite cookie flags each help?
Common mistakes
- •Storing the JWT in localStorage where any XSS can steal it.
- •Specific error messages ('email not found') that allow user enumeration.
- •Using placeholder text as the only label.
- •Treating client-side validation as sufficient — skipping server validation.
- •Disabling paste on password fields.
- •Not linking error text to the input with aria-describedby.
Edge cases
- •Autofill: browser-filled values may not trigger React onChange consistently.
- •Submitting with Enter must work, not just clicking the button.
- •Double-submit: disable the button while status is 'submitting'.
- •Network failure vs. invalid credentials need different UI handling.
Real-world examples
- •Standard product login, SSO/OAuth login buttons, multi-step login with 2FA.