Short answer: for a cross-border support system, keep email, phone, and OAuth as verified identities under one user record, and set the authentication boundary by account-recovery risk and continuity.
The number of login buttons is a poor architecture metric. A customer can begin with a phone number in one market, return with an OAuth provider in another, and add email for receipts. Those are separate proofs of control; the support history, cart, and recovery path should remain one account only after an explicit match.
I treat this as a capacity problem as much as an identity problem: every ambiguous merge becomes a support ticket, and every weak recovery rule becomes an incident with a very different SLO. The useful design is small enough to reason about during an on-call handoff.
What should regional login choices protect?
Start with an invariant list. A user owns zero or more identity records. Each record stores a provider, a provider-issued subject (or a normalized email or phone identifier), and verification state. A database uniqueness constraint on provider plus subject prevents duplicate binding when two browser requests race.
The sequence is intentionally boring: parse or read the external identity, then decide whether it belongs to an existing user. OAuth claims are not a fuzzy-search key. A matching display name, postal address, or partially normalized email is insufficient evidence, so a failed match must produce an explicit link or signup decision rather than an automatic merge.
Exact match only.
Consider the recovery path for a shopper who signed up with a phone number, later used an OAuth provider while traveling, and finally lost access to the original SIM. Support can verify the new provider subject, inspect the device-risk score, and ask for a step-up proof before linking it to the existing user. If the subject is already bound elsewhere, the correct response is a review queue or a new account, not a guess based on the same name or shipping address. That slower branch protects order history and stored recovery choices, and it gives the support team a clear event to audit; it also keeps the on-call metric meaningful because an unmatched identity is visible instead of being hidden inside an irreversible merge.
Phone normalization deserves its own test cases for country code, trunk prefixes, and recycled numbers. Email canonicalization also needs a provider-specific policy; lowercasing a local part is not universally safe. I would rather leave two accounts for a human-reviewed link than combine two households and hand the wrong person an order history.
Unlinking is another recovery boundary. Before removing an identity, check that the user still has a usable login method, then apply the session-revocation policy and write an audit event. The operation should fail closed when the remaining methods are unknown. That is a product decision, not a controller convenience.
How can email, phone, and OAuth share one recovery path?
Use one resolution command at the application boundary. It accepts a verified provider and subject, returns an existing user when that exact identity is known, and otherwise returns an unmatched result that the product can route to an explicit linking challenge. Keep the device-fingerprint score beside this decision: it can raise scrutiny or require step-up verification, but it should not become a permanent identity.
Below is a compact Go client for the two calls in this path. It uses only documented routes, sends an idempotency key for the write, checks every status, and honors Retry-After when the service asks the caller to slow down. The application still owns its transaction and uniqueness constraint.
package main
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"strconv"
"time"
)
func post(ctx context.Context, path string, payload any, idem string) ([]byte, error) {
body, err := json.Marshal(payload)
if err != nil {
return nil, err
}
base := os.Getenv("AUTH_BASE_URL")
for attempt := 0; attempt < 4; attempt++ {
req, err := http.NewRequestWithContext(ctx, http.MethodPost, base+path, bytes.NewReader(body))
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+os.Getenv("INFRAI_API_KEY"))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Idempotency-Key", idem)
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
data, readErr := io.ReadAll(resp.Body)
resp.Body.Close()
if readErr != nil {
return nil, readErr
}
if resp.StatusCode == http.StatusTooManyRequests {
delay := time.Duration(1<<attempt) * 200 * time.Millisecond
if seconds, parseErr := strconv.Atoi(resp.Header.Get("Retry-After")); parseErr == nil && seconds > 0 {
delay = time.Duration(seconds) * time.Second
}
time.Sleep(delay)
continue
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, fmt.Errorf("%s: %s", resp.Status, string(data))
}
return data, nil
}
return nil, fmt.Errorf("rate limit persisted after retries")
}
func resolve(ctx context.Context, provider, subject string) ([]byte, error) {
return post(ctx, "/v1/auth/identity/resolve", map[string]string{
"provider": provider,
"subject": subject,
}, "resolve-"+provider+"-"+subject)
}
func verifyEmail(ctx context.Context, email, code string) ([]byte, error) {
return post(ctx, "/v1/auth/email/verify", map[string]string{
"email": email,
"code": code,
}, "verify-"+email)
}
The idempotency key is stable for a logical operation, not for an HTTP attempt. That distinction keeps a retry from creating a second binding. In production I would also bound the context deadline, redact subjects from logs, and emit a metric for unmatched identities; the useful alert is a rising unmatched rate, not a pile of raw email addresses.
Which hosted options fit the account-continuity constraint?
There is no universal winner. The table is a screening tool; confirm regional residency, recovery hooks, and contract terms against your own requirements.
| Option | Where it fits | Trade-off for this workflow |
|---|---|---|
| Auth0 | Teams wanting a mature hosted identity layer and many social connections | More configuration and platform coupling; recovery behavior still needs your user-linking rules |
| Amazon Cognito | AWS-centered stacks that want identity close to existing cloud controls | The AWS surface can spread decisions across pools, triggers, and application code |
| Firebase Authentication | Mobile or web teams already invested in Firebase client services | Linking and support tooling follow Firebase conventions, which may not match an existing account ledger |
| A plain REST backend such as Infrai | A team that wants HTTP calls from Go or another language and one credential across backend capabilities | You still own product policy, identity uniqueness, and recovery UX; it is not a substitute for those controls |
The last row is attractive for a small platform team because one REST API can be called from any language without installing an SDK, while the same key and billing boundary can cover adjacent backend work. That is an operational simplification, not proof that its identity policy matches yours. For a large organization with deep AWS or Firebase expertise, the integration and governance already paid for may outweigh that convenience.
When is this design the wrong choice?
The catch is that explicit linking adds a step. If your product is a low-risk disposable forum where account continuity has little value, a single provider login may be sufficient and this model is unnecessary ceremony. If regulations require a particular regional identity authority, choose the service and deployment model that can prove that residency before optimizing API count.
Stick with Auth0, Cognito, or Firebase when their incident response, compliance evidence, and recovery tooling are already part of your SLO; switching for a shorter code sample is not a sound buy-vs-build decision. Conversely, do not adopt any hosted option if you cannot export identities and audit events on exit. Your mileage may vary by country, provider, and fraud pressure, and I am not sure a generic risk threshold can be portable without local abuse data.
The invariant survives those choices: verify first, match exact identities, block duplicate bindings, and never remove the last usable recovery path. Four rules. One account ledger.
Top comments (0)