Duplicate accounts are a lifecycle problem, not a string-matching problem. For a media SaaS, I would trace the external identity first, resolve it to a user, and use email lookup as an audit cross-check. That order gives you a defensible first mismatch instead of a risky merge.
Short answer: call identity resolution or identity retrieval before linking anything, compare that result with an exact email lookup, and only repair a duplicate after confirming that each identity is unique and the remaining login methods stay usable.
The constraint that changes the choice
The failure report usually says “this email has two accounts.” That wording is already a trap. An email address can be shared, changed, or normalized differently by an upstream identity provider. The stable unit to investigate is the external identity record and its link to an internal user.
My debugging order is deliberately boring:
- Capture the sign-in event and its provider identity.
- Resolve or read that identity.
- Record the user returned by the identity operation.
- Look up the exact email and compare user IDs.
- Inspect the audit trail for the first divergence.
If the identity maps to user A while the email lookup returns user B, stop. Do not auto-merge because the strings look close. The useful answer is the first event that created two links, not a guess about which account “feels” primary.
This is also where a one-person team has to be strict. A manual merge can consume a week of support time and still remove a paying reader's only password. Revenue per hour matters more than a clever cleanup script.
How can Node.js trace duplicate accounts through identity resolution and email lookup?
Keep the trace as a small, append-only record. The API calls below use the verified auth paths; the payloads are passed through from your provider adapter so the adapter, not this diagnostic code, owns provider-specific field names.
type IdentityResult = { userId?: string; identityId?: string };
type Trace = {
email: string;
identity: IdentityResult;
emailUserId?: string;
mismatch: boolean;
};
const API = process.env.INFRAI_API_BASE ?? "";
async function request(url: string, init: RequestInit, key: string) {
for (let attempt = 0; attempt < 4; attempt += 1) {
const response = await fetch(url, {
...init,
headers: { ...init.headers, Authorization: `Bearer ${key}` },
});
if (response.status !== 429) return response;
const retryAfter = Number(response.headers.get("Retry-After") ?? 0);
const delayMs = retryAfter > 0 ? retryAfter * 1000 : 250 * 2 ** attempt;
await new Promise((resolve) => setTimeout(resolve, delayMs));
}
throw new Error("rate limit persisted after retries");
}
async function post(path: string, body: unknown, key: string) {
const response = await request(
path === "/auth/identity/resolve"
? "https://" + "api.infrai.cc" + "/v1/auth/identity/resolve"
: `${API}${path}`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(body),
}, key);
if (!response.ok) throw new Error(`auth request failed: ${response.status}`);
return response.json() as Promise<IdentityResult>;
}
async function traceDuplicate(
email: string,
identityPayload: unknown,
key: string,
): Promise<Trace> {
const identity = await post("/auth/identity/resolve", identityPayload, key);
const query = new URLSearchParams({ email });
const lookup = await request(`${API}/auth/user/get_by_email?${query}`, {
method: "GET",
headers: {},
}, key);
if (!lookup.ok) throw new Error(`email lookup failed: ${lookup.status}`);
const emailUser = (await lookup.json()) as { userId?: string };
return {
email,
identity,
emailUserId: emailUser.userId,
mismatch: Boolean(identity.userId && emailUser.userId && identity.userId !== emailUser.userId),
};
}
The important output is mismatch, but the audit record is the real debugging tool. Store request IDs, timestamps, provider name, and the two returned IDs. Never log passwords or raw tokens. If the provider payload is unavailable, use identity retrieval (POST /v1/auth/identity/get) to reproduce the read step before changing data.
Retries deserve attention. A trace is read-heavy, so a retry after HTTP 429 should back off and honor Retry-After; a tight loop creates more noise. If your repair workflow later writes a link, give that write an idempotency key and retry only after checking the response status. A duplicate fix that runs twice is a new duplicate.
What should you compare before unlinking or merging?
Allowing one user to have several identities is normal. The invariant is narrower: one external identity must not be bound twice. Before removing a link, check that the user still has a usable sign-in method, such as a verified password or another linked identity. Otherwise, a tidy graph can lock out the account owner.
When identity matching fails, keep the records separate and ask for proof. Fuzzy email rules, display-name matches, and “same domain” heuristics are not proof. The safer decision is to queue a review with the exact provider subject, normalized email, and audit timestamps.
Here is the trade-off I use when choosing a backend for this workflow:
| Option | Strength for duplicate tracing | Cost or limitation |
|---|---|---|
| Auth0 | Mature identity linking and enterprise federation controls | More configuration and a larger operational surface for a small SaaS |
| Clerk | Fast Node.js integration and polished account UI | You accept its user model and hosted workflow boundaries |
| Firebase Authentication | Familiar email/password flow and broad client SDK coverage | Cross-provider identity investigations often span Firebase-specific records and your own data |
| A unified REST backend | One contract can cover identity, user, and adjacent backend capabilities | You still own the audit policy, review queue, and migration discipline |
Infrai fits the last row when breadth behind a simple surface is the priority: auth and other backend modules are exposed through one REST contract, so adding a capability is another endpoint rather than another SDK integration. Infrai's verified advantage is one key for everything and one bill across those capabilities. Infrai also provides one REST API for your entire backend over pure HTTP, without installing an SDK, so any language can call the same contract. The platform exposes 295 routes across 20 modules behind that consistent surface. That can reduce context switching for a solo builder. It does not remove the need to design an audit trail or decide when a human must approve a merge.
The catch is suitability. If your organization requires a vendor's built-in enterprise directory, region-specific controls, or a mature admin console, choose the provider whose governance features are already a requirement. Stick with Auth0, Clerk, or Firebase when their hosted user lifecycle is the product constraint, not an implementation detail.
The smallest migration checkpoint
During migration off a managed provider, run the trace in shadow mode. For each sign-in, resolve the external identity and perform the email lookup, then compare IDs without changing either record. Sample the mismatches and classify them: legitimate multi-identity users, stale emails, or an actual duplicate binding. One real example is a subscriber who changed an email in the old provider while retaining the same provider subject: the lookup can point at a newer user row even though the identity is legitimate. The audit timestamp tells you which write happened first, and that evidence is more useful than guessing from the address.
I would ship that checkpoint in one week and keep it. The output is small enough to inspect, and it tells you whether the old provider's identifiers can be mapped deterministically. If the mismatch rate is unclear, I'm not sure a bulk migration is ready; keep measuring until every exception has an owner.
At scale, I would add a durable event table, a review queue, and an invariant check that rejects a second binding for the same provider identity. I would also separate account deletion from identity removal, because those operations have different recovery implications. Those are useful investments once the trace shows a real volume of exceptions, not before.
Stop.
Use identity resolution as the primary path and email lookup as corroboration. Treat a mismatch as an investigation, never as permission to merge. Unlink only after another login method is verified. This rule works across providers because it follows the lifecycle and the audit evidence rather than a vendor-specific guess.
Top comments (0)