DEV Community

ColbyHayes3521
ColbyHayes3521

Posted on

Node.js Account Merge Preflight — Resolve 3 Identity Conflicts Without Destructive Merges

Decision rule: an account merge preflight may gather evidence and authorize a later command, but it must never move credentials, sessions, balances, or audit history. For a fintech login flow, treat a device fingerprint as a reason to ask for stronger proof, not as proof that two identities belong to one person.

Preflight result Evidence Next action
ready Both accounts were freshly authenticated; no policy conflict Issue a short-lived, single-use merge authorization
challenge Ownership proof is stale or login risk is elevated Reauthenticate one or both accounts
blocked Tenant, legal-hold, or account-state rules conflict Stop and send the case to the appropriate review path

The recommendation is a read-only preflight endpoint backed by a merge-intent record. It resolves identifiers to internal account IDs, evaluates policy, and returns an expiring authorization token. A separate command consumes that token after repeating the important checks. This split costs a little implementation time, but it prevents a preview request, a double-click, or a retried network call from becoming an irreversible identity operation. For a one-person SaaS, that is good revenue-per-hour math: outsource ordinary authentication mechanics, but keep the merge policy small enough to audit and ship weekly.

Keep that boundary hard.

How should account merge preflight resolve identities without performing destructive merges?

Start by resolving each submitted identifier independently. Normalize email addresses according to your own documented account rules, not an improvised lowercase-everything shortcut, then map each identifier to an opaque internal ID. The response should not reveal whether an arbitrary email exists. Return the same public shape for missing, ineligible, and protected accounts; retain the precise reason in restricted logs. OWASP recommends generic authentication responses because response differences can enable account enumeration.

Then classify, don't mutate. Three conflicts cover the useful control flow without pretending every business rule is universal: same_account, when both inputs resolve to one account; policy_conflict, when ownership or account-state rules disallow a merge; and proof_required, when authentication evidence is insufficient. The first is a no-op, the second stops, and the third starts a challenge. None should copy rows or revoke a session.

The preflight output should bind the source account, destination account, authenticated principals, policy version, and an expiry to one opaque merge intent. Keep the lifetime short enough that session state cannot drift unnoticed. I'm not sure one duration fits every fintech product: five minutes may suit an interactive consumer flow, while a reviewed business account may need a different workflow. What resolves that uncertainty is your measured completion time plus the sensitivity of the assets being transferred, not a fashionable default.

Device fingerprints belong beside that decision, with narrow authority. A new device, material fingerprint change, or impossible combination of session signals can raise the risk score and trigger reauthentication. A familiar fingerprint cannot waive proof of control for either account. Fingerprints are probabilistic, can change, and may be shared. Keep them out of the identity equivalence rule.

Risk is not identity.

No silent merges.

The two criteria that decide the design

The first criterion is session security. OWASP recommends reauthentication after high-risk events and rotating session tokens after reauthentication. An account merge changes the security boundary: credentials, recovery channels, and possibly financial context may become reachable through a different login. Require recent authentication for both sides, verify the second account through its established channel, and rotate relevant sessions when the later merge command succeeds. Do not accept a source account ID supplied by the browser as ownership evidence.

The second criterion is friction. Challenging every merge twice with no regard for session age is secure in a narrow sense, but it trains people to rush through prompts and creates support work. Use the preflight to distinguish fresh proof from stale proof. If both accounts have recent, policy-compliant authentication and the risk score is below your challenge threshold, the user can confirm the proposed direction. If not, ask only for the missing proof. The catch is that adaptive friction needs explainable policy and careful telemetry; a tiny product without those controls should use the simpler rule and require reauthentication on every merge. Predictable friction beats mysterious friction.

These criteria pull against each other, so record the reason code that selected a challenge, not the raw device fingerprint. Operations should be able to answer why a user was challenged without collecting more fingerprint material than the risk system needs.

A TypeScript preflight that cannot merge anything

Make the boundary visible in the types. The preflight service below receives read-capable dependencies plus an intent writer that can create authorization metadata. It receives no repository capable of moving identities, credentials, or ledger data. That constraint is more valuable than a comment saying "dry run."

type AccountId = string & { readonly accountId: unique symbol };

type AccountSnapshot = {
  id: AccountId;
  tenantId: string;
  status: "active" | "restricted" | "closed";
  legalHold: boolean;
  authTime: number;
};

type PreflightResult =
  | { status: "ready"; intentToken: string; expiresAt: string }
  | { status: "challenge"; reason: "proof_required" }
  | { status: "blocked"; reason: "same_account" | "policy_conflict" };

interface AccountReader {
  resolve(identifier: string): Promise<AccountSnapshot | null>;
}

interface MergeIntentWriter {
  create(input: {
    sourceId: AccountId;
    destinationId: AccountId;
    policyVersion: string;
    expiresAt: Date;
  }): Promise<string>;
}

type RiskDecision = { requireProof: boolean };

export async function preflightMerge(
  sourceIdentifier: string,
  destinationIdentifier: string,
  risk: RiskDecision,
  accounts: AccountReader,
  intents: MergeIntentWriter,
  now = new Date(),
): Promise<PreflightResult> {
  const [source, destination] = await Promise.all([
    accounts.resolve(sourceIdentifier),
    accounts.resolve(destinationIdentifier),
  ]);

  // The public response deliberately avoids identifying which account was absent.
  if (!source || !destination) {
    return { status: "challenge", reason: "proof_required" };
  }

  if (source.id === destination.id) {
    return { status: "blocked", reason: "same_account" };
  }

  const policyConflict =
    source.tenantId !== destination.tenantId ||
    source.status !== "active" ||
    destination.status !== "active" ||
    source.legalHold ||
    destination.legalHold;

  if (policyConflict) {
    return { status: "blocked", reason: "policy_conflict" };
  }

  const maxAuthenticationAgeMs = 5 * 60 * 1000;
  const proofIsStale =
    now.getTime() - source.authTime > maxAuthenticationAgeMs ||
    now.getTime() - destination.authTime > maxAuthenticationAgeMs;

  if (risk.requireProof || proofIsStale) {
    return { status: "challenge", reason: "proof_required" };
  }

  const expiresAt = new Date(now.getTime() + 5 * 60 * 1000);
  const intentToken = await intents.create({
    sourceId: source.id,
    destinationId: destination.id,
    policyVersion: "merge-v3",
    expiresAt,
  });

  return { status: "ready", intentToken, expiresAt: expiresAt.toISOString() };
}
Enter fullscreen mode Exit fullscreen mode

The 5-minute value and merge-v3 label are explicit policy inputs in this example, not universal standards. In production, put them in versioned policy and test the boundary at exactly 299, 300, and 301 seconds. Also test reversed source and destination IDs, two identifiers that map to the same account, a legal hold on either side, concurrent preflights, an expired intent, and reuse of a consumed token.

The later command needs an idempotency key and a transaction boundary around the actual state change. It must load the accounts again, compare the stored policy version, verify that the intent is unused and unexpired, then atomically mark it consumed. Preflight is not a promise that conditions will remain true. It is a bounded authorization proposal.

Deployment, errors, and observability

Ship the preflight dark first: evaluate policy, emit internal outcomes, and leave the merge command unreachable from it. Compare expected and observed challenge rates by account state and risk bucket. Don't log submitted email addresses, raw fingerprint components, session tokens, or intent tokens. Useful fields are a correlation ID, hashed internal account references with a separately controlled key, policy version, coarse risk bucket, result, reason code, and latency.

Error handling must preserve the no-mutation contract. A dependency timeout should produce a generic retryable response and no intent. A partial read should not degrade into ready. Rate-limit repeated resolution attempts, keep public timing and wording as uniform as practical, and alert on spikes in policy_conflict, token reuse, and merge-command revalidation failures. The exact alert threshold depends on baseline traffic; your mileage may vary, and a fixed percentage without a baseline would be fake precision.

Roll out by cohort. Start with internal test identities that carry no real financial state, then a small eligible cohort, then widen only after reviewing challenge completion, abandonment, enumeration signals, and command revalidation. This is slower than wiring a "merge" button straight to a transaction. It is still cheaper than reconstructing which credential gained access to which account after an unsafe merge. Keep the audit trail append-only: record who initiated the intent, which authenticated sessions supplied proof, the direction proposed, the policy version, each decision reason, confirmation, execution, and the resulting canonical identity. Store references rather than sensitive fingerprint payloads, and make access to this trail narrower than access to ordinary product analytics. That audit record is part of the security design, not optional debugging residue.

When is the simpler runner-up better?

A mandatory reauthentication flow with no adaptive risk shortcut is the runner-up. Stick with it when merge volume is low, the team cannot explain or monitor a fingerprint-based score, or policy must be identical for every account. It creates more friction, but its behavior is easy to test, support, and audit. A solo founder can ship that path sooner and revisit adaptive challenges only after the support queue shows a real problem.

Account merging is not suitable at all when regulation, legal hold, tenant isolation, or ledger ownership requires identities to remain separate. Use account linking with explicit context switching, or a reviewed support process, instead. Linking also fits cases where users need two personas to remain independently recoverable. The limit is deliberate: a preflight can establish permission to run an allowed operation; it cannot make a forbidden operation safe.

The decision stays plain. Resolve both identities without disclosure, require current proof proportional to risk, issue a single-use authorization, and revalidate before any destructive command. Device fingerprints can increase friction. They cannot establish ownership.

References

Top comments (0)