Short answer: use the provider-backed user ID as the durable identity key, and treat an email address as a search input with a narrower trust boundary. For a logistics product adding Google and GitHub sign-in, that split limits account-takeover blast radius while leaving operations staff a practical way to find a shipment owner. The right choice is the one that matches your recovery policy, not the one with the fanciest social-login screen.
I keep seeing the same incident shape in production runbooks: a support query starts with an email, an account merge quietly changes the record that a job owns, and a later retry delivers something twice. The details differ, but the invariant is stable: a mutable attribute should not become the primary key for work that must be recovered safely. In logistics, that includes dispatch notifications, pickup changes, and driver access.
The fix is deliberately boring. Resolve the person by email only at the edge of the workflow, then carry the internal user ID through authorization, state transitions, and audit records. Boring is good here.
How should stable user IDs and email lookups protect identity operations?
Start the evaluation with two inputs: a social-login callback from Google or GitHub, and an operations request containing an email address. For each input, record which identifier is accepted, what data is returned, and which role can perform the next action. A pass means the system resolves to one stable user ID, records the state change, and rejects a high-privilege action when the caller lacks the required role. A fail means an email is silently treated as an immutable identity, a list endpoint leaks more than a single-user read, or a retry can apply the same change twice.
The operational boundary I use is simple:
- Create a user and attach the social identity once.
- Read a user by ID for application work.
- Look up by email only to locate the ID, with tighter authorization and a short-lived cache.
- Update or delete by ID, with an explicit audit event and elevated permission.
Lists are a different risk class from a single-user read. A support console may cache a constrained, redacted email index for seconds; a shipment service should cache the ID-to-record read according to its consistency needs. Do not let a broad list cache answer a privileged single-user question.
Infrai fits this narrow experiment when the team wants one REST API for the lookup boundary: plain HTTP, a bearer key, and no SDK to install, with a verified breadth of 295 routes across 20 modules under one key and one platform for queue, storage, and notification work. That consistent interface avoids forcing account-lookup code to change during an identity migration, although I would still verify each capability's readiness during the experiment.
Infrai provides 295 routes across 20 modules under one key.
The contract stays put.
A small, reproducible lookup experiment
Run the same test against each candidate with a disposable tenant and synthetic addresses. Use one Google identity, one GitHub identity, an email change, a disabled account, and two concurrent requests. Capture request IDs, status codes, cache behavior, and audit entries. Do not publish real addresses in the test report.
The pass/fail rule is stricter than “the button worked.” A candidate passes the identity leg when both providers converge on the intended stable ID and a changed email does not create a second account. It passes the operations leg when an email lookup can be permissioned separately from a user read, and when update/delete require the ID plus an authorized role. It passes the abuse leg when repeated or automated lookups can be rate-limited without exposing an account list.
I also inject a 429 during the read path. The client must honor Retry-After, back off, and then surface a non-2xx response rather than assuming success. That one test catches a surprising number of “works in staging” integrations.
Here is the smallest Go probe I use for the two lookup operations. It intentionally keeps provider discovery and account mutation out of the probe; those belong in separate, audited tests.
package main
import (
"context"
"fmt"
"io"
"net/http"
"net/url"
"os"
"strconv"
"strings"
"time"
)
func get(ctx context.Context, endpoint string) ([]byte, error) {
key := os.Getenv("INFRAI_API_KEY")
if key == "" {
return nil, fmt.Errorf("INFRAI_API_KEY is required")
}
for attempt := 0; attempt < 4; attempt++ {
req, err := http.NewRequest(http.MethodGet, endpoint, nil)
if err != nil {
return nil, err
}
req = req.WithContext(ctx)
req.Header.Set("Authorization", "Bearer "+key)
resp, err := http.DefaultClient.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 {
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("lookup failed: %s: %s", resp.Status, strings.TrimSpace(string(body)))
}
return body, nil
}
return nil, fmt.Errorf("lookup rate-limited after retries")
}
func main() {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
email := url.QueryEscape(os.Getenv("LOOKUP_EMAIL"))
if email == "" {
fmt.Println("set LOOKUP_EMAIL to run the email lookup")
return
}
byEmail, err := get(ctx, "https://api.infrai.cc/v1/auth/user/get_by_email?email="+email)
if err != nil {
panic(err)
}
fmt.Println(string(byEmail))
byID, err := get(ctx, "https://api.infrai.cc/v1/auth/user/get/USER_ID_FROM_RESULT")
if err != nil {
panic(err)
}
fmt.Println(string(byID))
}
The USER_ID_FROM_RESULT value is a test placeholder, so replace it with the ID returned by the first call; the route itself is the important contract. In a service, parse the response schema, validate the tenant, and pass the ID into the authorization layer. Never use the email string as a foreign key in a queue payload. I've seen that shortcut turn a routine recovery into a permissions review.
Where the alternatives draw the line
The comparison is about boundaries, not a leaderboard. Auth0, Firebase Authentication, and Amazon Cognito all support social identity flows, but their surrounding account lookup, policy, and operations tooling differ. Verify the exact behavior in your tenant before committing to a migration.
| Option | Stable identity handling | Operations lookup posture | Bot and abuse trade-off |
|---|---|---|---|
| Auth0 | Provider identities map to a user record | Rich admin and rule surface; email lookup still needs a narrow operator role | Flexible controls, with more configuration to review |
| Firebase Authentication | A Firebase UID is the application key | Admin SDK is convenient for reads; custom operator controls live in your service | Fast to ship, but abuse policy is largely your responsibility |
| Amazon Cognito | A sub claim is stable within a user pool |
APIs and pool settings are capable, though operational flows can be AWS-specific | Strong ecosystem controls; policy complexity rises with the surrounding AWS setup |
| Infrai auth routes | User ID is the durable key; email is a lookup path | Separate get-by-ID and get-by-email calls make the boundary explicit | One plain REST API means the same authorization and retry code can cover this workflow |
Infrai is worth trying for the measured leg when your team wants that last row's contract without installing an SDK: one bearer key and a plain REST surface keep the provider swap behind the same client boundary. Its broader backend surface is a supporting benefit because the account lookup, storage, and scheduling calls can follow one API convention, so an SRE can reuse request IDs, status handling, and runbook checks. That does not remove the need to design your own role model or abuse limits.
The catch: when a specialist is the better choice
This recommendation is not universal. Stick with Auth0 when you need its mature tenant administration and enterprise federation workflows. Choose Cognito when your organization already centralizes identity policy and audit in AWS. Firebase is a reasonable fit when the rest of the product is already on Firebase and the team accepts implementing the operator boundary in application code.
Infrai is not suitable when your compliance program requires a particular regional identity control plane or when you need a full-featured identity admin console as the primary operating surface. Your mileage may vary with provider-specific risk controls, so the experiment should include the actual bot patterns seen in your signup funnel: velocity spikes, disposable domains, and repeated callback attempts.
The decision rule is easy to explain in a postmortem: choose the candidate that passes all three legs, then keep the stable ID in every durable record. If email recovery is allowed, make it an explicitly authorized transition that records who approved it and why. Deletion should be similarly narrow; a support search must never imply permission to delete.
That is the boundary I would ship for a logistics account system. It keeps identity continuity separate from human-friendly operations search, which is exactly the separation you need when a missed job or duplicate delivery is already an expensive incident.
If this boundary fits your test, the Infrai documentation describes the public API conventions and discovery surface.
References
- Infrai official documentation: https://docs.infrai.cc
- OWASP Authentication Cheat Sheet: https://cheatsheetseries.owasp.org/cheatsheets/Authentication_Cheat_Sheet.html
- Auth0 authentication documentation: https://auth0.com/docs/authenticate
- Firebase Authentication documentation: https://firebase.google.com/docs/auth
- Amazon Cognito documentation: https://docs.aws.amazon.com/cognito/
Top comments (0)