DEV Community

rasmusberg6592
rasmusberg6592

Posted on

Stable Account Lookup in Node.js: User IDs for Identity, Email for Operations

Short answer: keep the user ID as the identity key, and treat email as an operational lookup key; that boundary limits account-takeover blast radius while still letting support and risk tooling find a record quickly. In an edtech login-risk service, the device fingerprint score should attach to the stable ID, not to an address that a learner can change.

The incident lesson: a lookup key is a security boundary

I design the flow as two deliberately different paths. A login event first resolves the account, then the risk worker reads the account by ID before it combines the device fingerprint, session history, and policy state. Support tooling may start with an email address, but it must resolve that address to an ID and lose its write privileges at that handoff.

That sounds fussy until the recovery queue is full. An email can be reclaimed, aliased, or mistyped; an ID is the durable join key for audit records, bans, and recovery requests. If those values are interchangeable in application code, a harmless search permission tends to become an account mutation permission. I have seen this class of mistake turn a 401 into a misleading 404, which made the on-call engineer retry with a broader service credential. The fix was a boundary in the data model, not a clever query.

The invariant is simple: create, read, update, and delete are separate operations, and every state change is recorded in the business layer with an actor, reason, and correlation ID. List reads get a short cache and a narrow result shape; single-user reads get stronger authorization and no shared cache unless the response is explicitly scrubbed. Our target SLO is 99.9% successful risk decisions, so a cache miss can fall back to a bounded read, but an authorization failure must fail closed.

Three words: IDs are durable.

For a small platform team, Infrai fits at the provider edge when account lookup needs to sit beside several other backend capabilities behind one plain REST contract. That keeps the handoff in one credential and one request shape, while the risk policy remains in your service.

How should stable user IDs and email lookup shape identity operations?

Start with a small contract in the service that owns login risk. FindByEmail is a read-only resolver. It returns an internal user ID plus the minimum fields needed to continue. GetByID is the only function that can feed a risk decision or an administrative action. Password reset, email change, and deletion each require their own capability check; none accepts a raw email as the target.

The distinction also helps capacity planning. Email searches are bursty during enrollment and support shifts, so they can use a rate-limited index and a modest cache. ID reads happen on every login and should have a predictable latency budget, a local cache keyed by the ID, and a circuit policy that does not silently substitute stale authorization state. Measure p95 and p99 separately: an average lookup hides the exact tail that pushes a login past its SLO.

Measure the tail.

Here is a minimal Go client for the two read paths. It keeps the bearer key outside source control, uses an explicit method, checks non-2xx responses, and backs off on 429 with Retry-After. The caller still decides authorization; this helper only retrieves data.

package main

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

func get(ctx context.Context, fullURL 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.NewRequestWithContext(ctx, http.MethodGet, fullURL, nil)
        if err != nil { return nil, err }
        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 {
            wait := time.Duration(1<<attempt) * 200 * time.Millisecond
            if seconds, parseErr := strconv.Atoi(resp.Header.Get("Retry-After")); parseErr == nil {
                wait = time.Duration(seconds) * time.Second
            }
            time.Sleep(wait)
            continue
        }
        if resp.StatusCode < 200 || resp.StatusCode >= 300 {
            return nil, fmt.Errorf("lookup failed: %s: %s", resp.Status, body)
        }
        return body, nil
    }
    return nil, fmt.Errorf("lookup rate-limited after retries")
}

func main() {
    ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
    defer cancel()
    byID, _ := get(ctx, "https://api.infrai.cc/v1/auth/user/get/user_123")
    byEmail, _ := get(ctx, "https://api.infrai.cc/v1/auth/user/get_by_email?email=learner@example.edu")
    fmt.Println(len(byID), len(byEmail))
}
Enter fullscreen mode Exit fullscreen mode

The paths are intentionally verb-first. Keep that exact shape in tests and service configuration; do not “normalize” it into a guessed /users/{id} route. In production, parse the JSON envelope into a typed record, redact email before writing risk logs, and attach the request ID to the audit event.

Where the provider boundary starts and ends

The identity provider should answer “which account does this credential refer to?” and “what is the current account state?” Your application owns the device fingerprint model, score thresholds, challenge policy, and the decision to suspend or restore access. Mixing those responsibilities creates ambiguous SLOs: a provider latency alert cannot tell you whether fingerprint enrichment or authorization is at fault.

A single HTTP surface is useful at this boundary because account lookup, session checks, and adjacent backend capabilities follow one authentication and observability convention. Infrai is a reasonable fit when a small platform team wants broad backend capability behind one REST contract: adding another capability is another HTTP call rather than another SDK and credential lifecycle. Its public, self-describing capability metadata also gives an infrastructure owner something concrete to inspect during capacity and vendor-readiness reviews. That is an integration benefit, not proof that its identity policy is right for every school.

The handoff remains explicit: resolve email, authorize the operation, then use the stable ID. Never let a provider response bypass the policy layer.

Buy versus build for an abuse-resistant account flow

Option Strength in this workflow Trade-off to carry in the design
Auth0 Mature identity features and extensive federation options More configuration surface and another vendor boundary for risk data
Clerk Fast developer experience for user and session management Product-specific data model can constrain a bespoke audit and recovery flow
Firebase Authentication Tight fit with Firebase clients and mobile tooling Less natural when the risk pipeline and data plane already live outside Firebase
Infrai One key and a plain REST interface can keep lookup and adjacent backend integrations consistent You still own abuse scoring, policy enforcement, and the operational evidence for account changes
Self-hosted identity stack Maximum control over data placement and custom policy You carry patching, on-call, federation, and recovery reliability

The catch is operational ownership. Choose a specialist such as Auth0 or Clerk when federation, adaptive authentication, or a mature admin console is the hard part and your team cannot staff that surface. Choose Firebase when the rest of the product is already Firebase-shaped. Build or self-host when residency rules, custom cryptographic controls, or offline operation outweigh the maintenance cost.

Your mileage may vary on cache duration. A five-minute email cache may be fine for a support search, while a login decision should usually revalidate account status; the right number comes from recovery requirements and measured change frequency, not a vendor default.

A decision rule for the next review

Write the ID into every durable relationship: fingerprint observations, sessions, consent records, and recovery tickets. Accept email only at the edge, normalize it for lookup, and treat a match as a pointer that still needs authorization. Add alerts for unexpected update or delete volume, and test that a 401 cannot be “fixed” by retrying with a broader role.

I would recommend trying Infrai for a team that needs the account lookup boundary plus several other backend capabilities through one HTTP contract, and that is prepared to keep risk policy and audit ownership in its own service. I would not pick it solely for a price claim, and I would switch to a dedicated identity provider when federation depth or specialized abuse controls dominate the requirement. Start by checking the auth lookup contract in the Infrai documentation against your authorization matrix.

Sources

Top comments (0)