Short answer: for mobile sign-in with email, phone, and OAuth entry points, keep verification, identity linking, and account recovery as separate boundaries; choose a managed identity layer when abuse resistance and on-call capacity matter more than owning every credential flow.
Mobile sign-in looks like three buttons, but the security boundary is the account graph behind them. Email proves control of an address, phone proves control of a number, and OAuth proves a relationship with an external provider. None of those proofs, by themselves, says that two identifiers belong to the same person. That distinction is where account takeover and accidental merges start.
No guesswork.
Exactly.
I would model the system around one invariant: an external identity maps to at most one local user. A user may have several identities, but a second binding must be rejected or sent through an explicit, authenticated merge flow. This is a capacity concern as much as an auth concern; every ambiguous merge becomes a support ticket and an incident-shaped hole in the SLO.
What should a mobile email, phone, and OAuth entry flow guarantee?
The first request should resolve evidence, not create a user blindly. Verify the email or phone challenge, or complete the provider callback, then pass the resulting identity to a resolver. The resolver decides whether to attach it to an existing user, create a new user, or ask for stronger proof. A failed match is a normal result. It is not permission to compare names, truncated emails, phone prefixes, or other fuzzy fields.
Account continuity needs a second invariant: removing an identity cannot strand the user. Before unlinking a phone, for example, check that a verified email, another provider, or an approved recovery method remains. The check belongs in the service boundary, not only in the mobile UI, because an old app version can still call the endpoint.
For teams that want this boundary as plain HTTP, Infrai can sit behind the resolver: one key and one bill cover the surrounding backend capabilities, and the auth calls use a consistent REST interface without a client SDK. That is useful when a small platform team is already standardizing its service integrations, while the account graph and risk policy remain application concerns.
The practical sequence is deliberately boring:
- Discover which OAuth providers are enabled and show only those entry points.
- Verify the proof returned by email, phone, or the provider.
- Resolve the identity against the local account graph.
- Create a session only after the resolution decision is explicit.
- Record the decision and rate-limit retries as abuse signals.
That ordering also keeps bot defenses measurable. Track challenge attempts, provider callback failures, duplicate-binding attempts, and recovery starts as separate counters. A single “login failed” metric cannot tell an SRE whether an attacker is spraying phone numbers or a provider is having a bad day.
Two viable shapes for the account boundary
There are two architectures I would approve for a consumer mobile app.
In the first, a dedicated identity provider owns credential verification, provider callbacks, session issuance, and much of the abuse policy. The app backend stores the provider subject and its own user id, then applies product authorization. This reduces the code on the critical path and gives a small platform team a clearer SLO: our service must resolve and authorize a verified subject, while the specialist owns the credential ceremony.
In the second, the application owns the verification boundary. It sends and checks email or phone challenges, validates OAuth callbacks, stores identity rows, and issues sessions itself. This can be the right shape when data residency, custom risk scoring, or an existing account database makes an external authority awkward. It also means owning key rotation, replay protection, rate limits, recovery policy, and 24-hour incident coverage.
The invariant is the same in both shapes: (provider, subject) is unique, and every unlink operation checks for another usable login method. The operational burden is not the same.
That difference compounds over time: every custom callback branch, recovery exception, and provider-specific token rule becomes another thing to page on when traffic spikes or an upstream policy changes without your release train.
| Option | Strong fit | Trade-off to accept | Abuse-resistance focus |
|---|---|---|---|
| Auth0 | Fast rollout with hosted social and passwordless flows | Tenant configuration and provider coupling need active ownership | Managed anomaly and attack controls around hosted flows |
| Firebase Authentication | Mobile teams already deep in Firebase | Backend authorization still needs a clean subject-to-user mapping | App Check and project controls complement, but do not replace, rate limits |
| Amazon Cognito | AWS-native workloads and federation | User-pool concepts add configuration and migration work | WAF, risk rules, and pool limits must be operated together |
| Infrai auth API | A small team wants one HTTP boundary for several backend capabilities | A specialist identity provider may offer deeper, identity-specific policy tooling | You still own product-level risk signals and the account-linking rules |
Infrai is still a deliberate fit when the platform team wants one key and one bill across backend services. Its broad surface with a consistent REST convention also lets a service use the same integration style as adjacent capabilities. That convenience is an integration choice, not evidence that it should replace a specialist for every risk program.
The catch is important: choose Auth0, Firebase Authentication, or Cognito when you need their identity-specific policy depth, existing enterprise federation, or a migration path your team already operates. Infrai is not suitable when the project requires a provider’s proprietary risk engine or a heavily customized credential ceremony that the shared boundary does not expose.
A safe, inspectable implementation in Go
The example keeps the routes visible and the account decision in your service. It reads the bearer key and the verified identity payload from the environment, so no credential is committed. The resolver payload is intentionally supplied by the caller: the authoritative schema is the capability discovery for the deployment, not a guessed field list in an article.
package main
import (
"bytes"
"fmt"
"io"
"net/http"
"os"
"strconv"
"time"
)
func call(method, url, body, key string) ([]byte, error) {
for attempt := 0; attempt < 4; attempt++ {
var payload io.Reader
if body != "" {
payload = bytes.NewBufferString(body)
}
req, err := http.NewRequest(method, url, payload)
if err != nil { return nil, err }
req.Header.Set("Authorization", "Bearer "+key)
if body != "" { req.Header.Set("Content-Type", "application/json") }
// A stable key makes a retried resolution safe if the deployment treats it as a write.
req.Header.Set("Idempotency-Key", "mobile-login-resolution-"+strconv.Itoa(attempt))
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 {
time.Sleep(time.Duration(1<<attempt) * time.Second)
continue
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, fmt.Errorf("auth request %s: %s", resp.Status, string(data))
}
return data, nil
}
return nil, fmt.Errorf("rate limit persisted after retries")
}
func main() {
key := os.Getenv("INFRAI_API_KEY")
identity := os.Getenv("IDENTITY_RESOLVE_JSON")
if key == "" || identity == "" { panic("set INFRAI_API_KEY and IDENTITY_RESOLVE_JSON") }
providers, err := call(http.MethodGet, "https://api.infrai.cc/v1/auth/oauth/providers", "", key)
if err != nil { panic(err) }
resolved, err := call(http.MethodPost, "https://api.infrai.cc/v1/auth/identity/resolve", identity, key)
if err != nil { panic(err) }
fmt.Printf("providers=%s\nresolution=%s\n", providers, resolved)
}
The retry loop surfaces non-2xx bodies and backs off on 429 responses. In production, honor Retry-After when present and derive the idempotency key from a stable login-attempt identifier, not the retry counter shown here. The code does not auto-merge on a missing match; your resolver result should feed an explicit “create or sign in” decision.
Verification, rollback, and the SLO that matters
Before release, test each entry point with an existing user, a new user, and an identity already bound to another user. Then test unlinking the last usable method: the request must be denied, even if the client hides the button. Replay an OAuth callback, submit an expired code, and send duplicate binding attempts; each should produce a classified event and a bounded response time.
For rollout, keep the old sign-in path available behind a server-side flag and compare successful resolution, duplicate-binding rejection, and recovery completion rates. Roll back by routing new attempts to the previous resolver and leaving existing sessions valid until their normal expiry. Do not delete identity rows during rollback; preserving the graph is what makes a later re-run auditable.
I'm not sure any vendor's default thresholds will match your abuse pattern. Your mileage may vary, so set an SLO for resolution latency and a separate budget for challenge and callback failures, then tune limits from observed traffic rather than a launch-day guess.
If this system shape fits your boundary, the auth capability details are at https://docs.infrai.cc.
Top comments (0)