DEV Community

NoahHayes7250
NoahHayes7250

Posted on

Mobile Sign-In Account Systems: Email, Phone, OAuth, and Risk Entry Points

Most mobile sign-in migrations fail because the team treats email, phone, and OAuth as three user databases. For a healthtech app scoring login risk from device fingerprints, the safer design is one account record with several verified entry points, plus a risk decision that runs before a session is issued.

Short answer: keep one immutable account ID, normalize every email and phone identifier, bind OAuth identities to that account only after provider verification, and make the device-risk result an input to step-up authentication rather than a password replacement.

The constraint that changes the migration

I run a one-person SaaS, so my useful unit is revenue per hour. A managed identity provider can be perfectly reasonable while a product is finding product-market fit. The constraint changes when migration work appears: I need to ship weekly, preserve existing sessions, and avoid spending a month rewriting every screen that assumes “email login” is the account.

In healthtech, there is another constraint: a device fingerprint is a signal, not an identity. Phones get shared. Browsers get reset. A patient may use a caregiver's tablet. Treating a fingerprint as proof of ownership creates a lockout problem, and treating it as harmless metadata creates an account-takeover blind spot.

The migration target is therefore an account graph:

One account. Many proofs.

Record Purpose Invariant
account Internal user and consent owner ID never changes during a login-method migration
login_identity Email, phone, or OAuth subject (provider, subject) is unique
device_signal Fingerprint, age, and confidence Never grants access by itself
auth_event Attempts, challenges, and decisions Append-only audit trail

Do not use an email address as the primary key. A user can change it, and two entry points can represent the same person before they are linked. The account ID is the join key; everything else is evidence.

How should email, phone, and OAuth entry points share one account system?

Start with a single sign-in command that accepts an entry-point type. The command resolves an identity, checks its verification state, and then asks the risk engine for a policy decision. It should not create a second account just because the user tapped a different button.

Here is the smallest TypeScript shape I would deploy behind an HTTP handler. It deliberately keeps provider-specific fields at the edge.

type EntryPoint =
  | { kind: "email"; address: string }
  | { kind: "phone"; e164: string }
  | { kind: "oauth"; provider: string; subject: string; email?: string };

type RiskDecision = "allow" | "step_up" | "deny";

interface AuthStore {
  findIdentity(entry: EntryPoint): Promise<{ accountId: string; verified: boolean } | null>;
  createAccount(): Promise<string>;
  attachIdentity(accountId: string, entry: EntryPoint): Promise<void>;
  recordEvent(event: Record<string, unknown>): Promise<void>;
}

interface RiskEngine {
  score(input: { accountId: string; fingerprint: string; ip: string }): Promise<RiskDecision>;
}

export async function beginSignIn(
  entry: EntryPoint,
  fingerprint: string,
  ip: string,
  store: AuthStore,
  risk: RiskEngine,
): Promise<RiskDecision> {
  const normalized = normalize(entry);
  let identity = await store.findIdentity(normalized);

  if (!identity) {
    const accountId = await store.createAccount();
    await store.attachIdentity(accountId, normalized);
    identity = { accountId, verified: false };
  }

  const decision = await risk.score({ accountId: identity.accountId, fingerprint, ip });
  await store.recordEvent({
    type: "sign_in_started",
    accountId: identity.accountId,
    entryPoint: normalized.kind,
    risk: decision,
  });
  return decision;
}

function normalize(entry: EntryPoint): EntryPoint {
  if (entry.kind === "email") {
    return { kind: "email", address: entry.address.trim().toLowerCase() };
  }
  if (entry.kind === "phone") {
    return { kind: "phone", e164: entry.e164.replace(/[^+\d]/g, "") };
  }
  return { ...entry, provider: entry.provider.toLowerCase(), subject: entry.subject };
}
Enter fullscreen mode Exit fullscreen mode

The real endpoint still needs proof of control. For email, that usually means a single-use, short-lived code or link. For phone, it means an SMS challenge with rate limits and an abuse budget. For OAuth, validate the authorization-code exchange, issuer, audience, nonce, and state before accepting the provider subject. Never use a client-supplied email claim as the link key.

I once wired a phone flow using display formatting instead of E.164 normalization. The same number arrived as +1 415 555 0199 on iOS and 415-555-0199 on Android, producing two identities and two audit trails. The visible symptom was a “missing account.” The data bug was a missing canonical form.

Keep linking a separate, high-friction operation. A signed-in user who wants to add Google or a new phone should re-authenticate, complete the new challenge, and see the exact account that will be changed. Automatic linking by matching unverified emails is convenient until an attacker controls that mailbox.

Device risk belongs before the session, not inside the identity

A fingerprint service can return a score, confidence, and reason codes. Store the minimum needed to explain a decision, and set a retention rule. Raw fingerprints can become sensitive data when combined with health information, so access controls and deletion requests need to cover them.

The policy can be boring. Boring is good:

  1. allow issues a normal session after the entry point is verified.
  2. step_up requests another factor, such as a passkey, TOTP, or a fresh verified challenge.
  3. deny creates no session and records a generic user-facing error.

Do not let a low-risk device silently bypass an unverified email or phone. Conversely, do not deny a familiar device forever after one suspicious IP. Risk should expire, be recalculated, and be explainable to support staff without exposing a detection recipe.

For a migration, run the new score in shadow mode first. Compare decisions with the managed provider's outcome, sample false positives, and watch challenge completion. I would rather spend two release cycles measuring than spend one afternoon locking out a clinic's shared tablet fleet.

The migration sequence that keeps weekly shipping intact

Move one boundary at a time. First, import accounts and provider subjects into the new schema while the old provider remains the session authority. Second, dual-write new identities and auth events. Third, mint a migration token when an old session is exchanged for a new session. Finally, switch entry points gradually and keep a short rollback window.

The rollback window deserves an actual design, not a calendar note. During a staged cutover, I keep a migration_version on the account and include it in the session claims. If a cohort shows a spike in failed challenges, the gateway can stop issuing version-2 sessions while version-1 sessions continue to expire normally. The old provider remains read-only for identity lookup until the last version-1 session is gone. That extra state costs a column and a dashboard panel, but it prevents a frantic database restore when the problem is only a policy mismatch. I would remove the read-only path after two full recovery windows, with an export of the identity mapping retained for support and regulatory review.

The token must be one-time, audience-bound, and stored hashed. Log the old subject and new account ID together, but never log access tokens, SMS codes, or raw authorization responses. OWASP's authentication guidance also calls for generic error messages and defenses against credential stuffing; those details matter more than the migration diagram.

Tests should cover the joins, not just the happy path: an OAuth subject already attached to another account, a phone number that changes country code, a revoked email link, a replayed migration token, and a device that alternates between allow and step_up. Add property tests for normalization. A five-line normalization bug can outlive an entire provider contract.

Ship the join tests first.

Observability should answer three questions quickly: which entry point was used, which account ID was affected, and why the risk policy requested a challenge. Keep those fields in structured events with a correlation ID. Dashboards should show challenge rate, completion rate, account-link conflicts, and migration-token replays. They should not show raw identifiers to every operator.

What I would change at scale, and when this design is not suitable

At scale, I would add passkeys as a first-class entry point, isolate risk scoring behind a queue for expensive enrichment, and make account-link review an explicit support workflow. I would also partition audit events by account and enforce a retention job, because compliance requests become operational work the moment the app has real patients.

The catch is that this design is not suitable when the product cannot operate an identity store, an audit trail, and a recovery process. A regulated organization with an existing enterprise directory may be better served by keeping that directory as the source of truth and using standards-based federation. Stick with the managed provider when migration only changes a logo or a unit price; the engineering hours are rarely recovered by that alone.

Your mileage may vary. The right boundary depends on recovery staffing, regional SMS rules, and how much of the old provider's risk policy you can observe. Decide with a staged rollback plan, not a feature checklist.

References

Top comments (0)