A phone input mask that silently rewrote the number I typed
Numbers in this post are placeholders; the original test used a real number that has been redacted.
What happened
A registration form had a mobile number field with a national format mask:
<input type="text" class="form-control mask-phone" name="phone"
maxlength="14" minlength="14" required placeholder="Mobile number">
I filled it with a foreign number in international form. The field did not go red, no message appeared — the value became:
input: +421900123456
field: 06
Typing digit by digit was worse. The mask pre-seeds the national trunk prefix, so my leading digits were consumed as part of it and the rest shifted:
typed: 06301234567 -> field: 06 06 012-3456
typed: 301234567 -> field: 06 12 345-67
That third case is the dangerous one. 06 06 012-3456 is fourteen characters, matches the mask, passes
checkValidity() — and is a different phone number than the one I entered. The submit button enabled
itself and the form was ready to send someone else’s number.
Why it is worse than a rejection
A mask is a formatter. Given input it cannot represent, it does not fail — it produces the closest
thing it can. Combined with minlength = maxlength the result passes every client-side check, so:
- the user sees a plausible number and assumes it was accepted,
- validation reports success,
- the wrong value reaches the database,
- and if that field is a delivery or verification channel, the failure surfaces days later as “I never got the code”.
Setting the value programmatically hits the same wall — the mask handler rewrites it on input:
el.value = '+421900123456';
el.dispatchEvent(new Event('input', { bubbles: true }));
el.value; // "06 "
The only value that survived was one already shaped like the mask (06 30 123-4567), which is exactly the
tell: the widget accepts its own output and mangles everything else.
How to detect it in your own forms
Type a value that cannot fit the mask, then compare what you typed with what the field holds:
const el = document.querySelector('[name=phone]');
el.focus(); el.value = '';
for (const ch of '+421900123456') {
el.value += ch;
el.dispatchEvent(new Event('input', { bubbles: true }));
}
console.log({ typed: '+421900123456', stored: el.value, valid: el.checkValidity() });
If stored differs from typed and valid is true, you have this bug. The pass condition is not
“the field is valid” — it is “the field contains what the user meant, or it is visibly invalid”.
What to do instead
- Never make a fixed-length national mask the validation. Validate the parsed number (libphonenumber-style) and let the mask be cosmetic only.
- Accept
+and a country code. If the product is genuinely national-only, say so in the label and reject foreign input with a message — rejection is a feature; silent rewriting is a data-integrity bug. - Assert idempotency in tests:
format(parse(x)) === format(parse(format(parse(x)))), anddigits(stored) === digits(typed)for any input the form claims to accept. - Verify the channel. If the number matters, send a one-time code. A verification step turns a silent data corruption into an immediate, visible failure.
The broader point: a validator that repairs input instead of refusing it moves the error from the person who can fix it to a database row nobody will look at again.