How to check field length limits in a form

Read maxlength off the field, then put a value past it into the field three ways: type, paste, assign el.value. Typing and pasting stopped at 10 units here. The assignment kept 14 and the form still reported itself valid. Then post past the limit with curl and read what the store kept.

Why check this

Run this before sign-off on any form that writes free text into a record, and again after a framework upgrade changes how a field receives its value.

A length limit is never one number. The maxlength attribute, a script that sets the value, a check on the server and the column declaration are four limits, and nothing keeps them in step.

The failure it prevents is a truncated record that answers 200. Step 8 reproduces it: 106 characters arrive, 10 are stored, the status line says the save worked. Nobody sees the loss until the record is read back.

Everything runs against a local form and endpoint on port 8935, printed below.

Prerequisites

target.mjs serves the form, stores what arrives in a column declared VARCHAR(32), and reports both counts.

import { createServer } from 'node:http';
import { DatabaseSync } from 'node:sqlite';

const PORT = 8935;
const db = new DatabaseSync(':memory:');
db.exec('CREATE TABLE codes (id INTEGER PRIMARY KEY, code VARCHAR(32))');

const FORM = `<!doctype html><html><head><meta charset="utf-8"><title>length fixture</title></head>
<body>
<form id="f" method="post" action="/save">
  <label for="code">Code</label>
  <input id="code" name="code" type="text" minlength="4" maxlength="10" required>
  <label for="note">Note</label>
  <textarea id="note" name="note" maxlength="10"></textarea>
  <button id="go" type="submit">Save</button>
</form>
</body></html>`;

const units = (s) => s.length;
const points = (s) => [...s].length;
const bytes = (s) => Buffer.byteLength(s, 'utf8');

createServer((req, res) => {
  const url = new URL(req.url, `http://127.0.0.1:${PORT}`);
  if (req.method === 'GET' && url.pathname === '/') {
    res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' });
    return res.end(FORM);
  }
  if (req.method === 'GET' && url.pathname === '/rows') {
    const rows = db.prepare('SELECT id, code FROM codes').all();
    res.writeHead(200, { 'content-type': 'text/plain; charset=utf-8' });
    return res.end(rows.map((r) => 'id ' + r.id + '  units ' + units(r.code) + '  bytes ' + bytes(r.code)
      + '  code "' + (r.code.length > 14 ? r.code.slice(0, 14) + '...' : r.code) + '"').join('\n') + '\n');
  }
  if (!(req.method === 'POST' && url.pathname === '/save')) {
    res.writeHead(404, { 'content-type': 'text/plain' });
    return res.end('not found');
  }
  const chunks = [];
  req.on('data', (c) => chunks.push(c));
  req.on('end', () => {
    const raw = Buffer.concat(chunks).toString('utf8');
    const code = new URLSearchParams(raw).get('code') ?? '';
    const mode = url.searchParams.get('mode') ?? 'none';
    const stored = mode === 'cut' ? code.slice(0, 10) : code;
    const info = db.prepare('INSERT INTO codes (code) VALUES (?)').run(stored);
    const back = db.prepare('SELECT code FROM codes WHERE id = ?').get(Number(info.lastInsertRowid)).code;
    res.writeHead(200, { 'content-type': 'application/json; charset=utf-8' });
    res.end(JSON.stringify({
      mode, receivedUnits: units(code), receivedPoints: points(code), receivedBytes: bytes(code),
      storedUnits: units(back), storedBytes: bytes(back),
    }) + '\n');
  });
}).listen(PORT, '127.0.0.1', () => console.log('length fixture on http://127.0.0.1:' + PORT));

probe.mjs drives Chrome against that form. Each subcommand is one step below.

import { launch } from 'puppeteer-core';
import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';

const CHROME = 'C:/Program Files/Google/Chrome/Application/chrome.exe';
const ORIGIN = 'http://127.0.0.1:8935';
const pad = (n, w) => String(n).padStart(w);
const cps = (s) => [...s].map((c) => 'U+' + c.codePointAt(0).toString(16).toUpperCase().padStart(4, '0')).join(' ');

const profile = mkdtempSync(join(tmpdir(), 'h2c-len-'));
const browser = await launch({ executablePath: CHROME, headless: true, userDataDir: profile, args: ['--lang=en-US'] });
const page = (await browser.pages())[0];
const cdp = await page.createCDPSession();
await cdp.send('Browser.grantPermissions', { origin: ORIGIN, permissions: ['clipboardReadWrite', 'clipboardSanitizedWrite'] });
await page.goto(ORIGIN + '/', { waitUntil: 'networkidle2' });
await page.bringToFront();

const clear = () => page.evaluate(() => { const el = document.getElementById('code'); el.value = ''; el.focus(); });
const state = () => page.evaluate(() => {
  const el = document.getElementById('code');
  return { value: el.value, units: el.value.length, points: [...el.value].length,
    tooLong: el.validity.tooLong, tooShort: el.validity.tooShort,
    formValid: document.getElementById('f').checkValidity(), message: el.validationMessage };
});
const line = (how, s) => how.padEnd(14) + 'units ' + pad(s.units, 2) + '  tooLong ' + String(s.tooLong).padEnd(5)
  + '  tooShort ' + String(s.tooShort).padEnd(5) + '  form valid ' + String(s.formValid).padEnd(5) + '  value "' + s.value + '"';

async function type(text) { await clear(); await page.click('#code'); await page.type('#code', text); }
async function paste(text) {
  await clear();
  await page.evaluate((t) => navigator.clipboard.writeText(t), text);
  await page.click('#code');
  await page.keyboard.down('Control'); await page.keyboard.press('KeyV'); await page.keyboard.up('Control');
  await new Promise((r) => setTimeout(r, 250));
}
async function assign(text) { await clear(); await page.evaluate((t) => { document.getElementById('code').value = t; }, text); }

const mode = process.argv[2];
const FOURTEEN = 'A'.repeat(14);

if (mode === 'inventory') {
  const rows = await page.evaluate(() => [...document.querySelectorAll('input, textarea')].map((el) => ({
    id: el.id, tag: el.tagName.toLowerCase(),
    attrMax: el.getAttribute('maxlength'), maxLength: el.maxLength,
    attrMin: el.getAttribute('minlength'), minLength: el.minLength })));
  for (const r of rows) {
    console.log(r.id.padEnd(6) + r.tag.padEnd(10) + 'maxlength attr ' + String(r.attrMax).padEnd(6) + 'el.maxLength ' + pad(r.maxLength, 3)
      + '   minlength attr ' + String(r.attrMin).padEnd(6) + 'el.minLength ' + pad(r.minLength, 3));
  }
} else if (mode === 'insert') {
  await type(FOURTEEN); console.log(line('typed', await state()));
  await paste(FOURTEEN); console.log(line('pasted', await state()));
  await assign(FOURTEEN); console.log(line('el.value =', await state()));
} else if (mode === 'submit') {
  await assign(FOURTEEN);
  await Promise.all([page.waitForNavigation({ waitUntil: 'networkidle2' }), page.click('#go')]);
  console.log(await page.evaluate(() => document.body.textContent.trim()));
} else if (mode === 'unicode') {
  for (const [label, text] of [['ascii', 'A'.repeat(14)], ['emoji', '\u{1F9FE}'.repeat(8)],
    ['e-acute NFC', '\u00E9'.repeat(14)], ['e-acute NFD', 'e\u0301'.repeat(14)]]) {
    await paste(text);
    const s = await state();
    console.log(label.padEnd(12) + 'pasted units ' + pad(text.length, 2) + ' points ' + pad([...text].length, 2)
      + '   kept units ' + pad(s.units, 2) + ' points ' + pad(s.points, 2));
    console.log(''.padEnd(12) + 'kept code points  ' + cps(s.value));
  }
} else if (mode === 'minlength') {
  console.log(line('untouched', await state()));
  await type('ABC'); const a = await state(); console.log(line('typed 3', a)); console.log(' '.repeat(14) + 'message "' + a.message + '"');
  await assign('ABC'); console.log(line('el.value = 3', await state()));
  await type('ABCD'); console.log(line('typed 4', await state()));
}

await browser.close();
rmSync(profile, { recursive: true, force: true });

Start the target and leave it running.

node --no-warnings target.mjs

Steps

  1. Step 1.

    Read the length attributes off every field on the page.

    node probe.mjs inventory
    
    code  input     maxlength attr 10    el.maxLength  10   minlength attr 4     el.minLength   4
    note  textarea  maxlength attr 10    el.maxLength  10   minlength attr null  el.minLength  -1

    Take the number from the DOM property, not from the markup. Where the attribute is absent the property reads -1, not 0.

  2. Step 2.

    Put 14 characters into a field capped at 10, three different ways.

    node probe.mjs insert
    
    typed         units 10  tooLong false  tooShort false  form valid true   value "AAAAAAAAAA"
    pasted        units 10  tooLong false  tooShort false  form valid true   value "AAAAAAAAAA"
    el.value =    units 14  tooLong false  tooShort false  form valid true   value "AAAAAAAAAAAAAA"

    Typing and pasting both stopped at 10. The assignment kept all 14, validity.tooLong stayed false, and the form called itself valid.

  3. Step 3.

    Submit the assigned value and read what reached the endpoint.

    node probe.mjs submit
    
    {"mode":"none","receivedUnits":14,"receivedPoints":14,"receivedBytes":14,"storedUnits":14,"storedBytes":14}

    The field declares maxlength="10" and 14 characters left the browser, silently.

  4. Step 4.

    Paste four strings that look the same length and count what survives.

    node probe.mjs unicode
    
    ascii       pasted units 14 points 14   kept units 10 points 10
              kept code points  U+0041 U+0041 U+0041 U+0041 U+0041 U+0041 U+0041 U+0041 U+0041 U+0041
    emoji       pasted units 16 points  8   kept units 10 points  5
              kept code points  U+1F9FE U+1F9FE U+1F9FE U+1F9FE U+1F9FE
    e-acute NFC pasted units 14 points 14   kept units 10 points 10
              kept code points  U+00E9 U+00E9 U+00E9 U+00E9 U+00E9 U+00E9 U+00E9 U+00E9 U+00E9 U+00E9
    e-acute NFD pasted units 28 points 28   kept units 10 points 10
              kept code points  U+0065 U+0301 U+0065 U+0301 U+0065 U+0301 U+0065 U+0301 U+0065 U+0301

    The cap counts UTF-16 code units. Eight emoji became five. Fourteen accented letters became ten spelled as one code point (U+00E9) and five spelled as a letter plus a combining acute (U+0065 U+0301).

  5. Step 5.

    Go under minlength by typing, then by assignment.

    node probe.mjs minlength
    
    untouched     units  0  tooLong false  tooShort false  form valid false  value ""
    typed 3       units  3  tooLong false  tooShort true   form valid false  value "ABC"
                message "Please lengthen this text to 4 characters or more (you are currently using 3 characters)."
    el.value = 3  units  3  tooLong false  tooShort false  form valid true   value "ABC"
    typed 4       units  4  tooLong false  tooShort false  form valid true   value "ABCD"

    An empty field is not too short, it is missing. A typed 3 is too short. An assigned 3 is neither, and the form submits it.

  6. Step 6.

    Post the four boundary values the attributes define, straight to the endpoint.

    for n in 3 4 10 11; do curl -s -X POST http://127.0.0.1:8935/save -d "code=$(printf 'A%.0s' $(seq $n))"; done
    
    {"mode":"none","receivedUnits":3,"receivedPoints":3,"receivedBytes":3,"storedUnits":3,"storedBytes":3}
    {"mode":"none","receivedUnits":4,"receivedPoints":4,"receivedBytes":4,"storedUnits":4,"storedBytes":4}
    {"mode":"none","receivedUnits":10,"receivedPoints":10,"receivedBytes":10,"storedUnits":10,"storedBytes":10}
    {"mode":"none","receivedUnits":11,"receivedPoints":11,"receivedBytes":11,"storedUnits":11,"storedBytes":11}

    Minimum minus one, minimum, maximum, maximum plus one. All four stored. No request came from the form, so no attribute applied.

  7. Step 7.

    Send a value far past the column declaration.

    curl -s -X POST http://127.0.0.1:8935/save -d "code=$(printf 'A%.0s' $(seq 106))"
    
    {"mode":"none","receivedUnits":106,"receivedPoints":106,"receivedBytes":106,"storedUnits":106,"storedBytes":106}

    The column is declared VARCHAR(32) and SQLite kept 106 units without an error. There, a declared length is a comment.

  8. Step 8.

    Make the endpoint cut the value, and read the status code next to the counts.

    curl -s -w 'status %{http_code}\n' -X POST 'http://127.0.0.1:8935/save?mode=cut' -d "code=$(printf 'A%.0s' $(seq 106))"
    
    {"mode":"cut","receivedUnits":106,"receivedPoints":106,"receivedBytes":106,"storedUnits":10,"storedBytes":10}
    status 200

    96 characters gone and the status line says the save worked. That is the defect this procedure exists to find.

  9. Step 9.

    List what the store actually holds after all of it.

    curl -s http://127.0.0.1:8935/rows
    
    id 1  units 14  bytes 14  code "AAAAAAAAAAAAAA"
    id 2  units 3  bytes 3  code "AAA"
    id 3  units 4  bytes 4  code "AAAA"
    id 4  units 10  bytes 10  code "AAAAAAAAAA"
    id 5  units 11  bytes 11  code "AAAAAAAAAAA"
    id 6  units 106  bytes 106  code "AAAAAAAAAAAAAA..."
    id 7  units 10  bytes 10  code "AAAAAAAAAA"

    Row 1 came through a field capped at 10, and rows 5 and 6 are over both that cap and the column. Read the store, not the response.

How to read the result

| What you see | What it means | What to do | | --- | --- | --- | | el.maxLength is -1 | The attribute is absent on that field | Nothing caps the field in the browser. Find the limit the server enforces and report the pair. | | Typing stops at the cap and an assignment does not | The attribute constrains user edits only | Test the path your application uses. A value restored from state or a query string never passes through it. | | validity.tooLong is false on an over-length value | The value was not entered by the user | Assert on el.value.length against el.maxLength. tooLong will not catch this. | | The server stored maximum plus one | No server-side length check exists | Raise it against the field's own contract. Step 6 is the reproduction. | | Stored units below received units with a 200 | Something truncated in silence | Raise as data loss. Name the layer that cut, using step 9 to show the store. | | Eight emoji became five characters | The cap counts UTF-16 code units | Re-test every limit with non-ASCII fixtures and state the unit in the defect. | | tooShort is false on an empty required field | minlength does not apply to an empty value | Check valueMissing for that case. The two conditions are separate. |

Common mistakes

Sign: The test types past maxlength, sees the field stop, and passes. Production truncates the same field anyway.Cause: Typing and pasting are user edits and the attribute cuts both, as step 2 shows. An assignment to el.value is not a user edit, so it keeps all 14 characters and the form still validates. Any framework that writes the value from state, and any restore from a query string, takes that path.
Sign: A length assertion built on validity.tooLong never fails, on any input.Cause: tooLong needs a value longer than the cap that the user entered, and the browser prevents that combination from existing. Every row in steps 2 and 5 reads tooLong false, including the 14-unit one. Compare el.value.length with el.maxLength instead.
Sign: minlength is reported as broken because an empty form submitted.Cause: An untouched empty field reads tooShort false in step 5, because minlength does not apply to an empty value. required is what catches it. A value written with el.value is also exempt, which is the second half of the same bug and the harder one to see.
Sign: A field capped at 10 accepts a name in English and cuts a name in another script in half.Cause: The cap counts UTF-16 code units, so one character costs one unit or two. Step 4 pasted the same fourteen accented letters twice and kept ten of them in one spelling and five in the other, because the second spelling carries a combining mark. An ASCII fixture cannot find this.

Thresholds

maxlength counts UTF-16 code units, so a cap of 10 held 10 ASCII characters, 5 emoji, or 5 letters written as a base plus a combining acute Source: Measured in step 4, Chrome 152.0.7977.76, 2026-09-12, against https://html.spec.whatwg.org/multipage/input.html#attr-input-maxlength
a column declared VARCHAR(32) accepted and returned 106 units Source: Measured in step 7 on node:sqlite 3.51.3, 2026-09-12

What to check next

FAQ

What does the maxlength attribute do?

It caps the UTF-16 code units a user can type or paste. It does not cap a value assigned in script, and it has no effect on a request made outside the form.

Why is html input minlength not working?

Three reasons, all in step 5. An empty value is exempt, so required catches that case. A value set with el.value is exempt too. And the attribute blocks submission rather than typing.

What is the character limit on an input field?

There is no fixed one. maxlength sets whatever the markup says, and with no attribute the browser sets no practical cap. Step 7 pushed 106 characters through an endpoint whose column said 32.

How does boundary value testing apply to a length limit?

Four values per limit: minimum minus one, minimum, maximum, maximum plus one. Step 6 sends those four. Send them past the browser, or the attribute answers instead of the server.

Do minlength and maxlength count the same units?

Yes, both count UTF-16 code units. An emoji costs two against either limit, so four emoji satisfy a minlength of 8 while looking like four characters.

Verified

Verified by Maks VernyChrome 152.0.7977.76puppeteer-core 25.10.0node 22.23.2node:sqlite 3.51.3curl 8.21.0Windows 11 build 22631

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