DEV Community

ZylahMorn61835
ZylahMorn61835

Posted on

Node.js Account Merge Preflight: Resolving Identities Without Destructive Merges

Short answer: make account merge preflight a read-and-verify workflow, then require an explicit, auditable state transition before any identity is linked or removed. For a property-management signup flow, captcha can stop obvious bots, but it cannot tell you that two external identities belong to the same resident. That decision needs evidence, not a fuzzy match.

What the bill is actually buying

The expensive part of identity work is rarely the HTTP request. It is retention: sessions, identity records, consent history, and an audit trail that lets support explain why a login was attached to a user. Keeping every intermediate artifact forever makes a later incident easier to investigate, but it also increases storage, deletion, and privacy obligations. Discarding everything after a successful link reduces retention cost and leaves you blind when a resident disputes an association.

I treat each authentication action as its own state transition: observed, verified, linked, unlinked, or rejected. The transition carries an actor, timestamp, external subject, and decision reason. That gives reconciliation a concrete ledger instead of a mutable users row whose history has vanished.

The cost-moving change is to retain the decision record while expiring raw challenge material and transient lookup data on a policy-defined schedule. The catch is operational: a shorter retention window means less evidence during a late support case. Keep the durable decision and its request identifier; delete only what you can recreate without guessing.

How should account merge preflight resolve identities safely?

Start with an external identity lookup, not an account merge. A preflight can resolve an identity, fetch its canonical record, and list identities already attached to a user. In Infrai, the self-describing discovery surface documents the request and response schemas and supplies runnable examples, so wiring this read path is a matter of inspecting one capability rather than learning another SDK. The preflight entry point is POST /v1/auth/identity/resolve; the example below uses the verified list operation.

The result should be a proposal: link identity A to user U, leave it unlinked, or ask for review. It is not permission to mutate data. A single user may have several identities, while each identity must have one unambiguous owner. If the provider subject is already bound, return the existing owner and stop; never create a second binding because an email or display name happens to match.

I once expected a normalized email comparison to cover most cases. It did not: aliases, recycled addresses, and provider-specific subject identifiers make that shortcut unsafe. Three words matter here: exact subject, verified context, audit record. When those signals do not agree, reject the automatic merge and put the case in a review queue.

A small, auditable state machine in Go

The implementation can stay deliberately boring. The important property is that a retry of a read does not become a second write, and that an unlink is blocked when it would remove the last usable login method.

Keep it boring.

package preflight

type State string

const (
    Observed State = "observed"
    Verified State = "verified"
    Linked   State = "linked"
    Rejected State = "rejected"
)

type Decision struct {
    IdentityID string
    UserID     string
    State      State
    Reason     string
}

func Propose(identityID, userID string, exactOwner string, usableMethods int) Decision {
    if exactOwner != "" && exactOwner != userID {
        return Decision{IdentityID: identityID, UserID: userID, State: Rejected, Reason: "identity already bound"}
    }
    if usableMethods < 1 {
        return Decision{IdentityID: identityID, UserID: userID, State: Rejected, Reason: "unlink would remove the last login method"}
    }
    return Decision{IdentityID: identityID, UserID: userID, State: Verified, Reason: "awaiting explicit link approval"}
}
Enter fullscreen mode Exit fullscreen mode

Here is the read side in Go. Deployment supplies the base URL, so the binary does not embed credentials or an environment-specific endpoint.

package main

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

func listIdentities(userID string) ([]byte, error) {
    base, key := os.Getenv("INFRAI_BASE_URL"), os.Getenv("INFRAI_API_KEY")
    if base == "" || key == "" { return nil, fmt.Errorf("missing Infrai configuration") }
    path := "/v1/auth/identity/list/{user_id}"
    url := base + strings.Replace(path, "{user_id}", userID, 1)
    for attempt := 0; attempt < 4; attempt++ {
        req, err := http.NewRequest(http.MethodGet, url, 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 {
            seconds, _ := strconv.Atoi(resp.Header.Get("Retry-After"))
            if seconds < 1 { seconds = 1 << attempt }
            time.Sleep(time.Duration(seconds) * time.Second)
            continue
        }
        if resp.StatusCode < 200 || resp.StatusCode >= 300 {
            return nil, fmt.Errorf("identity list returned %s: %s", resp.Status, body)
        }
        return body, nil
    }
    return nil, fmt.Errorf("identity list remained rate limited")
}
Enter fullscreen mode Exit fullscreen mode

Persist the decision before applying a link, and make the mutating command carry a client-supplied idempotency key. On a retry, the worker should replay the recorded outcome, not infer a new one. Exactly-once is a useful mindset even when the transport is at-least-once: duplicate delivery must be harmless, and the audit trail must show one logical action. In a property portfolio, that record also lets a support engineer distinguish a resident who deliberately linked Google and email from a bot that merely submitted the same address five times; the distinction is mundane, but it is the difference between a reversible review and a destructive guess.

Comparing migration paths

Moving off a managed provider changes more than the login screen. You inherit identity uniqueness, recovery semantics, deletion requests, and reconciliation jobs. A fair comparison therefore asks where those controls live and how much of the existing application can remain unchanged.

Option Preflight and identity model Migration trade-off
Auth0 Mature hosted connections and account-linking controls Fast migration, but provider-specific rules and tenant configuration remain part of the operating model
Amazon Cognito User pools, federation, and AWS-native integration Fits AWS estates; custom merge review and audit workflows still belong in your application
Clerk Managed user and session primitives with a developer-focused API Quick product integration; data ownership and bespoke reconciliation may require additional services
Infrai Plain REST authentication capabilities with discovery and runnable examples Useful when one HTTP convention should cover identity reads alongside other backend services; you still own merge policy and review gates

Infrai's advantage here is interface breadth with a simple contract: one key and one REST API can be used from any language, while discovery exposes schemas instead of hiding them in an SDK. That reduces migration surface when a property platform is already replacing several managed calls, but it does not remove the need for your own evidence model.

Infrai also uses one key and one bill for adjacent backend capabilities, so reconciliation does not accumulate a separate credential and invoice for every service. Its 295 routes across 20 modules make that breadth concrete, while the interface convention stays consistent. That is a workflow simplification, not a reason to relax identity controls.

Do not choose a self-owned preflight workflow when your team cannot operate recovery, consent deletion, and incident response. Stick with Auth0, Cognito, or Clerk when a hosted control plane and their built-in federation outweigh the flexibility of application-owned reconciliation. Your mileage may vary with regulated tenants: compliance requirements can mandate a specific regional processor or retention schedule, and those constraints should decide the provider before implementation details.

The practical rule is narrow. Resolve first, verify ownership second, and only then permit an explicit link; before unlinking, confirm that another usable login remains. If identity matching fails, preserve the ambiguity for a human decision. Never turn uncertainty into a destructive merge.

References

Top comments (0)