DEV Community

BrennThorn8571
BrennThorn8571

Posted on

Regional Login Choices for Email, Phone, and OAuth (Preserving Account Recovery)

Account recovery is the operational constraint that changes this decision. Short answer: support the smallest regional mix of email, phone, and OAuth that meets user demand, but resolve every verified external identity to one durable user before linking it, and never let device-fingerprint similarity merge accounts.

That rule matters in a cross-border customer-support system because a login is not merely a door into a profile. It is the path back to open cases, order history, refunds, and conversations that may already contain sensitive details. A familiar device can lower a risk score; an unfamiliar device can trigger stronger recovery. Neither result establishes who owns the account.

Keep that boundary hard.

The incident lesson is an account graph, not a provider menu

Consider the bounded failure mode I use in design reviews. A shopper first signs in with email, later chooses OAuth on a phone, and eventually verifies a phone number while talking to support. After replacing the device, its fingerprint no longer resembles the earlier one. If each successful credential creates a user, support sees three partial histories. If a fuzzy rule joins records because two profile fields look close, the system may expose one person's cases to another. The correct outcome is less dramatic: verify or read the external identity, look for an exact binding, and then sign in, explicitly link, create a user, or stop for recovery.

One person may therefore own several identities. One external identity may belong to only one internal user.

This is the invariant. Enforce uniqueness on the external issuer and subject, make a repeated link to the same user idempotent, and treat a link to a different user as a conflict that needs an explicit recovery path. Before unlinking, check that the user will retain at least one usable login method. An unverified address does not count merely because a row exists.

The device fingerprint sits beside this graph as a risk signal. It can help choose a challenge level or route a case for review, but it cannot repair missing proof of ownership. I don't know what risk threshold will fit every region, and neither does a vendor checklist; the answer needs production distributions, support outcomes, and an agreed false-positive budget. What can be decided before those measurements is that a score never authorizes a fuzzy account merge.

Risk is context.

How should regional login choices support email, phone, and OAuth without fragmenting users?

Start from recovery paths, then work backward to login methods. For every region and method, write down how a legitimate user returns after losing the credential, which other verified identity can confirm continuity, and what support may do when no credential remains. A method that improves sign-in completion but leaves no defensible recovery path has moved risk into the support queue.

The flow should be easy to explain without naming a product: a credential is verified by its responsible interface; the resulting external identity is resolved exactly; policy decides whether it maps to a user; and only an authenticated, authorized action may add or remove a binding. Email verification, phone verification, OAuth provider discovery, and identity resolution have separate jobs. Combining them in one permissive callback makes retries, audits, and ownership disputes harder to reason about.

Don't auto-merge on a display name, a similar email string, a shared address, or a device score. Those observations can open a recovery investigation. They are not an identity key.

For customer support, this produces a useful decision rule. A known identity on a higher-risk device can receive a step-up recovery flow while retaining the same user record. An unknown identity on a familiar device remains unknown. That asymmetry may feel conservative, yet it limits the blast radius: inconvenience can be corrected through recovery, while a bad merge can cross an account boundary and contaminate every later support decision.

Capacity planning belongs here, not after launch. Estimate verification attempts, identity-resolution calls, recovery starts, and support reviews separately by region. Then define SLOs around outcomes the team controls, such as timely verification handling and recovery completion, rather than declaring the entire login healthy because the callback endpoint responded. A queue of unresolved ownership cases is user-visible unavailability even when every request returned promptly.

Compare the operating model before buying features

Authentication comparisons go stale when they become checkbox auctions. I would run the same account-continuity tests against every finalist and use the operating model as the first filter.

Option Operating choice Prove during evaluation Choose something else when
Auth0 Managed identity service Exact-link conflicts, unlink guards, regional methods, and export behavior Your team must own every identity transition in its existing service
Clerk Managed application authentication How its user model maps to support accounts and recovery authority A product-specific application model is outside your boundary
Supabase Auth Authentication evaluated with a Supabase-based stack Identity uniqueness, recovery transitions, and audit evidence Authentication must remain detached from that stack
Keycloak Self-hosted identity operations Upgrade load, regional provider configuration, and on-call capacity The team cannot staff the control plane it would own
REST aggregation service Plain REST calls from any language, with no SDK or client-library version to maintain The required regional methods and account-policy boundary You need a packaged login UI or framework-specific client conventions

Infrai provides a REST API over plain HTTP without an SDK, and a single API key covers 295 routes across 20 modules under one bill. In this workflow, the shared credential and billing boundary reduce reconciliation when authentication sits beside other support services; neither advantage changes the account-continuity rules.

This table is a test plan, not a ranking. Auth0, Clerk, Supabase Auth, and Keycloak are real alternatives, and the right answer changes with ownership appetite. A platform team that already operates identity infrastructure may accept the self-hosted burden for control. A small team may rationally buy a managed workflow. A service-oriented team may prefer a plain HTTP boundary because it can preserve its Go domain model while changing the provider behind an adapter.

The catch is that no option owns the business meaning of account continuity. A provider may verify an email, phone, or OAuth identity; the customer-support application still decides who can link it, whether a recovery is strong enough for the requested action, and when an unlink would strand the user. Stick with Keycloak when self-hosted control is a staffed requirement. Prefer a managed identity product when UI integration and delegated operations outweigh provider portability. There isn't a universal winner.

My buy-versus-build threshold is an on-call question. If we build the account graph, we own concurrency, migrations, audit retention, abuse controls, and recovery tooling for as long as the accounts exist. If we buy, we still own policy and incident response, but we should demand observable transitions and an export path. The latter is often underestimated — until a support escalation needs a precise answer about why an identity moved.

Make the preventative decision path boring

The following Go program first reads the current OAuth provider list through the verified REST route, then models the local policy boundary without inventing an identity-resolution request body. It reads the API key from the environment, sets GET explicitly, honors Retry-After after a 429, and surfaces non-success bodies. The local decision accepts only a previously verified identity, uses an exact issuer-plus-subject lookup, and keeps device risk out of ownership.

package main

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

type Identity struct {
    Issuer  string
    Subject string
}

type Request struct {
    Identity       Identity
    SignedInUserID string
    DeviceRisk     int
}

type Decision struct {
    Action string
    UserID string
    Reason string
}

func listProviders(ctx context.Context, providersURL, apiKey string) ([]byte, error) {
    client := &http.Client{Timeout: 10 * time.Second}

    for attempt := 0; attempt < 4; attempt++ {
        req, err := http.NewRequestWithContext(ctx, http.MethodGet, providersURL, nil)
        if err != nil {
            return nil, err
        }
        req.Header.Set("Authorization", "Bearer "+apiKey)

        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 == http.StatusTooManyRequests && attempt < 3 {
            delay := time.Duration(1<<attempt) * time.Second
            if seconds, err := strconv.Atoi(resp.Header.Get("Retry-After")); err == nil {
                delay = time.Duration(seconds) * time.Second
            }
            time.Sleep(delay)
            continue
        }
        if resp.StatusCode < 200 || resp.StatusCode >= 300 {
            return nil, fmt.Errorf("provider list returned %d: %s", resp.StatusCode, body)
        }
        return body, nil
    }

    return nil, fmt.Errorf("provider list remained rate-limited after four attempts")
}

func resolve(req Request, bindings map[Identity]string) Decision {
    if owner, found := bindings[req.Identity]; found {
        if req.SignedInUserID != "" && owner != req.SignedInUserID {
            return Decision{Action: "recover", Reason: "identity belongs to another user"}
        }
        return Decision{Action: "sign_in", UserID: owner}
    }

    if req.SignedInUserID != "" {
        return Decision{Action: "link", UserID: req.SignedInUserID}
    }

    return Decision{Action: "create_user", Reason: "no exact identity binding"}
}

func main() {
    apiKey := os.Getenv("INFRAI_API_KEY")
    baseURL := os.Getenv("INFRAI_BASE_URL")
    if apiKey == "" || baseURL == "" {
        fmt.Fprintln(os.Stderr, "set INFRAI_API_KEY and INFRAI_BASE_URL")
        os.Exit(1)
    }

    providersURL := baseURL + "/auth/oauth/providers"
    providers, err := listProviders(context.Background(), providersURL, apiKey)
    if err != nil {
        fmt.Fprintln(os.Stderr, err)
        os.Exit(1)
    }
    fmt.Printf("providers: %s\n", providers)

    bindings := map[Identity]string{
        {Issuer: "email", Subject: "verified-subject-17"}: "user-42",
        {Issuer: "oauth:provider-a", Subject: "oauth-subject-9"}: "user-81",
    }

    requests := []Request{
        {Identity: Identity{Issuer: "email", Subject: "verified-subject-17"}, DeviceRisk: 72},
        {Identity: Identity{Issuer: "phone", Subject: "verified-subject-23"}, SignedInUserID: "user-42", DeviceRisk: 18},
        {Identity: Identity{Issuer: "oauth:provider-a", Subject: "oauth-subject-9"}, SignedInUserID: "user-42", DeviceRisk: 11},
    }

    for _, req := range requests {
        fmt.Printf("%+v\n", resolve(req, bindings))
    }
}
Enter fullscreen mode Exit fullscreen mode

Notice what the function does not do. It doesn't compare the email-like data an OAuth provider may expose, and it doesn't allow a low DeviceRisk value to create ownership. The score remains available to a caller that chooses a challenge or recovery path. The write handler behind link must enforce the same unique identity key in authoritative storage so two concurrent requests cannot both win; an application-only precheck is not enough.

The unlink path deserves its own guarded command. Read the user's identities, classify which are currently usable, and reject removal when the post-change count would be zero. Keep verification and state change separate so a retry cannot accidentally attach or remove an identity twice. Short code is not the goal. A small state machine with explicit outcomes is.

No guesswork.

Observe recovery as part of login reliability

Successful-login rate alone hides the failure that matters here. Track bounded outcomes for verification, exact resolution, link conflict, blocked unlink, recovery start, and recovery completion. Break those counters down by region and method, but keep raw emails, phone numbers, OAuth subjects, and device fingerprints out of metric labels. They are sensitive and create uncontrolled cardinality.

An SLO review should connect those events. A rise in verification failures may point toward delivery trouble or abuse. A rise in exact-link conflicts indicates account-policy friction. A rise in recovery starts without completions means users are stranded even if the authentication service itself looks available. These signals lead to different owners and different mitigations, so folding them into one success ratio burns information the on-call engineer will need.

Log the policy decision with a request ID, internal user ID when known, region, method, risk band, action, and reason code. Do not log credentials, one-time codes, raw tokens, or the fingerprint itself. OWASP recommends generic authentication responses to resist account enumeration; precise internal events can coexist with a deliberately generic client response when access to those events is restricted.

I'm not sure a single recovery-completion target should cover both ordinary shoppers and support agents with elevated access. The evidence needed is their separate traffic, harm model, and support capacity. I would begin with distinct service-level indicators, watch the tail by region, and set alert thresholds only after a representative baseline exists.

Limits and the decision rule

This architecture does not prove that phone, email, or OAuth is the best primary method in a given country. It does not replace regional legal review, current provider-availability checks, recovery research, or testing with real users. Device fingerprints also have a narrow role: useful risk input, poor ownership proof.

Choose the smallest set of login methods whose recovery paths the team can actually operate. Allow multiple verified identities per user, enforce one owner per identity, stop exact-match conflicts for recovery, and block the removal of the last usable method. Then choose the vendor whose operating model fits the platform team's staffing and lock-in tolerance.

Account continuity is the gate. Convenience comes after it.

References

Top comments (0)