Full-Stack Form Handling and Validation
Introduction
Forms are the primary way users interact with web applications. Whether it’s a login form, a registration page, or a contact form, every form needs validation on both the client side (for a responsive user experience) and the server side (for security). This tutorial covers the complete picture: HTML constraint validation, Express.js backend validation with Zod, and security measures including CSRF protection.
Client-Side Validation with the HTML Constraint Validation API
The browser has a built-in validation API that works without any external libraries. Understanding it gives you precise control over form behavior.
How Browser Validation Works
When a form is submitted, the browser checks each input against its validation attributes. By default, it shows a popup气泡 and blocks submission if validation fails. You can intercept this with a submit event listener.
The key methods are:
element.checkValidity()— returnstrueorfalsewithout showing any UIelement.reportValidity()— returnstrueorfalseand displays the browser’s native error message to the user
const form = document.getElementById('registration-form');
form.addEventListener('submit', (event) => {
if (!form.reportValidity()) {
event.preventDefault(); // stop submission
}
});
Calling form.submit() directly bypasses constraint validation entirely. Always use a submit event listener with reportValidity() instead.
Inspecting the validity Property
Each input element has a validity object with boolean flags describing exactly what went wrong:
const emailInput = document.querySelector('input[name="email"]');
if (emailInput.validity.valueMissing) {
console.log('Email is required');
} else if (emailInput.validity.typeMismatch) {
console.log('Email format is invalid');
} else if (emailInput.validity.tooShort) {
console.log(`Email must be at least ${emailInput.getAttribute('minlength')} characters`);
} else if (emailInput.validity.valid) {
console.log('Email is valid');
}
The validity flags are:
| Flag | Meaning |
|---|---|
valueMissing | Required field is empty |
typeMismatch | Value doesn’t match the input type (e.g., not an email) |
patternMismatch | Value fails the pattern regex |
tooShort | Below minlength |
tooLong | Exceeds maxlength |
rangeUnderflow | Below min (number, date, etc.) |
rangeOverflow | Above max |
stepMismatch | Value doesn’t comply with step attribute |
customError | Set via setCustomValidity() |
valid | true if all constraints pass |
Setting Custom Validation Messages
The browser’s default error messages are generic. You can provide your own with setCustomValidity():
const passwordInput = document.querySelector('input[name="password"]');
passwordInput.addEventListener('input', () => {
const value = passwordInput.value;
if (value.length < 8) {
passwordInput.setCustomValidity('Password must be at least 8 characters');
} else if (!/[A-Z]/.test(value)) {
passwordInput.setCustomValidity('Password must contain an uppercase letter');
} else {
// Clear the error — this is required or the field stays invalid
passwordInput.setCustomValidity('');
}
passwordInput.reportValidity();
});
Without that final setCustomValidity(''), the field stays in an invalid state permanently.
Disabling Browser Validation
Add novalidate to the <form> element to suppress the browser’s native error bubbles. This lets you build entirely custom validation UI:
<form id="registration-form" novalidate>
<!-- custom error display handled by JavaScript -->
</form>
Styling Valid and Invalid States
CSS pseudo-classes let you style fields based on their validation state:
input:invalid {
border-color: #e74c3c;
}
input:valid {
border-color: #27ae60;
}
input:invalid:not(:placeholder-shown) {
/* only show error style when field has content */
}
Server-Side Validation with Express and Zod
Client-side validation improves user experience but never replaces server-side validation. A malicious user can bypass client-side JavaScript entirely.
Zod is a popular schema validation library with TypeScript support and zero dependencies. It works well with Express.
Setting Up Express with JSON Body Parsing
const express = require('express');
const app = express();
app.use(express.json()); // built-in since Express 4.16+
Defining a Zod Schema
const { z } = require('zod');
const RegisterSchema = z.object({
email: z.string().email('Enter a valid email address'),
username: z.string().min(3).max(30).trim(),
password: z
.string()
.min(8, 'Password must be at least 8 characters')
.regex(/[A-Z]/, 'Password must contain an uppercase letter')
.regex(/[a-z]/, 'Password must contain a lowercase letter')
.regex(/\d/, 'Password must contain a digit'),
confirmPassword: z.string()
}).refine(data => data.password === data.confirmPassword, {
message: 'Passwords do not match',
path: ['confirmPassword']
});
The .refine() method adds cross-field validation. The path option attaches the error to a specific field.
Handling Validation in a Route
Zod’s .parse() throws on failure. Use .safeParse() to get a result object instead:
app.post('/api/register', (req, res) => {
const result = RegisterSchema.safeParse(req.body);
if (!result.success) {
// Map Zod errors to a cleaner structure
const errors = result.error.errors.map(err => ({
field: err.path.join('.'),
message: err.message
}));
return res.status(400).json({ errors });
}
// result.data is fully typed and validated
const { email, username, password } = result.data;
// Save user to database here...
res.status(201).json({ message: 'User registered successfully' });
});
CSRF Protection
Cross-Site Request Forgery (CSRF) is an attack where a malicious site tricks a user’s browser into sending a request to your application while the user is logged in. Because browsers include cookies automatically, the attack succeeds even though the malicious site never sees the response.
SameSite Cookies
The first line of defense is the SameSite attribute on session cookies:
res.cookie('sessionId', token, {
httpOnly: true,
secure: true,
sameSite: 'lax' // 'strict' blocks all cross-origin requests
});
SameSite=Strict— cookies only sent on same-origin requests. Very safe but can break legitimate navigation from other sites.SameSite=Lax— sent with safe top-level GET navigations from other origins, but not with form POSTs.
Lax alone does not fully protect POST forms. Use it together with the Synchronizer Token Pattern.
Synchronizer Token Pattern
Generate a random CSRF token per session, embed it in every form as a hidden field, and verify it on the server.
const crypto = require('crypto');
// Middleware: attach CSRF token to every request
app.use((req, res, next) => {
if (!req.session.csrfToken) {
req.session.csrfToken = crypto.randomBytes(32).toString('hex');
}
res.locals.csrfToken = req.session.csrfToken;
next();
});
For an Express template (EJS shown), render the token in the form:
<form action="/contact" method="POST">
<input type="hidden" name="_csrf" value="<%= csrfToken %>">
<input type="text" name="name" required>
<button type="submit">Send</button>
</form>
Then verify on POST:
app.post('/contact', (req, res) => {
if (req.body._csrf !== req.session.csrfToken) {
return res.status(403).json({ error: 'Invalid CSRF token' });
}
// Process the form...
res.send('Message received');
});
For Express apps, the csrf-sync package automates this pattern instead of writing the middleware manually.
Input Sanitization
Sanitization cleans or normalizes input values. Validation checks whether input meets rules. Always sanitize before validating, and do both on the server.
Express-validator combines sanitization and validation in a middleware chain:
const { body, validationResult } = require('express-validator');
app.post('/api/contact', [
body('name').trim().escape().notEmpty().withMessage('Name is required'),
body('email').trim().isEmail().normalizeEmail(),
body('message').trim().isLength({ min: 10 }).withMessage('Message must be at least 10 characters')
], (req, res) => {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ errors: errors.array() });
}
res.send('Message sent');
});
.trim() removes surrounding whitespace. .escape() converts <, >, &, ", and ' to HTML entities. .normalizeEmail() standardizes email addresses.
A Complete Registration Form
This example ties together everything: HTML5 constraint validation on the client, Zod validation on the server, and CSRF protection.
Frontend (HTML + JavaScript)
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Register</title>
<style>
.field-error { color: #e74c3c; font-size: 0.875em; }
input:invalid { border-color: #e74c3c; }
input:valid { border-color: #27ae60; }
</style>
</head>
<body>
<form id="register-form" novalidate>
<input type="hidden" name="_csrf" value="<%= csrfToken %>">
<label>Email
<input type="email" name="email" required minlength="5">
<span class="field-error" id="email-error"></span>
</label>
<label>Username
<input type="text" name="username" required minlength="3" maxlength="30" pattern="[a-zA-Z0-9]+">
<span class="field-error" id="username-error"></span>
</label>
<label>Password
<input type="password" name="password" required minlength="8"
pattern="^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)[a-zA-Z\d]{8,}$">
<span class="field-error" id="password-error"></span>
</label>
<label>Confirm Password
<input type="password" name="confirmPassword" required>
<span class="field-error" id="confirmPassword-error"></span>
</label>
<button type="submit">Register</button>
</form>
<script>
const form = document.getElementById('register-form');
form.addEventListener('submit', async (event) => {
event.preventDefault();
if (!form.reportValidity()) return;
const formData = new FormData(form);
const data = Object.fromEntries(formData.entries());
const response = await fetch('/api/register', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
});
if (!response.ok) {
const result = await response.json();
displayServerErrors(result.errors);
} else {
window.location.href = '/register/success';
}
});
function displayServerErrors(errors) {
errors.forEach(err => {
const field = err.field.replace(/\[|\]|\./g, '-');
const errorEl = document.getElementById(`${field}-error`);
if (errorEl) errorEl.textContent = err.message;
});
}
</script>
</body>
</html>
Note: the HTML pattern restricts passwords to alphanumeric only. Zod’s validation is authoritative — this HTML constraint is for early feedback only.
Backend (Express + Zod)
const express = require('express');
const session = require('express-session');
const crypto = require('crypto');
const { z } = require('zod');
const app = express();
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
app.use(session({
secret: process.env.SESSION_SECRET,
resave: false,
saveUninitialized: false,
cookie: {
httpOnly: true,
secure: true,
sameSite: 'lax'
}
}));
// Attach CSRF token to every request
app.use((req, res, next) => {
if (!req.session.csrfToken) {
req.session.csrfToken = crypto.randomBytes(32).toString('hex');
}
res.locals.csrfToken = req.session.csrfToken;
next();
});
const RegisterSchema = z.object({
email: z.string().email('Enter a valid email address'),
username: z.string().min(3).max(30).trim().regex(/^[a-zA-Z0-9]+$/, 'Username must be alphanumeric'),
password: z
.string()
.min(8, 'Password must be at least 8 characters')
.regex(/[A-Z]/, 'Password must contain an uppercase letter')
.regex(/[a-z]/, 'Password must contain a lowercase letter')
.regex(/\d/, 'Password must contain a digit'),
confirmPassword: z.string()
}).refine(data => data.password === data.confirmPassword, {
message: 'Passwords do not match',
path: ['confirmPassword']
});
app.get('/register', (req, res) => {
// Render the HTML form with the CSRF token
res.send(`<!DOCTYPE html>
<html>
<body>
<form id="register-form" novalidate>
<input type="hidden" name="_csrf" value="${res.locals.csrfToken}">
<!-- form fields here -->
</form>
</body>
</html>`);
});
app.post('/api/register', (req, res) => {
// Verify CSRF token
if (req.body._csrf !== req.session.csrfToken) {
return res.status(403).json({ error: 'CSRF token mismatch' });
}
const result = RegisterSchema.safeParse(req.body);
if (!result.success) {
const errors = result.error.errors.map(err => ({
field: err.path.join('.'),
message: err.message
}));
return res.status(400).json({ errors });
}
const { email, username, password } = result.data;
// Hash password and save to database here...
res.status(201).json({ message: 'User registered' });
});
app.listen(3000, () => console.log('Server running on port 3000'));
The key flow is:
- User loads the form — server generates a CSRF token and stores it in the session, then renders it into the form.
- Browser submits the form — includes the CSRF token in the hidden field.
- Server checks
req.body._csrf === req.session.csrfTokenbefore any other processing. - Zod validates the parsed body. If validation fails, return structured errors the frontend can display.
- If all checks pass, proceed with registration.
Key Gotchas
form.submit()bypasses browser constraint validation. Always use a submit event listener withreportValidity().- After calling
setCustomValidity('error')on a field, you must callsetCustomValidity('')when the field becomes valid — otherwise it stays stuck in invalid state. - Client-side validation is for user experience only. Every field must be re-validated on the server.
SameSite=Laxdoes not prevent CSRF on POST requests. Use both SameSite cookies and a CSRF token for state-changing operations.- Zod’s
.parse()throws exceptions. Use.safeParse()if you want to handle errors without try/catch blocks.
See Also
- The Fetch API — working with asynchronous code in JavaScript
- JSON.stringify() — parsing and stringifying data, as used when sending form data to an API