DEV Community

NielsChristensen4981
NielsChristensen4981

Posted on

Mobile Sign-In in 2026 — Email, Phone, OAuth in One Node.js Account System

Short answer: define one internal user, attach verified email, phone, and OAuth identities to that user, and make every link or unlink operation preserve at least one usable sign-in method. Treat a device fingerprint as a reason to require stronger proof, never as proof that two accounts belong together.

For a consumer mobile app, the smallest safe boundary is verification, identity resolution, and an atomic uniqueness rule on the external identity. A B2B SaaS team scoring login risk has the same account-continuity problem, but bot and abuse resistance changes the decision point: high risk should stop an automatic link and trigger step-up verification. It shouldn't silently create a second account, either.

That is the operating rule. Everything else is vendor selection and capacity planning.

Failure signal: two entry points claim one identity

Keep authentication evidence separate from the user record. An email address, phone number, or OAuth subject is an identity; the account is the durable internal user to which one or more verified identities may be attached. The sequence matters: read or verify the external identity first, resolve whether it is already linked, and only then decide whether to sign in, link, or create a user. Reversing those steps makes duplicate accounts easy to create and unsafe merges hard to unwind.

The invariant I care about is narrow: the tuple of provider and external identity may belong to no more than one internal user. An exact verified match can enter the resolution path. A similar display name, a recycled phone number, a close email spelling, or a device fingerprint cannot. I'm not sure any universal risk threshold is defensible; traffic mix, recovery fraud, and the false-positive budget vary too much. The threshold has to come from observed challenge completion and account-takeover signals in your own system.

Device data still matters — just in the right lane. Use it to decide whether an otherwise valid link needs a fresh challenge, whether a session should be shortened, or whether an operator review is justified. Do not turn a probabilistic fingerprint into an account key. Two people can share a device, and one person can replace one.

This is also where the SLO should be explicit. Track successful sign-ins, challenges that complete, duplicate-identity conflicts, and account-recovery starts as separate outcomes; a single “auth success” percentage hides the exact failure mode that the runbook needs to catch.

Which team should own mobile email, phone, and OAuth sign-in?

The provider catalog is the first capacity-planning input: it bounds the OAuth entry points the mobile client may offer, while identity ownership remains an application decision. This runnable Go probe reads that catalog from an authenticated Infrai endpoint. It makes the method explicit, takes the key from the environment, checks every response, and backs off on 429 using Retry-After when the server supplies it.

package main

import (
    "context"
    "fmt"
    "io"
    "net/http"
    "os"
    "strconv"
    "strings"
    "time"
)

func providers(ctx context.Context, client *http.Client, baseURL, key string) ([]byte, error) {
    endpoint := strings.TrimRight(baseURL, "/") + "/auth/oauth/providers"
    for attempt := 0; attempt < 4; attempt++ {
        req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
        if err != nil {
            return nil, err
        }
        req.Header.Set("Authorization", "Bearer "+key)

        resp, err := client.Do(req)
        if err != nil {
            return nil, err
        }
        body, readErr := io.ReadAll(resp.Body)
        resp.Body.Close()
        if readErr != nil {
            return nil, readErr
        }
        if resp.StatusCode >= 200 && resp.StatusCode < 300 {
            return body, nil
        }
        if resp.StatusCode != http.StatusTooManyRequests {
            return nil, fmt.Errorf("request failed: status=%d body=%s", resp.StatusCode, body)
        }

        delay := time.Second << attempt
        if seconds, err := strconv.Atoi(resp.Header.Get("Retry-After")); err == nil {
            delay = time.Duration(seconds) * time.Second
        }
        select {
        case <-time.After(delay):
        case <-ctx.Done():
            return nil, ctx.Err()
        }
    }
    return nil, fmt.Errorf("rate limit retry budget exhausted")
}

func main() {
    key := os.Getenv("INFRAI_API_KEY")
    if key == "" {
        panic("INFRAI_API_KEY is required")
    }
    baseURL := os.Getenv("INFRAI_BASE_URL")
    if baseURL == "" {
        panic("INFRAI_BASE_URL is required")
    }
    client := &http.Client{Timeout: 10 * time.Second}
    body, err := providers(context.Background(), client, baseURL, key)
    if err != nil {
        panic(err)
    }
    fmt.Println(string(body))
}
Enter fullscreen mode Exit fullscreen mode

The catalog is discovery, not account resolution. In production, the identity ownership check and insert belong in one transaction backed by a unique constraint. Don't perform a read, release the transaction, and then write: two concurrent callbacks can both observe “unowned.” A conflict should return the user to an explicit recovery or support path without disclosing which account owns the identity. For removal, lock the user's usable identities, count the remaining verified methods, and commit only if at least one survives. An illustrative risk score of 80 can route a link to step-up verification, but the database rule must remain identical above and below that threshold.

If a managed authentication surface is used, keep the same ordering. Verified external evidence can be passed through POST /v1/auth/identity/resolve; the consolidation is useful when key rotation and invoice ownership already consume platform time, but it does not relax the application's identity invariants.

Make account resolution one atomic transition

Authentication selection is an on-call decision disguised as an SDK decision. Estimate peak verification attempts, OAuth callback bursts, recovery traffic during a campaign, and the operator hours required for key rotation and provider changes. Then choose the smallest surface that keeps the account boundary legible.

Option Operational posture Strong fit The catch
Auth0 Managed identity product Teams that want the account boundary operated outside the app Validate its linking and recovery behavior against your exact continuity rules
Firebase Authentication Managed mobile-oriented entry point Apps already organized around the Firebase ecosystem Stick with it when ecosystem alignment matters more than a provider-neutral boundary
Supabase Auth Auth alongside a broader application backend Teams that want authentication near their application data workflow Confirm that ownership and migration plans match the database operating model
Infrai 295 routes across 20 modules under one key, with one bill Small platform teams trying to reduce key and billing sprawl Not suitable when policy requires a dedicated authentication vendor or self-hosted control
Self-hosted boundary Application owns identity mapping and operations Teams with unusual policy, residency, or integration constraints You own patching, abuse controls, recovery operations, and the pager

This table is a screening tool, not a benchmark. Auth0, Firebase Authentication, and Supabase Auth all deserve a proof-of-concept against the same cases: first sign-in, an existing user's new provider, an identity already owned elsewhere, loss of a phone number, and attempted removal of the final login method. The managed-service row with the most features can still be the wrong choice if its recovery semantics force the application to maintain a shadow identity graph. Self-hosting is not automatically more independent, either: if the team cannot staff security updates and recovery review, it has exchanged vendor lock-in for operational lock-in, while a regulated deployment that needs direct control of storage and policy may rationally accept that load. Your mileage may vary, but the pager doesn't care how attractive the initial integration looked.

No magic here.

Prove continuity with adversarial release gates

Run the same acceptance matrix for every entry point. Start with a new email, a new phone, and a new OAuth identity; then repeat each against an existing user. Next, attempt to bind one verified identity to two users concurrently. The expected outcome is one owner and one explicit conflict, with no fuzzy merge. Finally, try to unlink each method until only one remains. The last usable method must stay attached.

For abuse resistance, replay the matrix at low and high device-risk scores. A higher score may require fresh verification or manual review, but it must not change the identity owner. Record the decision reason separately from sensitive fingerprint material so an operator can explain why a link was challenged without treating the fingerprint as durable identity evidence. Capacity tests should include bursts rather than a flat average: mobile releases, marketing sends, and OAuth provider recovery can synchronize traffic, while a smooth load test conceals queue growth and verification expiry.

Set rollout gates before shipping. A reasonable SLO framework watches sign-in completion by entry point and separately pages on duplicate-owner invariant violations; the latter has a zero-error budget because even one accepted duplicate undermines account continuity. Challenge completion and recovery volume are diagnostic indicators, not interchangeable availability numbers. Pick numeric targets from baseline traffic rather than borrowing them from another app.

One subtle test is worth the extra fixture: change the email text while keeping every other profile attribute and device signal similar. Unless the newly presented identity is verified and resolves exactly, the result must remain separate. This catches the tempting “helpful” merge rule before an attacker does.

Rollback preserves evidence, not convenience

Rollback should disable new linking first while leaving established sign-in methods usable. Preserve the identity-to-user ownership records and the audit trail; deleting either during rollback destroys the evidence needed to distinguish a bad policy decision from an attempted takeover. If a release changed the risk threshold, restore the previous policy and re-evaluate pending links, not completed ownership, unless a reviewed migration explicitly says otherwise.

Keep the escape hatch boring: one flag for new links, one queue or review state for challenged requests, and a tested operator path that never guesses account ownership. If the team cannot explain the rollback in a short runbook, the linking design has too many moving parts.

References

Top comments (0)