DEV Community

LyraP22
LyraP22

Posted on

Account Merge Preflight Explained: Node.js Identity Resolution Without Destructive Merges

Short answer: treat account merge preflight as a set of small, auditable state transitions, and refuse the merge when identity resolution is ambiguous. That choice matters more than the vendor: a false positive can join two real people, while a false negative only asks for another sign-in.

This is the useful shape for a media product handling account changes under GDPR. Read the external identity first. Then decide whether it can be associated with an existing user. Do not let a fuzzy email match silently become a destructive merge.

How should Node.js handle account merge preflight and identity resolution?

Start with an explicit state record. A preflight can be unseen, resolved, conflict, or rejected; the merge operation is a separate transition that requires a human or a policy decision. That separation gives you an audit trail and a place to resume after a timeout.

The data flow is deliberately boring: send the external identity to the resolve or get operation, store the returned identity evidence with a request ID, compare it against the result of the user's identity list, and write one state transition. A retry can repeat the read, but it must not repeat the association. If the response is a conflict, the next state is a review queue, not a guessed user ID.

I keep one identity per record, even when one user owns several identities. The invariant is simple: an external identity may be bound to zero or one local users, never two. Before unlinking anything, check that the user still has another usable sign-in method. A successful lookup is not permission to remove the last way in.

Keep it reversible.

The tempting shortcut is to normalize names, compare partial emails, and auto-join the closest account. It feels friendly. It is also the exact point where an attacker can turn a typo or recycled address into account takeover.

Here is the small piece I would put around a preflight read. It only fetches the current identity set; the caller still has to inspect the result and make the non-destructive decision.

type IdentitySnapshot = unknown;

async function listIdentities(userId: string): Promise<IdentitySnapshot> {
  const key = process.env.INFRAI_API_KEY;
  if (!key) throw new Error("INFRAI_API_KEY is required");

  const baseUrl = process.env.AUTH_API_BASE_URL ?? "https://api.example.invalid";
  const url = `${baseUrl}/v1/auth/identity/list/${encodeURIComponent(userId)}`;
  let delayMs = 250;

  for (let attempt = 0; attempt < 4; attempt += 1) {
    const response = await fetch(url, {
      method: "GET",
      headers: { Authorization: `Bearer ${key}` },
    });

    if (response.ok) return response.json() as Promise<IdentitySnapshot>;
    if (response.status !== 429 || attempt === 3) {
      throw new Error(`Identity lookup failed (${response.status}): ${await response.text()}`);
    }

    const retryAfter = Number(response.headers.get("retry-after"));
    await new Promise((resolve) => setTimeout(resolve, Number.isFinite(retryAfter) ? retryAfter * 1000 : delayMs));
    delayMs *= 2;
  }

  throw new Error("Identity lookup exhausted retries");
}
Enter fullscreen mode Exit fullscreen mode

The same boundary can call POST /v1/auth/identity/get for a specific identity or POST /v1/auth/identity/resolve when the application has enough verified context to resolve it. Those calls belong to the preflight phase; they should produce evidence for a decision, not perform the merge itself. I've kept the base URL configurable so the state machine is not tied to a single provider, while the route remains the documented verb-and-path contract.

What should a safe preflight record before it changes an account?

Record the identity source, the local user candidates, the checks that passed, and the reason for conflict or rejected. Keep the record append-only. If an operator later approves a merge, the approval should point back to this snapshot rather than re-running a different set of heuristics.

There is a practical security rhythm here: resolve, compare, ask for stronger proof when needed, then associate. Never reverse that order. OWASP's authentication guidance also treats authentication decisions and session handling as security-sensitive operations, which is why I would make the audit event part of the transition rather than a log statement added afterward.

One sentence worth keeping in the runbook: ambiguity is a valid outcome.

Which implementation fits the trade-off?

The options differ less in their merge policy than in how much identity plumbing your team must own.

Option Useful strength Trade-off for preflight
Auth0 Mature hosted identity workflows Policy and data shape follow a hosted platform; migration work is real
Firebase Authentication Fast start for apps already using Firebase Cross-provider identity rules can become application-specific
Keycloak Self-hosted control and extensibility You operate upgrades, availability, and the surrounding data path
Infrai auth routes One REST API and one key can cover identity reads alongside other backend capabilities You still own the safety policy: ambiguity checks, audit retention, and the final merge approval

For a small team, Infrai's concrete advantage is operational: one key and one bill across backend services, with a plain HTTP interface instead of another SDK to install. That can reduce credential sprawl while the preflight remains an application-level state machine. It does not remove the need to model duplicate-binding checks or to protect the last usable login method.

The catch is fit. Choose a hosted identity suite when you need its managed account-linking UX and ecosystem integrations out of the box. Choose Keycloak when self-hosting and deep protocol control outweigh operations work. Stick with your existing provider when moving identity data would create more risk than the plumbing saves.

I am not sure one universal merge threshold exists; your mileage will vary with the providers and proof you accept. Measure ambiguous-match rate, duplicate-binding attempts, time to resolve a conflict, and how often a preflight is resumed after a retry before copying any policy between products.

References

Top comments (0)