← all writing tech

0712 vs 254712: the login bug that lives in phone-number formatting

A driver login kept returning 401 with a correct PIN, because the same phone number written four legitimate ways was four different users to the database.

A driver kept failing to log in to a field app, 401 every time, with the right PIN. The signing was correct. The credentials were correct. The bug was that 0723680311, 254723680311, and +254723680311 were three different users to the database, and only the one stored exactly as typed could ever authenticate.

The login flow did an exact string match on the phone number. Trim the whitespace, compare. That’s it. Which means the moment someone registered with 0723... and later logged in with +254723..., the lookup found nobody, and the app told a person holding the right phone with the right PIN that they were a stranger.

One human, four legitimate spellings

In a lot of the world, a phone number is a single canonical thing with a fixed shape. Here it is not. The same line is correctly written at least four ways depending on who’s typing and where:

  • Local trunk form. 0723680311, with the leading zero, the way people read it off a business card.
  • International without the plus. 254723680311, the way a payment gateway or an SMS API hands it back to you.
  • Full E.164. +254723680311, the way a contacts app normalizes it.
  • Spaced or dashed. 0723 680 311, because a human typed it into a text field at their own pace.

To a person, those are obviously the same number. To a WHERE phone = ? they are four distinct rows that never collide. Register as one, log in as another, and you are locked out of your own account by a system that is technically working exactly as written.

Why it survives every test you’d think to run

This is the kind of bug that passes review and passes QA and then waits. In testing you register and log in within the same session, copy-pasting the same string, so it always matches itself. The failure only appears with a real user who registered through one surface and logged in through another, the SMS provider storing the international form, the keypad giving you the local one.

It hides because nothing is null, nothing throws, and no log line looks wrong. The query runs, returns zero rows, and the app does the honest thing with zero rows, which is to reject the login. Every individual piece is behaving. The defect lives in the gap between how a human writes a number and how a column compares one.

A phone number is not a string you store, it’s an identity you have to canonicalize. If you don’t own its one true form, you’ve quietly built four accounts for every person and let only one of them log in.

Own the canonical form, at both ends

The fix is not to add more formats to the match. That just moves the goalposts: now you’re comparing four spellings against four spellings and praying the cross product lines up. The fix is to pick one canonical form and force everything through it, at both the write and the read edge.

  • Normalize on write. Before a number ever reaches the database, run it through a real phone-number library with a known default region, and store full E.164. Not the raw input. Never the raw input.
  • Normalize on read. Apply the exact same transform to whatever the user types at login before you look anything up. The query should never see a spelling, only the canonical key.
  • Reconcile what’s already there. The existing rows were written in whatever form arrived first, so a one-time backfill that canonicalizes the column, and surfaces the genuine duplicates it reveals, is part of the fix, not an optional cleanup.

The principle generalizes past phone numbers. Any field a user can write more than one way and that you then treat as an identity, an email with stray capitals, a national ID with or without dashes, is a latent auth bug until you name its canonical form and refuse to store anything else. The database compares bytes. People write meaning. The whole job is the function that turns the second into the first, once, on the way in and on the way out, and nowhere else.

Email