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
- Node 22.
node:sqliteprints a warning unless you pass--no-warnings. - Chrome and
puppeteer-core(npm i puppeteer-core), withCHROMEpointed at your own binary. This run drove Chrome 152.0.7977.76. A browser figure is one capture on one machine. - curl. See the curl manual.
- The maxlength definition, whose dirty value flag is what step 2 measures.
- Non-ASCII fixtures are written as escapes, so nothing normalises them in transit.
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
- Step 1.
Read the length attributes off every field on the page.
node probe.mjs inventorycode 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 -1Take the number from the DOM property, not from the markup. Where the attribute is absent the property reads
-1, not0. - Step 2.
Put 14 characters into a field capped at 10, three different ways.
node probe.mjs inserttyped 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.tooLongstayedfalse, and the form called itself valid. - 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. - Step 4.
Paste four strings that look the same length and count what survives.
node probe.mjs unicodeascii 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+0301The 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).
- Step 5.
Go under
minlengthby typing, then by assignment.node probe.mjs minlengthuntouched 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.
- 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.
- 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. - 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 20096 characters gone and the status line says the save worked. That is the defect this procedure exists to find.
- Step 9.
List what the store actually holds after all of it.
curl -s http://127.0.0.1:8935/rowsid 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
Thresholds
What to check next
- How to test that client validation is enforced on the server: the general case behind steps 6 to 8.
- How to check string length with emoji: what a limit should count, if not code units.
- How to check required field validation: the attribute that catches an empty value.
- How to test form validation messages: the text shown once
tooShortis true. - How to test unicode input: the same field beyond Latin.
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.
Related on this site
intermediate12 minpublished updated Maks Verny