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

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

  1. 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
    ]
    }

    stillOn is /, and the click produced no navigation. The browser answered by itself.

  2. 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.

  3. 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. maxlength cut the name to 10 and set no error flag. seventeen became the empty string, with valueMissing and badInput set rather than a type error.

  4. Step 4.

    Turn the form's own validation off and submit.

    node probe.mjs novalidate
    
    landed 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. noValidate stops the reporting, not the editing step 3 measured.

  5. 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 201

    nameLength is 23 behind an attribute that says 10, the age is a word, and the email keeps its padding.

  6. 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 400

    The form declares six constraints; the route has this one.

  7. 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.

  8. 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 201

    Both values the browser named, accepted and given an id.

  9. 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

Sign: The form refuses a bad value, so the run is recorded as server validation covered.Cause: The refusal happened in the browser. Step 2 shows the server received nothing at all, so that run produced no evidence about the route. Only a request that reaches the route tests the route.
Sign: A trimmed value comes back from the server, and the conclusion is that the server trims.Cause: type=email strips leading and trailing whitespace from the value before anything reads it, and the HTML standard gives type=url the same sanitizing. Step 3 typed 18 characters into the email field and the field held 14. Step 5 sent all 18 and the server stored all 18, so the trimming was never the server's.
Sign: A non-numeric age is blocked in the browser, and the result is filed as a working type check.Cause: input type=number returns the empty string for a value it cannot parse. seventeen never leaves the field, and step 3 shows valueMissing and badInput set together. The server sees a missing field, so a required rule catches this and a type rule is never reached.
Sign: form.noValidate is set, the submit goes through, and the payload matches none of the test data.Cause: noValidate turns off the blocking and the message, not the value sanitizing. Step 4 sent the truncated name and an empty age. To send the original strings you have to leave the browser, which is what step 5 does.

What to check next

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.

intermediate12 minpublished updated Maks Verny