Skip to content
MartinsAI.
All writing
Backend & Architecture

Designing Auth for a Market Where Phone Number Beats Email

Most auth libraries assume email-first sign-up by default. Building CowriterAI's onboarding meant treating phone number as the primary identity instead — and what that changes.

5 min read

Most authentication tutorials — and most authentication libraries, by default configuration — assume email is the primary identity. You sign up with an email, you verify it, you reset your password through it. That assumption holds for a lot of products. It doesn't hold for CowriterAI's audience: Nigerian university students, many of whom check a shared or infrequently-used email far less often than they check WhatsApp.

If email verification is the gate between sign-up and first use, and your users don't reliably check email, you don't have an auth bug — you have a conversion problem wearing an auth bug's clothes. The fix is to make phone number the primary identity, not a secondary recovery method bolted onto an email-first flow.

Why this isn't just "swap the field"

The naive version of phone-first auth is: rename the email column to phone, send an OTP over SMS instead of a magic link, ship it. That mostly works until you hit the parts of the auth stack that quietly assume email semantics:

  • Normalization. Emails are case-insensitive and have one canonical form per provider quirks aside. Phone numbers have country codes, sometimes a leading zero that means "domestic format," and multiple ways to write the same number (+234 803 xxx xxxx vs 0803xxxxxxx vs 234803xxxxxxx). Without normalizing to E.164 at the boundary, you'll end up with duplicate accounts for the same person.
  • Uniqueness and lookup. If normalization isn't consistent everywhere a phone number is written or queried, uniqueness constraints stop being reliable, and "log in with your phone number" silently fails for users whose stored format doesn't match what they typed.
  • Delivery reliability. Email delivery failures are usually silent and rare. SMS delivery failures are noisy, market-dependent, and cost money per attempt — which changes the retry and rate-limiting design significantly.

This is where using better-auth's phoneNumber plugin paid off over hand-rolling it: the plugin already handles OTP generation, verification, and expiry against a normalized phone identity, which meant the actual engineering effort could go into normalization at the edges and the parts specific to this product, rather than reimplementing OTP mechanics that are easy to get subtly wrong (timing attacks on OTP comparison, replay of expired codes, rate limiting sign-up attempts per number).

The phoneHarmony layer: reconciling formats you don't control

Even with a plugin handling OTP mechanics, the harder problem is upstream: users type phone numbers in whatever format their keyboard, their memory, or their previous SIM registration trained them to use. phoneHarmony sits in front of the core phone auth flow specifically to reconcile that — normalizing incoming input to a canonical E.164 form before it ever reaches the uniqueness check or the OTP dispatch, so 0803..., +234803..., and 234803... all resolve to the same account rather than three attempted accounts for one person.

// Simplified shape of the normalization step ahead of account lookup/creation.
import { parsePhoneNumberFromString } from "libphonenumber-js";

function normalizeToE164(rawInput: string, defaultCountry = "NG") {
  const parsed = parsePhoneNumberFromString(rawInput, defaultCountry);
  if (!parsed?.isValid()) {
    throw new InvalidPhoneNumberError(rawInput);
  }
  return parsed.number; // canonical E.164, e.g. "+2348031234567"
}

Getting this step wrong doesn't fail loudly — it fails as a support ticket three weeks later from a student who "already has an account" and can't figure out why sign-up keeps rejecting their number.

A three-call sequence, not one endpoint

Account creation itself runs as three server-side calls rather than a single "create user" endpoint: request OTP, verify OTP, then create the account record with the verified phone as the confirmed identity. Collapsing this into one call is tempting for simplicity, but it removes the ability to distinguish "OTP never arrived" from "OTP arrived but was entered wrong" from "OTP was correct but the account payload failed validation" — three failure modes that need three different UI responses and, in a product built for patchy mobile connectivity, three different retry strategies.

Moving the wizard to react-hook-form

The multi-step sign-up wizard itself (phone entry → OTP → profile details) was migrated to react-hook-form specifically for schema-driven validation per step and centralized control over wizard state, replacing an earlier implementation where each step managed its own local state and validation logic independently. The payoff shows up in exactly the bugs you'd expect from uncoordinated per-step state: a user going back from step 3 to step 2 with step 2's fields silently reset, or a step's "next" button staying enabled despite a field that failed validation on blur. Centralizing the form state made those bugs structurally harder to reintroduce, not just easier to catch in review.

const signUpSchema = z.object({
  phone: z.string().min(10),
  otp: z.string().length(6).optional(),
  displayName: z.string().min(2),
});

const form = useForm<z.infer<typeof signUpSchema>>({
  resolver: zodResolver(signUpSchema),
  mode: "onBlur",
});

None of this is unique to academic writing tools or to Nigeria specifically — it applies anywhere phone number is the identity people actually check. The general lesson is narrower than "support phone auth": treat the identity your users actually use as primary in the data model and the validation logic, not as a convenience field layered on top of an email-shaped system, because the parts of an auth stack that assume email semantics don't announce themselves until a real phone number breaks one.

Authbetter-authPayload CMSReact Hook Form