Authentication

Account Linking: When One Person Has Five Logins

The data model and security rules for linking social, SSO, and password identities to one user — automatic vs manual linking, the trusted-email trap, unlinking rules, and how to merge two accounts that were never meant to be separate.

Emilian GheoneaJuly 14, 20268 min read

Dana signs up with a password in March. In June she comes back, does not remember whether she had an account, and clicks "Continue with Google" — same email address. What should happen?

There are three possible answers, and every one of them is defensible in some products and catastrophic in others. Answer it wrong in the permissive direction and you have an account takeover vulnerability. Answer it wrong in the strict direction and you have two accounts for one human, a support ticket, and a user who is now convinced your product lost their data.

This is account linking, and it is one of those problems that looks like a small UX detail right up until it becomes a data-integrity incident.

The data model comes first

Almost every account linking disaster traces back to the same original sin: storing the identity provider's user ID directly on the users table.

-- The model that cannot be fixed later
users
  id, email, password_hash, google_id, github_id, microsoft_id, ...

This works for exactly as long as you have three providers and nobody needs two accounts at the same provider. Then someone asks for Apple sign-in, and you add a column. Then an enterprise customer wants two separate SAML connections, and columns stop working entirely.

The model that scales separates the person from the ways they prove they are that person:

users
  id, email, email_verified_at, name, created_at

identities                          -- one row per linked login method
  id, user_id, provider, provider_account_id,
  provider_email, email_verified_by_provider,
  linked_at, last_used_at
  UNIQUE (provider, provider_account_id)

credentials                         -- passwords, passkeys
  id, user_id, type, secret_hash, created_at

Three properties make this work. The UNIQUE (provider, provider_account_id) constraint guarantees one external identity maps to at most one user — the constraint that prevents the worst class of bug. The provider_email is stored separately from the user's email, because they drift and you need to know what the provider actually asserted. And email_verified_by_provider is recorded per identity, because — as we are about to see — whether the provider verified the address is the single most important input to the linking decision.

This is the same identity-versus-method separation that makes multi-tenant auth and enterprise SSO additive rather than a rewrite.

The three linking strategies

Never link

Every provider produces a separate account. [email protected] with a password and [email protected] via Google are two unrelated users.

This is the safest option and it is what many products ship by accident rather than by decision. It is genuinely correct for products where an email address is not a meaningful identifier of a person — but for most SaaS it produces the single most common auth support ticket in existence: "I logged in and all my data is gone." The data is not gone. It is in the other account. The user does not know there is another account.

If you choose this, you must at least detect the collision and tell the user: "An account already exists for this address with a different sign-in method. Sign in that way, then link Google from your settings."

Automatic linking on matching email

If the incoming provider asserts an email that matches an existing user, attach the identity to that user and sign them in. Seamless, invisible, and exactly what users expect.

It is also, done naively, an account takeover vulnerability — the one described below.

Manual linking with proof

Never link based on an email match alone. Instead, when a collision is detected, require the user to authenticate with the existing method first. Then, and only then, attach the new identity.

This is the correct default for anything with meaningful data behind it. The cost is one extra step in a relatively rare flow.

The trusted-email trap

Here is the attack that makes automatic linking dangerous, and it is much simpler than people expect.

The victim has an account with [email protected], protected by a strong password and MFA. The attacker registers an account at some OAuth provider — one that lets users set an email address without verifying it, or one whose verification can be bypassed — and sets that account's email to [email protected]. The attacker then clicks "Continue with Provider" on your application. Your application receives an email claim matching an existing user, links the identity automatically, and issues a session.

The attacker is now signed in as Dana. Her password was never guessed. Her MFA was never challenged. Your application accepted a third party's unverified assertion as proof of mailbox ownership.

Real, exploited instances of this have existed at large companies. The defense has three parts:

Check the verification claim, and know your providers. OIDC provides email_verified. If it is absent or false, the email claim proves nothing and must never drive linking. But the claim is only as trustworthy as the provider — some send email_verified: true for addresses they never verified, some omit it entirely, and some providers (Apple, notably) let users relay through a private address that has different semantics. Maintain an explicit per-provider trust decision in your configuration rather than trusting the claim generically.

Never link to an account whose own email is unverified. If the existing user never confirmed their address, matching on it is matching on an unproven string on both sides.

Require proof of the existing account for anything privileged. The safest rule, and the one worth defaulting to: automatic linking is acceptable only when both sides are verified and the existing account has no elevated state. If the account holds an active subscription, is an owner in an organization, or has MFA enrolled, require an explicit re-authentication. An account with MFA enrolled should never be linkable by a flow that bypasses MFA — that is the whole point of having enrolled it.

Linking initiated from settings

The other direction — a signed-in user clicking "Connect GitHub" — is safer, because the user has already authenticated. It still has sharp edges.

The identity may already belong to someone else. The user connects a GitHub account that is already linked to a different user in your system. The UNIQUE constraint saves you from corruption, but you have to decide what to tell them. Silently stealing the link from the other account is wrong. The correct response is a clear error: this GitHub account is already connected to another account; disconnect it there first.

Use a distinct redirect path for linking. The linking callback and the login callback do different things — one attaches an identity to the current session, the other creates or resumes one. Sharing a route invites the bug where a login callback attaches an identity to whoever happens to be signed in.

Bind the state to the session. The linking flow's state parameter must be tied to the initiating session, not just to a random value. Otherwise an attacker can trick a signed-in victim into completing a link to the attacker's provider account — the reverse takeover, where the attacker gains a permanent login path into the victim's account. This is CSRF against the linking endpoint, and it is why the state check described in how PKCE works matters here too.

Re-authenticate for sensitive links. Adding a new way to log in is a security-relevant change. It deserves the same step-up treatment as changing a password.

Unlinking

Easier, with two rules that are almost always missing.

Never leave an account with zero login methods. If the last identity is removed and no password or passkey exists, the user is locked out permanently. Block it, and explain why: "Set a password before disconnecting your only sign-in method."

Notify on unlink. Removing a login method is exactly what an attacker does after taking over an account — they detach the legitimate owner's provider so the owner cannot get back in. A notification to the address on file is what surfaces it.

There is a third, subtler rule for B2B: if the organization enforces SSO, an individual member should not be able to unlink the SSO identity that their access depends on. Enforcement policy overrides individual preference — see SSO enforcement for B2B.

Merging two accounts that already exist

Sometimes prevention failed, or the user genuinely had two accounts and now wants one. Merging is the hardest case because it is not an auth problem — it is a data problem that auth is asked to solve.

Prove ownership of both. Not one and then a claim about the other: an authenticated session on account A, plus a full authentication or a verified email round-trip on account B. Anything less and "merge" becomes "absorb someone else's account."

Then decide, per resource, what merging means. This is unavoidable product work and it cannot be generic:

  • Organization memberships — union them, but resolve role conflicts explicitly. Member in one, admin in the other: which wins? Take the higher role, or ask.
  • Subscriptions — two active paid plans cannot silently become one. This needs a billing decision and probably a proration, not a database migration.
  • Owned content — reassign user_id. Straightforward, unless uniqueness constraints collide.
  • Preferences and profile — pick a winner and say which.
  • Audit history — keep both, annotated with the merge. Never rewrite history to pretend the second account did not exist; that destroys the record precisely when someone might need it.

Make merges reversible for a window, or at minimum make them logged in enough detail to reconstruct. And soft-delete the absorbed account rather than dropping it — a foreign key you forgot about will surface eventually.

A default policy

For most B2B products, this is the policy that holds up:

  1. Separate users and identities tables from day one.
  2. Automatic linking only when the provider verified the email, your user's email is verified, the provider is on your trusted list, and the account has no MFA and no elevated state.
  3. Otherwise, detect the collision and route to "sign in with your existing method, then link."
  4. Settings-initiated linking is session-bound, state-protected, and behind step-up auth.
  5. Unlinking cannot leave zero login methods, and always notifies.
  6. Merging requires proof of both sides and an explicit per-resource policy.

Key takeaways

  • Model identities as rows, not columns. Provider IDs on the users table is the mistake you cannot migrate away from cheaply.
  • An email claim is only as good as the provider that made it. Trust email_verified per-provider, explicitly.
  • Never let a linking flow bypass MFA the user deliberately enrolled.
  • Settings-initiated linking needs a session-bound state or it is CSRF.
  • Unlinking must never orphan an account, and always notifies.
  • Merging is a product decision per resource, not a database operation.

EmbedAuth models users and identities separately by design, so password, OAuth, magic-link, and enterprise SSO logins attach to one identity — with linking gated on verified provider claims and step-up re-authentication rather than a bare email match.

Written by

Emilian Gheonea

Senior Blockchain & Full-Stack Software Engineer. I build EmbedAuth — an embeddable authentication platform for SaaS — and write about the auth problems most teams hit too late.