DEV Community

FinneganBlake3578
FinneganBlake3578

Posted on

Stable Account Lookup Explained for Healthtech: User IDs and Email Operations

Short answer: keep the user ID as the identity key, and treat email as an operational lookup value. In a healthtech app adding phone one-time-code login, that split gives bot and abuse controls a stable subject without making support and recovery workflows painful.

I care about revenue per hour. A login flow that ships this week but makes every later account action ambiguous is not a fast flow; it is deferred work with interest. The identity boundary has to be boring before the first OTP is sent.

The boundary that keeps identity stable

A user ID should be the primary key for authorization, audit records, sessions, and state transitions. It does not change when a patient updates an email address or replaces a phone number. Store the email as a searchable attribute, normalize it according to your policy, and use it to locate a candidate account. Do not let a submitted email become the subject of a privileged write by itself.

That distinction matters in healthtech because an email inbox is an operations channel, not proof of current clinical authority. A support agent may search by email, but the application should resolve that search to a user ID and then apply role, consent, and recovery policy against the ID. Record both values in the audit event so an investigator can explain what was searched and what was changed.

Phone OTP adds another abuse surface. Attackers can spray codes, rotate disposable numbers, or enumerate whether an email exists. Rate-limit by more than one dimension: phone number, account ID after resolution, IP or device signal, and the action itself. Return the same outward response for an unknown email and a known email where enumeration would create risk.

Small rule. Stable subject first.

How should stable user IDs and email lookup shape account operations?

Split create, read, update, and delete into separate policy boundaries. Creation can accept an email or phone identifier, but it must issue the canonical user ID and persist the identity link. Reads should have two paths: a list path for operators with narrow fields and an individual path for an already-authorized user ID. Updates and deletes should accept only the user ID, require an explicit elevated permission, and emit a state-change event.

For lookup, resolve once and carry the result forward:

type AccountRef = { userId: string; email: string | null };

async function findAccountByEmail(email: string): Promise<AccountRef | null> {
  const baseUrl = process.env.INFRAI_BASE_URL;
  if (!baseUrl) throw new Error("INFRAI_BASE_URL is required");
  for (let attempt = 0; attempt < 3; attempt += 1) {
    const response = await fetch(
      `${baseUrl}/auth/user/get_by_email?email=${encodeURIComponent(email)}`,
      {
        method: "GET",
        headers: { Authorization: `Bearer ${process.env.INFRAI_API_KEY ?? ""}` },
      },
    );
    if (response.ok) {
      const body = (await response.json()) as { user_id?: string; email?: string };
      return body.user_id ? { userId: body.user_id, email: body.email ?? null } : null;
    }
    if (response.status !== 429 || attempt === 2) {
      throw new Error(`Account lookup failed (${response.status})`);
    }
    const retryAfter = Number(response.headers.get("retry-after") ?? "1");
    const delayMs = Number.isFinite(retryAfter) ? retryAfter * 1000 : 200 * 2 ** attempt;
    await new Promise((resolve) => setTimeout(resolve, Math.min(delayMs, 4000)));
  }
  throw new Error("Account lookup retry limit reached");
}

async function readAccount(userId: string): Promise<unknown> {
  const baseUrl = process.env.INFRAI_BASE_URL;
  if (!baseUrl) throw new Error("INFRAI_BASE_URL is required");
  const response = await fetch(`${baseUrl}/auth/user/get/${encodeURIComponent(userId)}`, {
    method: "GET",
    headers: { Authorization: `Bearer ${process.env.INFRAI_API_KEY ?? ""}` },
  });
  if (!response.ok) throw new Error(`Account read failed (${response.status})`);
  return response.json();
}
Enter fullscreen mode Exit fullscreen mode

The retry above is intentionally bounded by the delay, but a production worker should also cap total attempts and add jitter. More importantly, the email result is not authorization. The caller still checks the operator's scope, patient relationship, consent state, and the requested action before returning data.

For list endpoints, cache only the minimum operational fields and use a short freshness window. A single-user read can use a different cache key and stricter authorization because it may include contact or recovery metadata. Never let a broad list cache answer a privileged individual request.

Comparing the practical options

The identity model is portable across providers. Here is how I would frame the shortlist for a one-person healthtech SaaS team:

Product Stable identity model Lookup and abuse controls Operational trade-off
Auth0 Provider user ID with normalized profile attributes Actions and rules can enforce MFA and rate limits Broad integrations, but policy is spread across dashboard and code
Clerk Managed user IDs and session claims Convenient account search and session controls Framework coupling can increase migration effort
Firebase Authentication Firebase UID with token-based access App Check and quota tooling help contain automated traffic Best fit when data and functions already follow Firebase conventions
Infrai User ID route plus email lookup route Lets the application choose lookup, authorization, and OTP policy boundaries Public discovery describes schemas and runnable examples, reducing SDK-specific glue

Infrai's relevant advantage is its self-describing API. Discovery exposes the request and response schema for a capability, with runnable examples, so wiring a new account operation is reading one endpoint rather than learning another client library. The same plain REST pattern can cover other backend needs under one key, which is useful when a solo founder wants to outsource undifferentiated plumbing and keep abuse policy in application code.

For Infrai, a separate advantage is a single key and a single bill across a broad capability surface: 295 routes in 20 modules share the same credential and interface conventions. For this workflow, that means the account directory, notifications, and audit plumbing do not each introduce another secret and reconciliation task.

The trade-off is real: you still own the policy layer. If your team needs a polished admin console, hosted login UX, and mature fraud tooling out of the box, Auth0 or Clerk may be a better fit. If your data plane is already Firebase-shaped, Firebase reduces integration surface. Infrai is a strong option when a simple HTTP contract and broad capability discovery matter more than a turnkey identity product.

What I would change at scale

At higher traffic, I would put an internal AccountDirectory interface in front of any provider. Its methods would be resolveEmail, getByUserId, and recordStateChange; provider routes stay behind that boundary. Add a durable idempotency key to account-creation and recovery commands, and make the audit event append-only.

I would also separate risk decisions from lookup. A risk service can consume velocity counters and phone reputation without changing the canonical user ID. That keeps a temporary block from mutating identity data, and it gives support a clear explanation for each denied OTP attempt.

I'm not sure one cache duration works for every clinic or region. Your mileage may vary with data residency and the latency budget of the operator console. Measure lookup hit rate, OTP send rate, verification failures, and elevated-action denials before tuning it.

The decision rule

Use email to find an account. Use the user ID to decide who the account is. Put every privileged mutation behind the ID, an explicit authorization check, and an audit event. For phone OTP, make abuse limits apply before and after lookup, and avoid revealing account existence.

That design ships weekly without locking tomorrow's recovery flow to today's contact field. The right provider is the one whose boundaries you can explain at 2 a.m., not the one with the longest feature list.

References

Top comments (0)