HTML input pattern validation

Read el.pattern off the field, put values at the edges into it, and read validity.patternMismatch in Chrome. Run the same strings through new RegExp(el.pattern) in Node and compare the two columns. Here AB1234CD passed the Node test and the browser rejected it, because the attribute anchors the match.

Why check this

Run this on any field whose rule lives in markup, before release and after the rule is edited. The same expression behaves differently in an attribute than in a script, and both differences are silent.

The attribute is matched against the whole value, as if the source were wrapped in ^(?: and )$. A developer who confirmed it in a console with test() answered a different question: whether the value contains a match. Step 2 shows them disagreeing.

The second difference is worse. The attribute is compiled with the v flag, so a character class an ordinary RegExp accepts can be a syntax error here, and a pattern that fails to compile is dropped. The field then takes anything, and step 7 stores a sentence in a phone column.

Prerequisites

Save the form as form.html.

<!doctype html>
<meta charset="utf-8">
<title>pattern fixture</title>
<form id="f" method="post" action="/save">
  <label>Ticket <input id="ticket" name="ticket" type="text" pattern="\d{4}" required></label>
  <label>Ticket, with a title <input id="ticketh" name="ticketh" type="text" pattern="\d{4}" title="Four digits, for example 4821"></label>
  <label>Phone <input id="phone" name="phone" type="tel" pattern="\+?[0-9 \(\)\-]{7,20}"></label>
  <label>Phone, unescaped class <input id="phonebad" name="phonebad" type="tel" pattern="[0-9+()-]{7,15}"></label>
  <button id="go" type="submit">Save</button>
</form>

Save the endpoint as target.mjs, which stores every non-empty field.

import { createServer } from 'node:http';
import { readFileSync } from 'node:fs';

const PORT = 8935;
const rows = [];
const cps = (s) => [...s].map((c) => 'U+' + c.codePointAt(0).toString(16).toUpperCase().padStart(4, '0')).join(' ');

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(readFileSync(new URL('form.html', import.meta.url)));
  }
  if (url.pathname === '/favicon.ico') {
    res.writeHead(204);
    return res.end();
  }
  if (req.method === 'GET' && url.pathname === '/rows') {
    res.writeHead(200, { 'content-type': 'text/plain; charset=utf-8' });
    return res.end(rows.map((r) => 'id ' + r.id + '  ' + r.field.padEnd(9) + '"' + r.value + '"  ' + cps(r.value)).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 body = new URLSearchParams(Buffer.concat(chunks).toString('utf8'));
    const kept = [];
    for (const [field, value] of body) {
      if (value === '') continue;
      rows.push({ id: rows.length + 1, field, value });
      kept.push({ field, value });
    }
    res.writeHead(200, { 'content-type': 'application/json; charset=utf-8' });
    res.end(JSON.stringify({ stored: kept, rows: rows.length }) + '\n');
  });
}).listen(PORT, '127.0.0.1', () => console.log('pattern fixture on http://127.0.0.1:' + PORT));

Save the driver as probe.mjs. Each subcommand below is one step.

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

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

const profile = mkdtempSync(join(tmpdir(), 'h2c-pat-'));
const browser = await launch({ executablePath: CHROME, headless: true, userDataDir: profile, args: ['--lang=en-US'] });
const page = (await browser.pages())[0];
const seen = [];
page.on('console', (m) => seen.push(m.type() + ': ' + m.text()));
page.on('pageerror', (e) => seen.push('pageerror: ' + e.message));
await page.goto(ORIGIN + '/', { waitUntil: 'networkidle2' });

const set = (id, v) => page.evaluate((i, t) => { document.getElementById(i).value = t; }, id, v);
const src = (id) => page.evaluate((i) => document.getElementById(i).pattern, id);
const state = (id) => page.evaluate((i) => {
  const el = document.getElementById(i);
  return {
    value: el.value,
    patternMismatch: el.validity.patternMismatch,
    valueMissing: el.validity.valueMissing,
    valid: el.validity.valid,
    message: el.validationMessage,
  };
}, id);
const compiles = (s, flags) => { try { new RegExp(s, flags); return 'compiles'; } catch (e) { return e.name + ': ' + e.message; } };

const mode = process.argv[2];

if (mode === 'inventory') {
  const rows = await page.evaluate(() => [...document.querySelectorAll('input')].map((el) => ({
    id: el.id, type: el.type, prop: el.pattern, title: el.getAttribute('title') })));
  for (const r of rows) {
    console.log(pad(r.id, 10) + 'type ' + pad(r.type, 6) + 'el.pattern ' + pad(r.prop, 26)
      + 'title ' + (r.title === null ? 'absent' : JSON.stringify(r.title)));
  }
} else if (mode === 'anchor') {
  const s = await src('ticket');
  const re = new RegExp(s);
  console.log('el.pattern is  ' + s);
  console.log(pad('value', 12) + pad('RegExp(pattern).test()', 24) + pad('validity.patternMismatch', 26) + 'browser verdict');
  for (const v of ['4821', 'AB1234CD', '48210', '4821 ', '482']) {
    await set('ticket', v);
    const st = await state('ticket');
    console.log(pad(JSON.stringify(v), 12) + pad(re.test(v), 24) + pad(st.patternMismatch, 26)
      + (st.patternMismatch ? 'rejected' : 'accepted'));
  }
} else if (mode === 'empty') {
  await set('ticket', '');
  const a = await state('ticket');
  console.log('ticket (pattern + required), value ""');
  console.log('  patternMismatch ' + a.patternMismatch + '   valueMissing ' + a.valueMissing
    + '   valid ' + a.valid + '   message "' + a.message + '"');
  await set('phone', '');
  const b = await state('phone');
  console.log('phone  (pattern, no required), value ""');
  console.log('  patternMismatch ' + b.patternMismatch + '   valueMissing ' + b.valueMissing
    + '   valid ' + b.valid + '   message "' + b.message + '"');
} else if (mode === 'title') {
  for (const id of ['ticket', 'ticketh']) {
    await set(id, 'AB1234CD');
    const st = await state(id);
    console.log(pad(id, 10) + 'title ' + pad(JSON.stringify(await page.evaluate((i) => document.getElementById(i).title, id)), 34)
      + 'message "' + st.message + '"');
  }
} else if (mode === 'phone') {
  const s = await src('phone');
  console.log('el.pattern is  ' + s + '    on type="tel"');
  const values = ['+1 555 123 4567', '(555) 123-4567', '555-123-4567', ' 555-123-4567',
    '555-123-4567 ext. 89', '--------', '\u0665\u0665\u0665\u0661\u0662\u0663\u0664'];
  for (const v of values) {
    await set('phone', v);
    const st = await state('phone');
    console.log(pad(JSON.stringify(v), 24) + pad(st.patternMismatch ? 'rejected' : 'accepted', 10) + cps(v));
  }
} else if (mode === 'broken') {
  const s = await src('phonebad');
  console.log('el.pattern is  ' + s);
  for (const f of ['', 'u', 'v']) console.log('  new RegExp(pattern, ' + JSON.stringify(f) + ')  ' + compiles(s, f));
  for (const v of ['555-1234', 'not a phone at all', '']) {
    await set('phonebad', v);
    const st = await state('phonebad');
    console.log(pad(JSON.stringify(v), 22) + 'patternMismatch ' + pad(st.patternMismatch, 7)
      + 'valid ' + pad(st.valid, 7) + 'message "' + st.message + '"');
  }
  console.log('console messages and page errors since load: ' + seen.length);
  for (const m of seen) console.log('  ' + m);
} else if (mode === 'version') {
  console.log(await browser.version());
} else if (mode === 'submit') {
  await set('ticket', '4821');
  await set('phone', '--------');
  await set('phonebad', 'not a phone at all');
  console.log('form.checkValidity() ' + await page.evaluate(() => document.getElementById('f').checkValidity()));
  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.textContent.trim()));
}

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

Start the endpoint.

node target.mjs

Steps

  1. Step 1.

    Read the pattern off every field, from the DOM property.

    node probe.mjs inventory
    
    ticket    type text  el.pattern \d{4}                     title absent
    ticketh   type text  el.pattern \d{4}                     title "Four digits, for example 4821"
    phone     type tel   el.pattern \+?[0-9 \(\)\-]{7,20}     title absent
    phonebad  type tel   el.pattern [0-9+()-]{7,15}           title absent

    el.pattern is the string the browser compiles. Take the test data from it, not from the source file.

  2. Step 2.

    Put five values in and print the browser's verdict beside Node's.

    node probe.mjs anchor
    
    el.pattern is  \d{4}
    value       RegExp(pattern).test()  validity.patternMismatch  browser verdict
    "4821"      true                    false                     accepted
    "AB1234CD"  true                    true                      rejected
    "48210"     true                    true                      rejected
    "4821 "     true                    true                      rejected
    "482"       false                   true                      rejected

    Three rows disagree. test() answers whether the value contains four digits; the attribute answers whether the whole value is four digits.

  3. Step 3.

    Empty both patterned fields and read the flags.

    node probe.mjs empty
    
    ticket (pattern + required), value ""
    patternMismatch false   valueMissing true   valid false   message "Please fill out this field."
    phone  (pattern, no required), value ""
    patternMismatch false   valueMissing false   valid true   message ""

    The pattern does not fire on an empty value. required catches the first field; the second reports itself valid and empty.

  4. Step 4.

    Break the same pattern on two fields, one carrying a title.

    node probe.mjs title
    
    ticket    title ""                                message "Please match the requested format."
    ticketh   title "Four digits, for example 4821"   message "Please match the requested format."

    The two messages are identical. The title does not reach validationMessage, so an assertion on it cannot say which rule failed.

  5. Step 5.

    Compile the unescaped phone class three ways, then measure the field.

    node probe.mjs broken
    
    el.pattern is  [0-9+()-]{7,15}
    new RegExp(pattern, "")  compiles
    new RegExp(pattern, "u")  compiles
    new RegExp(pattern, "v")  SyntaxError: Invalid regular expression: /[0-9+()-]{7,15}/v: Invalid character in character class
    "555-1234"            patternMismatch false  valid true   message ""
    "not a phone at all"  patternMismatch false  valid true   message ""
    ""                    patternMismatch false  valid true   message ""
    console messages and page errors since load: 1
    error: Pattern attribute value [0-9+()-]{7,15} is not a valid regular expression: Uncaught SyntaxError: Failed to read the 'patternMismatch' property from 'ValidityState': Invalid regular expression: /[0-9+()-]{7,15}/v: Invalid character in character class

    The same source compiles with no flag and with u, and throws under v. The field then accepts a sentence. One console error is the only sign.

  6. Step 6.

    Feed a phone pattern the forms a real number arrives in.

    node probe.mjs phone
    
    el.pattern is  \+?[0-9 \(\)\-]{7,20}    on type="tel"
    "+1 555 123 4567"       accepted  U+002B U+0031 U+0020 U+0035 U+0035 U+0035 U+0020 U+0031 U+0032 U+0033 U+0020 U+0034 U+0035 U+0036 U+0037
    "(555) 123-4567"        accepted  U+0028 U+0035 U+0035 U+0035 U+0029 U+0020 U+0031 U+0032 U+0033 U+002D U+0034 U+0035 U+0036 U+0037
    "555-123-4567"          accepted  U+0035 U+0035 U+0035 U+002D U+0031 U+0032 U+0033 U+002D U+0034 U+0035 U+0036 U+0037
    " 555-123-4567"         accepted  U+0020 U+0035 U+0035 U+0035 U+002D U+0031 U+0032 U+0033 U+002D U+0034 U+0035 U+0036 U+0037
    "555-123-4567 ext. 89"  rejected  U+0035 U+0035 U+0035 U+002D U+0031 U+0032 U+0033 U+002D U+0034 U+0035 U+0036 U+0037 U+0020 U+0065 U+0078 U+0074 U+002E U+0020 U+0038 U+0039
    "--------"              accepted  U+002D U+002D U+002D U+002D U+002D U+002D U+002D U+002D
    "٥٥٥١٢٣٤"               rejected  U+0665 U+0665 U+0665 U+0661 U+0662 U+0663 U+0664

    Eight hyphens are accepted and an extension rejected. [0-9] matches ASCII digits only, so Arabic-Indic digits fail. A pattern decides which characters may appear, nothing more.

  7. Step 7.

    Submit the form with two values the fields were meant to block.

    node probe.mjs submit
    
    form.checkValidity() true
    landed on /save
    {"stored":[{"field":"ticket","value":"4821"},{"field":"phone","value":"--------"},{"field":"phonebad","value":"not a phone at all"}],"rows":3}

    The form reports itself valid, the browser navigates, and the endpoint stores a sentence in a phone column.

  8. Step 8.

    Send the refused values with no browser in the path.

    curl -s -X POST http://127.0.0.1:8935/save --data-urlencode "ticket=AB1234CD" --data-urlencode "phone=555-123-4567 ext. 89" --data-urlencode "phonebad=nonsense" -w 'HTTP %{http_code}\n'
    
    {"stored":[{"field":"ticket","value":"AB1234CD"},{"field":"phone","value":"555-123-4567 ext. 89"},{"field":"phonebad","value":"nonsense"}],"rows":6}
    HTTP 200

    Every value steps 2 and 6 marked rejected is stored. The attribute never left the page.

  9. Step 9.

    Read back what the endpoint kept.

    curl -s http://127.0.0.1:8935/rows
    
    id 1  ticket   "4821"  U+0034 U+0038 U+0032 U+0031
    id 2  phone    "--------"  U+002D U+002D U+002D U+002D U+002D U+002D U+002D U+002D
    id 3  phonebad "not a phone at all"  U+006E U+006F U+0074 U+0020 U+0061 U+0020 U+0070 U+0068 U+006F U+006E U+0065 U+0020 U+0061 U+0074 U+0020 U+0061 U+006C U+006C
    id 4  ticket   "AB1234CD"  U+0041 U+0042 U+0031 U+0032 U+0033 U+0034 U+0043 U+0044
    id 5  phone    "555-123-4567 ext. 89"  U+0035 U+0035 U+0035 U+002D U+0031 U+0032 U+0033 U+002D U+0034 U+0035 U+0036 U+0037 U+0020 U+0065 U+0078 U+0074 U+002E U+0020 U+0038 U+0039
    id 6  phonebad "nonsense"  U+006E U+006F U+006E U+0073 U+0065 U+006E U+0073 U+0065

    Six rows, three from the form. Only 4821 satisfies the markup. Stop the endpoint by its process id, never by image name.

How to read the result

| What you see | What it means | What to do | | --- | --- | --- | | test() true and patternMismatch true on one value | The attribute anchors the match and the console test did not | Re-test the expression as ^(?:source)$, or state the anchors in the attribute and keep them. | | A console error naming the pattern attribute | The pattern did not compile, so the browser dropped it | Raise it as a field with no client rule at all. Fix the class, then re-run step 5. | | patternMismatch false on every value of a field | Either the pattern matches everything, or it failed to compile | Read the console before deciding which. The two look identical from the DOM. | | patternMismatch false and valueMissing true | The field is empty, and pattern does not apply | Test the empty case against required, separately from the format case. | | A hyphen run accepted by a phone pattern | The class allows those characters in any order | Decide whether the field needs a shape check or a reachability check, and say which in the defect. | | A value in non-ASCII digits rejected | [0-9] and \d cover ASCII only | Confirm the rule is intended, and test the same field with the scripts your users type in. | | The endpoint stores a value the field rejected | No server rule mirrors the attribute | Raise one defect per missing rule, naming the attribute it should match. |

Common mistakes

Sign: The expression was confirmed in a console with test(), and the field rejects values that pass it.Cause: The attribute is matched against the whole value, as if the source were wrapped in ^(?: and )$. In step 2 the string AB1234CD returns true from RegExp(pattern).test() and sets patternMismatch on the same source. Anchors written into the attribute are harmless, so a source that carries its own ^ and $ behaves the same in both places.
Sign: A field with a pattern accepts anything, and validity.patternMismatch is false for every value tried.Cause: The attribute is compiled with the v flag, under which an unescaped (, ) or a trailing - inside a character class is a syntax error. Step 5 shows [0-9+()-]{7,15} compiling with no flag and with u, and throwing under v. Chrome then ignores the attribute: the field reports valid true on a sentence, with one console error as the only trace.
Sign: A required field with a pattern is reported as passing because an empty submit was blocked.Cause: Step 3 shows patternMismatch false and valueMissing true on the same element. The empty case was decided by required. Remove required and the same empty field reports valid true, so the format rule was never exercised by that run.
Sign: A phone pattern is signed off because the test data was a list of well formed numbers.Cause: Step 6 accepted eight hyphens and a number with a leading space, and rejected one carrying an extension. A character class decides which characters may appear and in what quantity. It cannot decide that a number is assigned, or that it belongs to the person typing it.

What to check next

FAQ

What does the input pattern attribute do?

It holds a regular expression the browser matches against the whole field value. A value that does not match sets validity.patternMismatch and blocks submission. An empty value is exempt.

Why does my pattern work in JavaScript and fail in the attribute?

Two reasons, both measured above. The attribute is anchored, so a partial match is not enough. It is also compiled with the v flag, which rejects character classes an ordinary RegExp accepts.

How do I do HTML input type phone number validation?

Use type="tel" for the keyboard and a pattern for the accepted characters, then treat both as hints. Step 6 accepted eight hyphens. Reachability is decided by sending a code.

Does pattern replace server side validation?

No. Step 8 posts every rejected value to the endpoint and all of them are stored. The attribute runs in the visitor's browser, so the route needs the same rule written again.

How do I test a pattern attribute that is generated at runtime?

Read el.pattern from the DOM, as step 1 does, and build the test data from it. Reading the source file misses a pattern a script replaced after load.

Verified

Verified by Maks VernyChrome 152.0.7977.76puppeteer-core 25.10.0node 22.23.2curl 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.

intermediate10 minpublished updated Maks Verny