How to test that client validation is enforced on the server
Type past every constraint the form declares, set form.noValidate = true in the console, and submit. The browser still sends values it has already trimmed and coerced. Replay the same fields with curl --data-urlencode, with no browser in the path. The server here answered 201 to a 23 character name behind maxlength="10".
Why check this
Two programs decide whether a value is acceptable, and only one of them is yours. The required, maxlength, type and min attributes run inside the visitor's browser, where one console line removes them. Nothing about them travels with the request.
Run this before release, and again whenever a field's attributes change. The failure it prevents is a stored record the application cannot display: a 200 character display name in a column sized for 10. It survived a test session that used the form, which never let the tester produce it.
The check separates three answers that get treated as one. What the browser blocked is not what the server rejected, and neither is what the server stored. How to test form validation messages covers the text shown while it blocks.
Prerequisites
- Node 22 for the target below, or point the commands at your own route.
- curl 8.0 or later. Every request goes to
127.0.0.1, so any build works. - Chrome, plus
puppeteer-corebeside the fixture (npm i puppeteer-core). SetCHROMEif Chrome is elsewhere. - Browser figures are one capture from Chrome 152.0.7977.76, run with
--lang=en-US: message text follows the UI locale, and step 1 under a Ukrainian UI returnedЗаповніть це поле. - The attributes and their
validityflags are defined in the HTML standard under constraint validation.
Save the form as form.html. Three constraints, one per field.
<!doctype html>
<meta charset="utf-8">
<form id="signup" method="post" action="/signup">
<label>Email <input id="email" name="email" type="email" required></label>
<label>Display name <input id="name" name="name" type="text" required maxlength="10"></label>
<label>Age <input id="age" name="age" type="number" min="18" required></label>
<button id="go">Create account</button>
</form>
Save the target as signup-server.mjs. Its only rule is a non-empty email.
import { createServer } from 'node:http';
import { readFileSync } from 'node:fs';
const stored = [];
let requests = 0;
createServer((req, res) => {
if (req.method === 'GET' && req.url === '/') {
res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' });
return res.end(readFileSync(new URL('form.html', import.meta.url)));
}
if (req.method === 'GET' && req.url === '/records') {
res.writeHead(200, { 'content-type': 'application/json' });
return res.end(JSON.stringify({ requests, stored }) + '\n');
}
if (req.method === 'POST' && req.url === '/signup') {
let body = '';
req.on('data', (c) => (body += c));
req.on('end', () => {
requests += 1;
const f = Object.fromEntries(new URLSearchParams(body));
if (!f.email) {
res.writeHead(400, { 'content-type': 'application/json' });
return res.end(JSON.stringify({ accepted: false, error: 'email is empty', received: f }) + '\n');
}
stored.push(f);
res.writeHead(201, { 'content-type': 'application/json' });
res.end(JSON.stringify({ accepted: true, id: stored.length, received: f, nameLength: (f.name || '').length }) + '\n');
});
return;
}
res.writeHead(404).end();
}).listen(8933, '127.0.0.1', () => console.log('target on 8933'));
Save the driver as probe.mjs. It types into the form and reports what each field holds.
import { launch } from 'puppeteer-core';
const CHROME = process.env.CHROME || 'C:/Program Files/Google/Chrome/Application/chrome.exe';
const mode = process.argv[2] || 'empty';
const values = mode === 'refused'
? { email: 'not-an-email', name: 'x', age: '-5' }
: { email: ' qa@example.com ', name: 'Wolfgang Amadeus Mozart', age: 'seventeen' };
const browser = await launch({ executablePath: CHROME, headless: true, args: ['--lang=en-US'] });
const page = await browser.newPage();
await page.goto('http://127.0.0.1:8933/', { waitUntil: 'networkidle2' });
if (mode !== 'empty') for (const [id, v] of Object.entries(values)) await page.type('#' + id, v);
if (mode === 'novalidate') {
await page.evaluate(() => { document.getElementById('signup').noValidate = true; });
await Promise.all([page.waitForNavigation({ waitUntil: 'networkidle2' }), page.click('#go')]);
console.log('landed on ' + new URL(page.url()).pathname);
console.log(await page.evaluate(() => document.body.innerText));
} else {
await page.click('#go');
await new Promise((r) => setTimeout(r, 300));
const fields = await page.evaluate((v) => [...document.getElementById('signup').elements]
.filter((e) => e.name)
.map((e) => ({
field: e.name,
typed: v[e.name] ?? '',
value: e.value,
valueLength: e.value.length,
message: e.validationMessage,
flags: ['valueMissing', 'typeMismatch', 'tooLong', 'rangeUnderflow', 'badInput']
.filter((k) => e.validity[k]),
})), mode === 'empty' ? {} : values);
console.log(JSON.stringify({ stillOn: new URL(page.url()).pathname, fields }, null, 1));
}
await browser.close();
Steps
- Step 1.
Start the target with
node signup-server.mjs, then submit the empty form.node probe.mjs empty{ "stillOn": "/", "fields": [ { "field": "email", "typed": "", "value": "", "valueLength": 0, "message": "Please fill out this field.", "flags": [ "valueMissing" ] }, … name and age report the same message and the same flag ] }stillOnis/, and the click produced no navigation. The browser answered by itself. - Step 2.
Ask the server how many POSTs it has seen.
curl -s http://127.0.0.1:8933/records{"requests":0,"stored":[]}Zero. Step 1 produced no evidence about the route, which was never asked.
- Step 3.
Type a value that breaks each constraint, then read back what the fields hold.
node probe.mjs typed{ "stillOn": "/", "fields": [ { "field": "email", "typed": " qa@example.com ", "value": "qa@example.com", "valueLength": 14, "message": "", "flags": [] }, { "field": "name", "typed": "Wolfgang Amadeus Mozart", "value": "Wolfgang A", "valueLength": 10, "message": "", "flags": [] }, { "field": "age", "typed": "seventeen", "value": "", "valueLength": 0, "message": "Please enter a number.", "flags": [ "valueMissing", "badInput" ] } ] }Three edits, none announced.
type="email"stripped the padding, 18 characters to 14.maxlengthcut the name to 10 and set no error flag.seventeenbecame the empty string, withvalueMissingandbadInputset rather than a type error. - Step 4.
Turn the form's own validation off and submit.
node probe.mjs novalidatelanded on /signup {"accepted":true,"id":1,"received":{"email":"qa@example.com","name":"Wolfgang A","age":""},"nameLength":10}The first request the server has seen, and not what was typed.
noValidatestops the reporting, not the editing step 3 measured. - Step 5.
Send the strings from step 3, with no browser in the path.
curl -s -X POST http://127.0.0.1:8933/signup --data-urlencode "email= qa@example.com " --data-urlencode "name=Wolfgang Amadeus Mozart" --data-urlencode "age=seventeen" -w '\nHTTP %{http_code}\n'{"accepted":true,"id":2,"received":{"email":" qa@example.com ","name":"Wolfgang Amadeus Mozart","age":"seventeen"},"nameLength":23} HTTP 201nameLengthis 23 behind an attribute that says 10, the age is a word, and the email keeps its padding. - Step 6.
Send the one payload the server has an opinion about.
curl -s -X POST http://127.0.0.1:8933/signup --data-urlencode "email=" --data-urlencode "name=" --data-urlencode "age=" -w '\nHTTP %{http_code}\n'{"accepted":false,"error":"email is empty","received":{"email":"","name":"","age":""}} HTTP 400The form declares six constraints; the route has this one.
- Step 7.
Ask the browser about a payload the server will take.
node probe.mjs refused{ "stillOn": "/", "fields": [ { "field": "email", "value": "not-an-email", "message": "Please include an '@' in the email address. 'not-an-email' is missing an '@'.", "flags": [ "typeMismatch" ] }, … name "x" is valid and reports no flags { "field": "age", "value": "-5", "message": "Value must be greater than or equal to 18.", "flags": [ "rangeUnderflow" ] } ] }Unlike step 3, both values survive intact. The browser keeps them and refuses to send.
- Step 8.
Send that payload past the browser.
curl -s -X POST http://127.0.0.1:8933/signup --data-urlencode "email=not-an-email" --data-urlencode "name=x" --data-urlencode "age=-5" -w '\nHTTP %{http_code}\n'{"accepted":true,"id":3,"received":{"email":"not-an-email","name":"x","age":"-5"},"nameLength":1} HTTP 201Both values the browser named, accepted and given an id.
- Step 9.
Read back everything the service kept.
curl -s http://127.0.0.1:8933/records{"requests":4,"stored":[{"email":"qa@example.com","name":"Wolfgang A","age":""},{"email":" qa@example.com ","name":"Wolfgang Amadeus Mozart","age":"seventeen"},{"email":"not-an-email","name":"x","age":"-5"}]}Four requests arrived, three rows kept, and no row satisfies the form that produced the first.
How to read the result
| What you see | What it means | What to do |
| --- | --- | --- |
| Step 2 reports requests: 0 | The browser answered on the server's behalf | Nothing is known about the route yet. Steps 4 to 8 are the test. |
| Step 5 returns 201 | No attribute on the form has a counterpart on the route | Raise one defect per missing rule, with the attribute it should mirror. |
| Step 5 returns 400 and names the field | The route enforces that constraint too | Compare the boundary: an attribute of 10 and a server limit of 255 still disagree. |
| Step 4 and step 5 store different strings | The browser edited the value before sending | Test the server with the unedited string. The browser cannot produce it. |
| age is empty in step 4 and a word in step 5 | The type is enforced by coercion in the browser only | Check what the route does with an empty value and with a non-numeric one, separately. |
| Step 9 shows a row the form could not create | The stored data has no schema behind it | Check the read path as well. A row that cannot render breaks the page that lists it. |
Common mistakes
What to check next
- How to check required field validation: one attribute in full, and an empty field against an absent one.
- How to check field length limits in a form: what
maxlengthtruncation hides, and the boundary values to send past it. - Input type email validation: how far the browser's address check goes, and what the route repeats.
- How to test whitespace trimming in a form field: which side did the trimming that step 3 caught.
- How to test API with invalid input: the same question with no form at all.
FAQ
What is the difference between client side and server side validation?
Client side validation runs in the browser and improves typing. Server side validation runs where data is accepted and decides what gets stored. Step 5 removes the first with one command.
How do I check server side validation?
Send the request without the form. Steps 5, 6 and 8 post to the same route with curl, one payload per constraint. Read the status code, then the stored record.
How do I see the form data before it is submitted?
Read new FormData(document.querySelector('form')) in the console, or let the route echo the parsed body. Both show values after the browser's editing, which step 3 measures.
What does the novalidate attribute do?
novalidate on the form, and formnovalidate on a submit button, stop the browser blocking the submit. It does not undo maxlength truncation or the sanitizing that type applies.
Is client side form validation worth keeping?
Yes, for the person filling the form in. It saves a round trip on a typo. It is not a control: a route that relies on it accepts whatever any other client sends.
Verified
Verified by Maks Vernycurl 8.21.0node 22.23.2Chrome 152.0.7977.76
Each output block is what the command above it printed on that date, on the host named in the step. Figures read from a live site move between runs. Compare the shape of the answer rather than the digits, and see the methodology for how a page is re-verified.
Related on this site
intermediate12 minpublished updated Maks Verny