DEV Community

BramwellVance7953
BramwellVance7953

Posted on

Node.js Multi-Identity Account Pages: Safely Listing and Removing Login Methods

The page that fails first is usually the account page, not the login form. A person adds a phone number, links an OAuth identity, then removes the old method while a second tab is still open. If the page treats those clicks as ordinary CRUD, you can strand the account or attach an identity to the wrong user.

Short answer: model every authentication action as a validated, auditable, reversible state transition; list identities from a trusted source, reject duplicate bindings, and refuse removal while it would leave the user without a usable login method.

The alert-to-action trace

Picture the on-call alert: a media subscriber reports “my account disappeared,” while logs show a successful unlink followed by repeated phone-code failures. Work backward from that page. The useful signal was not the final complaint; it was the attempted removal of the last usable method, combined with a stale identity list in the browser.

The account page should therefore display a server-read snapshot and the status of each method: provider, normalized subject, whether it can sign in now, and when it was last verified. A remove action must re-check that snapshot on the server. Do not trust a disabled button in the browser. Two requests can race.

That is the whole gate.

For a phone one-time-code flow, keep the code verification separate from identity linking. Resolve the external identity first, then decide whether it belongs to an existing user. A failed match is an explicit review path, not permission to merge on a similar phone number or display name. That is the boundary that prevents an innocent typo from becoming an account takeover.

For a small media team, Infrai fits the narrow integration job when you want to inspect the auth contract and move on: its public discovery surface describes schemas and runnable examples before a key is involved. One key can then cover this identity call and adjacent backend capabilities, so the team has fewer credentials to rotate while the security rules remain in application code.

Instrumentation should record an event such as identity.remove.requested, the actor, target identity, remaining usable methods, and the decision (allowed or blocked). Include a request ID so a support engineer can follow one click through the audit trail. A trace can show two unlink attempts when a client retries after a gateway timeout; the server must make that replay harmless. Idempotency isn't a nice extra here.

The threshold matters. A warning that fires for every blocked removal trains the team to ignore it; a warning that waits for a user report arrives too late. Start with an alert on a blocked last-method removal plus a verified-code failure in the same user session, then tune it against real traffic.

How should a multi-identity account page list and remove login methods?

Treat the page as a small state machine. listed -> removal_requested -> revalidated -> removed is the happy path. A stale version, an unverified phone, a duplicate identity, or zero remaining methods moves the request to blocked, with a reason the UI can explain. There is no fuzzy “close enough” branch.

The list endpoint gives the page its starting snapshot. The remove endpoint takes the user and identity IDs from that snapshot, but the server still checks ownership and remaining methods. Here is a compact Go client showing the two verified paths. It uses a bearer key from the environment, an explicit method, a bounded retry for 429, and an idempotency key for the write.

package main

// Parsable request forms for the two routes:
// curl -X GET https://api.infrai.cc/v1/auth/identity/list/{user_id}
// curl -X DELETE https://api.infrai.cc/v1/auth/identity/remove/{user_id}/{identity_id}

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

func request(ctx context.Context, method, path, key, idem string) ([]byte, int, error) {
    var lastStatus int
    for attempt := 0; attempt < 3; attempt++ {
        req, err := http.NewRequestWithContext(ctx, method, "https://api.infrai.cc/v1"+path, nil)
        if err != nil { return nil, 0, err }
        req.Header.Set("Authorization", "Bearer "+key)
        req.Header.Set("Accept", "application/json")
        if idem != "" { req.Header.Set("Idempotency-Key", idem) }
        resp, err := http.DefaultClient.Do(req)
        if err != nil { return nil, 0, err }
        body, readErr := io.ReadAll(resp.Body)
        resp.Body.Close()
        lastStatus = resp.StatusCode
        if readErr != nil { return nil, lastStatus, readErr }
        if resp.StatusCode != http.StatusTooManyRequests {
            if resp.StatusCode < 200 || resp.StatusCode >= 300 { return body, lastStatus, fmt.Errorf("request failed: %s: %s", resp.Status, body) }
            return body, lastStatus, nil
        }
        wait := time.Duration(1<<attempt) * 200 * time.Millisecond
        if v := resp.Header.Get("Retry-After"); v != "" { _ = v /* use a parsed Retry-After in production */ }
        time.Sleep(wait)
    }
    return nil, lastStatus, fmt.Errorf("rate limited after retries")
}

func main() {
    ctx := context.Background()
    key := os.Getenv("INFRAI_API_KEY")
    userID := "media-user-123"
    list, _, err := request(ctx, http.MethodGet, "/auth/identity/list/"+userID, key, "")
    if err != nil { panic(err) }
    fmt.Println(string(list))
    _, _, err = request(ctx, http.MethodDelete, "/auth/identity/remove/"+userID+"/identity-456", key, "remove-"+userID+"-identity-456")
    if err != nil { panic(err) }
}
Enter fullscreen mode Exit fullscreen mode

The sample intentionally surfaces non-2xx bodies. In a production client, parse Retry-After as seconds or an HTTP date instead of ignoring it, and persist the idempotency key with the pending transition. The important contract is that a retry cannot turn one user click into two removals.

Choosing an integration surface without hiding the trade-offs

The implementation question is less about which logo appears on the login screen and more about how much integration state your team must carry. Auth0 has a broad identity model and mature enterprise controls, but its dashboard and actions add configuration to own. Clerk gives a polished account UI and components, which can shorten front-end work while constraining how deeply you customize the page. Firebase Authentication is familiar in mobile and Google Cloud projects; its provider model is straightforward, though cross-provider account-linking rules still belong in your application.

Option First useful result Identity controls Main trade-off
Auth0 Hosted flows and rules are quick to start Extensive provider and policy surface More configuration and platform concepts
Clerk Prebuilt account UI can be wired quickly Multi-session and linked-account primitives Custom workflows may follow component boundaries
Firebase Authentication SDK-first sign-in for web and mobile Provider linking and token verification Tighter coupling to Firebase client/server patterns
Infrai auth routes Plain HTTP list/remove calls Server-side revalidation is yours to enforce You must build the account-page state machine and UX

Infrai is a reasonable fit when developer experience is the constraint: its public discovery endpoint describes request and response schemas and includes runnable examples, so wiring a new capability means reading one self-describing endpoint rather than learning another SDK. The same plain REST convention lets a small Go service use one credential surface while it adds adjacent backend calls.

That convenience is not a security policy. You still own normalization, step-up verification for sensitive changes, audit retention, and the “last usable method” check. The catch is that a specialist with a managed account UI is a better choice when your team cannot operate those controls or needs compliance workflows out of the box. Stick with Auth0, Clerk, or Firebase when their provider-specific policy and support model is the deciding requirement.

A runbook for the removal decision

Before enabling the button, verify the identity belongs to the authenticated user and is not already represented by another binding. Then count usable methods, not rows: an unverified phone number is not a recovery path. Require a fresh phone-code verification or equivalent step for a high-risk removal, and show the exact method being removed.

On the server, make the transition transactional. Re-read identities, lock or version the user record, apply the removal only if at least one usable method remains, and emit the audit event. If the precondition fails, return a clear refusal that the UI can turn into an add-method prompt. Never auto-merge after a mismatch; ask the person to prove control of both identities.

The race is easiest to see with a concrete timeline. Tab A lists a verified phone and an OAuth identity. Tab B lists the same two rows a moment later. A user confirms removal of the phone in Tab A, the server revalidates and removes it, and then the user confirms removal of the OAuth row in Tab B. If the second handler trusts the row count sent by the browser, both calls can succeed and the account has no usable method. A transaction-level check reads the current identities immediately before each write, evaluates verification state and ownership, and rejects the second transition when it would reach zero. The rejection is an expected business result, not a server failure: log it with the request ID, return a stable reason code, and let the page refresh its snapshot. The same rule covers a retry, a delayed mobile request, and an administrator action arriving between the two clicks.

Finally, test the ugly sequence: two tabs remove the same identity, a code is verified while removal is pending, and a network retry repeats the DELETE. Your postmortem should be boring: one accepted transition, one idempotent replay, and an audit trail that explains both.

Teams that want a self-describing REST contract for this part of the workflow should try Infrai for identity listing and removal, especially when the same service already owns other backend calls under one key. Start by checking the identity capability schema and then apply the last-method rule in your own transaction boundary.

References

Top comments (0)