DEV Community

HoratioFox1281
HoratioFox1281

Posted on

Node.js Identity Linking: Why Duplicate SaaS Accounts Happen (Practical Guide)

Short answer: link a social login to an existing B2B SaaS user only after proving control of both sign-in methods. Treat the external identity as an issuer-scoped subject, not as an email address. If no trusted link exists, pause for verification instead of silently merging or creating another account. That rule prevents most accidental duplicates while making the bot-resistant path explicit.

Situation Decision Abuse control Observable result
Known issuer and subject Sign in to the linked user Normal rate limits and session checks auth_link_lookup is hit
Unknown social identity, user is already signed in Offer an explicit link ceremony Recent authentication plus proof of the new method link_completed or a typed denial reason
Unknown social identity, matching email exists Do not merge from the email match alone Prove the existing account and the social login link_challenge_started
Unknown identity and no match Enter controlled signup Throttling, risk checks, and tenant policy signup_created or a typed rejection
Conflict with an existing link Stop No automatic reassignment link_conflict and an alertable counter

This field guide uses Google and GitHub sign-in as the concrete shape of the problem, but the boundary is generic. The application owns users, organizations, memberships, and link policy. An external provider supplies one authentication signal. Keep those responsibilities separate.

What does identity linking mean, and why do duplicate accounts happen?

A duplicate appears when the application answers two different questions with one field. “Which external principal just authenticated?” is an authentication question. “Which local user should receive this session?” is an account-correlation question. Email looks convenient as the bridge, but convenience is not proof that two credentials belong to one person. In practical terms, identity linking means storing an intentional relationship between a proven external identity and one local user.

Consider Maya, invited to northwind.example with maya@northwind.example. She first uses Google. A week later she chooses GitHub, where the callback also yields that address. Code that performs findUserByEmail() and then either signs in or inserts a user has hidden policy inside a database lookup. Depending on timing, normalization, missing attributes, and concurrent requests, it can merge too eagerly or create user_1042 and user_1187 for the same human. Their organization memberships, audit history, and recovery paths now diverge.

Bots make the weak branch expensive. They can replay signup attempts, rotate provider identities, probe whether an email already exists, and force repeated account creation. A bot check at the first screen does not repair a dangerous merge later. The correlation transition itself needs authentication, throttling, and a clear failure state.

Tiny distinction. Big blast radius.

Here is the whole path.

The useful mental model is a diagram in words: provider callback -> verified external identity -> link lookup -> local user -> organization membership -> session. Signup is a side branch after a lookup miss, not the default result of every successful OAuth callback. Linking is another guarded branch. Neither branch should be implicit. Duplicate accounts happen when code skips that link lookup, treats an email match as a completed proof, or lets two concurrent misses create separate local users. Once explained as state transitions, the fix becomes much easier to review: every arrow has a precondition, an outcome, and an event.

Pick a correlation policy before writing callbacks

There are three serious policies. Explicit linking asks a signed-in user to add another method and prove it. Pick this for established B2B accounts, privileged roles, and any system where an incorrect merge would expose tenant data. The extra interaction is deliberate friction. It also produces a clean audit event.

Verified-domain admission can help route a person toward an organization, but it should not silently establish that two identities are the same user. Pick it when administrators control membership policy for a corporate domain. Keep admission and identity linking as separate decisions: “may request access to Northwind” is weaker than “is Maya's existing account.”

New-account creation after every link miss is appropriate only when the product genuinely permits multiple independent accounts and makes that outcome obvious. It is easy to implement and hard to unwind. For a B2B workspace, it often creates support work because invitations and resources remain attached to the first local user.

I would choose explicit linking here.

This is a trade-off, not a claim that every extra click improves security. A recent local session plus a fresh provider callback gives the server two proofs to bind; an email match gives it one shared string. The former also creates a narrow place to apply throttling and alerting without punishing every ordinary sign-in.

Implement the invariant in Node.js

The central record needs a uniqueness rule on the provider identity pair. Call the columns issuer and subject; their names matter less than the invariant that the pair maps to at most one local user. Store organization membership elsewhere. That prevents a provider callback from deciding tenant authorization.

Here is a compact service boundary. The callback verifier is intentionally outside it: verification must finish before this function receives an identity. The repository methods represent transactions backed by database uniqueness constraints, not check-then-insert promises.

type ExternalIdentity = {
  issuer: string;
  subject: string;
};

type LinkResult =
  | { kind: "linked"; userId: string }
  | { kind: "already-linked"; userId: string }
  | { kind: "conflict" };

interface IdentityLinks {
  find(identity: ExternalIdentity): Promise<{ userId: string } | null>;
  insertUnique(
    userId: string,
    identity: ExternalIdentity,
  ): Promise<"inserted" | "duplicate">;
}

export async function linkIdentity(
  authenticatedUserId: string,
  identity: ExternalIdentity,
  links: IdentityLinks,
): Promise<LinkResult> {
  const current = await links.find(identity);

  if (current?.userId === authenticatedUserId) {
    return { kind: "already-linked", userId: authenticatedUserId };
  }

  if (current) return { kind: "conflict" };

  const inserted = await links.insertUnique(authenticatedUserId, identity);
  if (inserted === "inserted") {
    return { kind: "linked", userId: authenticatedUserId };
  }

  const winner = await links.find(identity);
  return winner?.userId === authenticatedUserId
    ? { kind: "already-linked", userId: authenticatedUserId }
    : { kind: "conflict" };
}
Enter fullscreen mode Exit fullscreen mode

The second lookup is not decoration.

Two callbacks can race between find and insertUnique. Only the database uniqueness constraint chooses one winner reliably; the service then translates that result into an ordinary idempotent response or a conflict. Never move a link from one user to another inside this code path. Reassignment needs a separate recovery process with stronger review because it changes who can reach data.

Do not pass an email into linkIdentity. That omission is useful. It makes an accidental findUserByEmail merge impossible at this boundary and forces email-based discovery, invitations, and domain policy into named flows. The callback handler can now have four explicit outcomes: linked sign-in, guarded link challenge, guarded signup, or denial.

Bot resistance belongs around each transition. Apply throttling to failed authentication and link challenges, require reauthentication before sensitive account changes, return generic public errors where account discovery is possible, and record the internal reason separately. OWASP's Authentication Cheat Sheet describes login throttling, generic authentication responses, reauthentication after risk events, and logging and monitoring failures. Those controls matter more here than a decorative challenge on one page.

Make correlation visible before it hurts

Start with events, then derive metrics. Each decision should emit one structured event with a correlation ID, a coarse provider key, the outcome, and the policy branch. Do not put access tokens, authorization codes, raw cookies, or other authentication secrets in logs. Hashing an email does not automatically make it safe or useful; prefer internal opaque identifiers when you need to join events.

type CorrelationOutcome =
  | "hit"
  | "challenge_started"
  | "signup_created"
  | "conflict"
  | "denied";

interface AuthDecisionEvent {
  event: "auth_correlation_decision";
  requestId: string;
  providerKey: "google" | "github";
  outcome: CorrelationOutcome;
  riskBand: "low" | "medium" | "high";
  userId?: string;
  organizationId?: string;
}

export function recordDecision(event: AuthDecisionEvent): void {
  process.stdout.write(`${JSON.stringify(event)}\n`);
}
Enter fullscreen mode Exit fullscreen mode

Count outcomes by provider and policy version. Watch the ratio of link conflicts to completed links, signup creation after an email-match discovery, challenge failures per network bucket, and repeated attempts against one local account. The exact alert threshold must come from normal traffic; an invented universal percentage would be noise. A sudden change after deployment deserves attention even when the absolute count is small.

Keep labels bounded.

providerKey, outcome, and a coarse riskBand work as metric dimensions. Email, subject, request ID, and user ID belong in controlled logs or traces, not metric labels with unbounded cardinality. This split keeps dashboards usable while preserving enough detail for an authorized investigation.

Test the state machine, not just the happy callback. Run two concurrent link attempts for the same issuer-subject pair. Verify that a matching email cannot create a session. Confirm that a conflict reveals no other account details. Exercise retries so an already completed link is idempotent. Then deploy behind a policy flag, compare decision-event distributions, and retain a direct rollback path for the new branching logic without removing the database constraint.

Limits worth keeping explicit

Linking cannot prove that two people sharing an inbox are one person. It cannot repair a compromised upstream account, and it does not replace tenant authorization, session protection, recovery review, or multifactor authentication. It answers one narrow question: which proven external identity is bound to which local user?

Keep the answer narrow. Use an issuer-scoped subject as the lookup key, demand proof on both sides before binding, make conflicts terminal in the callback path, and observe every transition. That gives Google and GitHub sign-in a predictable place in the account lifecycle without letting email coincidence or hostile traffic decide who owns a B2B SaaS account.

References

Top comments (0)