Skip to content

A Phone Column of 'not-a-number': Birth of a Shared Validator

Adityo Guni Waluyo

The same QA finding hit two admin modules: non-digit phones accepted. The fix: one backend validator, a frontend mirror, one 400 INVALID_PHONE contract.

TL;DR

KotaPortal's phone fields accepted garbage like bukan-angka because validation existed only client-side, never at the backend trust boundary. The fix was one allowlist validator in Go shared by both modules, mirrored in TypeScript on the frontend, with a length cap matching the database column. They skipped libphonenumber since the need was simple format checks, not international parsing.

The first time I opened the admin module table in KotaPortal, I found something odd. In a column that should hold phone numbers, one row carried the literal value bukan-angka, Indonesian for "not-a-number". No error, no rejection. The raw text sailed in, got stored, and went on to render on a public page.

My first guess: an accident in one form. The fix I imagined was trivial, paste one regex into the two different editors and call it a day. After all, users would never type letters into a phone field if the client side restricted the input.

The guess missed on two layers. The QA sessions had found the same pattern in two different modules, the information editor and the venue editor, two components that know nothing about each other. And the root cause was not user carelessness: phone format validation simply never existed at the system's trust boundary. Client-side validation is a comfort layer, not a wall. With the backend validation empty, any data walked through to the database.

One Source of Truth in the Backend

The repair starts with one function in the shared backend validator package, not two twin regexes in two editors. The principle is the allowlist OWASP recommends: define what you accept, reject the rest, and do not try to recognize every malicious string [4]. The accepted charset: plus sign, digits, space, parentheses, hyphen. RFC 3966 notes that visual separators like parentheses and hyphens are allowed in phone numbers [5], so the rule follows how humans type, not how computers store. Total length is capped at 3 to 20 characters with at least 7 digits, and the field is optional: empty means no phone, not corrupted data.

// one rule, used by two modules
func ValidatePhone(s string) error {
	if s == "" {
		return nil // optional field
	}
	if len(s)  20 { // cap = contact_phone VARCHAR(20) column
		return ErrInvalidPhone
	}
	if !phoneRe.MatchString(s) { // allowlist: + digits space ( ) -
		return ErrInvalidPhone
	}
	if len(nonDigitRe.ReplaceAllString(s, "")) < 7 {
		return ErrInvalidPhone
	}
	return nil
}

A small detail saved the day: the rule's size follows the database column, not an invented number. The validator's first draft capped at 25 characters, then the pre-commit review caught the mismatch against the VARCHAR(20) contact_phone column. The cap was cut to 20 before the commit landed, and boundary tests were added on both sides. Had the numbers stayed different, a fresh error would have surfaced much later on the database path, far from the cause.

A Mirror on the Frontend, a Contract in the Middle

The backend is the source of truth, but users do not live there. So the same rule was rewritten as a TypeScript mirror on the frontend, complete with its vitest suite, and wired into both editors at once: the Kontak field in the information editor, the Telepon field in the venue editor. Its job is one thing: an inline error before the request fires, not after the server rejects the data. When the backend still answers 400 with the INVALID_PHONE code, the frontend falls back to the same inline message, so a user never sees two error languages for one mistake. The backend handler maps a single ErrInvalidPhone to one response, period; there is no family of error codes both sides have to memorize.

Why not reach for an operator-grade phone parsing library like Google's libphonenumber [6]? Because the need is a different class. KotaPortal needs format sanitation: incoming data must look like a callable phone number. That library is superb for cross-country international number parsing, but it carries a large dependency for a need one allowlist function answers here. If the product one day needs per-country validation, the path is clear: swap the validator's internals without touching the two consuming modules.

The lesson I took home: a QA finding that appears in two places with the same wound is not two bugs, it is one missing design. One validator on the truth side, a mirror on the comfort side, one error code as the bridge, and a rule size locked to the database column. Two modules closed by one decision, and the phone column stopped being a dumping ground for arbitrary text.

Sources

[4] OWASP Input Validation Cheat Sheet
[5] RFC 3966: The tel URI for Telephone Numbers
[6] libphonenumber