Skip to main content

One account, two doors: what phone-or-email login really costs

· 4 min read
Founder, DartWay

Letting people sign in with either a phone number or an email looks like a one-line change: widen the lookup, accept both. We shipped it last week in a live project, and the one-line change turned out to be a data-model change with a bill attached.

Here is what it actually costs, in the order the costs show up.

The lookup was never the problem

The framework stored a single userIdentifier column and matched it by strict equality. Ask it to find a profile by phone or email and there is no seam to extend — not because the query is hard, but because authentication itself requires that column to exist. Remove it and the core fails at startup.

So the first fix is not "search two fields". It is giving the application a say in how a user is found:

DwAuthConfig(
findUserProfileByIdentifier: (session, identifier) async {
// the app decides what "the same person" means
},
);

The column stops being mandatory, the identifier arrives already normalised, and the framework stops pretending it knows what identity means in your domain. Three obligations move to the application, and the docs say so out loud — a resolver that silently does the wrong thing is worse than no resolver.

Searching two fields fixes nothing on its own

This is the part that surprised us, and it is the reason this post exists.

A user who signed up by phone has an empty email column. Searching both fields finds nothing new, because there is nothing in the second field. The lookup is correct and useless at the same time.

To fill that column you need a way to attach a second channel to an existing account — which means addAuthProvider and changeIdentifier have to actually work. In our case both threw UnimplementedError, and there was no screen for it in any mockup. Until that exists, signing in from the other channel keeps quietly creating a second account, exactly as before.

The duplicates are already there

Then comes the part you cannot postpone. We went to add unique indexes on phone and email and found there were no unique indexes at all — not on the new columns, not on the old userIdentifier. Two rows with the same value were already legal, and the lookup resolved to whichever one the database felt like returning.

You cannot add the index until the duplicates are merged. And the duplicates exist precisely because login from a second channel used to create a new account. The bug and its cleanup are the same object.

Merging is an event, not an action

Account merge is irreversible: one profile absorbs another, and afterwards nobody can reconstruct what came from where. So we modelled it as a ProfileMerge event rather than a method call — who merged, whom, and what moved. An action leaves the database consistent and the story gone; an event leaves both.

What we would tell ourselves a week ago

  • "Login by phone or email" is not an auth-screen task. It is a profile identity task, and the screen is the last five percent.
  • Ship the attach-a-second-channel flow in the same release. Without it, the feature is a lookup that finds nothing.
  • Check for unique indexes before you promise anything. Ours were missing for the entire life of the project, and nobody noticed because nothing enforced them.
  • Verify on a live database with a real round trip — phone signup, email attach, email login into the same account, one row in the table. We had 23 server tests including that pass; the reading-the-code version of this article would have been wrong in two places.

The framework side landed in dartway core 0.12.1 — the auth resolver and a working changeIdentifier. If you are running the earlier version, the migration is the interesting part, not the API.