Account linking across email, social login, and enterprise SSO
For developers building or operating authentication flows where one person can arrive through passwordless/email, Google/GitHub-style social login, and SAML/OIDC enterprise SSO. This guide explains the data model, linking rules, failure modes, and implementation patterns you need to avoid duplicate users and unsafe merges.
TL;DR — Account linking is the process of attaching multiple login identities to one internal user record without accidentally merging two different humans. The safest default is: never auto-link solely on email for enterprise SSO or unverified social claims; link automatically only when the email is verified by a trusted provider and your policy allows it, otherwise require a signed-in linking step or admin approval. Reading time: ~7 min
What it is and where it sits
In an IAM architecture, account linking sits between authentication and your application user model. Its job is to answer: "this login assertion came from Google / magic link / Azure AD SAML — which internal user does it belong to?"
Without linking, you usually end up with one row per login method. That creates duplicate profiles, broken billing ownership, inconsistent audit logs, and support tickets like "I signed in with Google and my projects disappeared." Linking replaces the naive pattern of treating every successful upstream authentication as a new local account.
The components are usually:
- your app or API gateway
- an auth service or auth callback handler
- one or more upstream identity providers: email/passwordless, social OIDC, enterprise OIDC/SAML
- your user database, usually with separate
usersandidentitiestables - session issuance after the identity is resolved to one internal user
A typical request/data flow looks like this:
View as text
[Browser] ---- 1. GET /login/google ----> [App] [App] ---- 2. redirect with state ----> [Google OIDC] [Google OIDC] ---- 3. code callback ----> [App] [App] | 4. exchange code, fetch claims v [Auth Handler] [Auth Handler] ---- 5. lookup identity ----> [DB] [DB] ---- 6. no identity; find/link user ----> [Auth Handler] [Auth Handler] ---- 7. create session ----> [App] [App] ---- 8. Set-Cookie, 302 /dashboard ----> [Browser]
Where it lives in code:
- callback endpoint for OIDC/SAML/email-link completion
- user provisioning path for first login
- account settings path for explicit "link another sign-in method"
- admin tooling for conflict resolution and merge review
The core architectural decision is this: your canonical person is your local users.id, not the provider subject, not the email address. Provider identities are attached to that user.
How it actually works
Use two tables, not one:
users: your canonical account/person/tenant membership anchoridentities: one row per upstream identity, keyed by provider + provider subject
Typical columns:
users(id, primary_email, email_verified_at, status, created_at, ...)identities(id, user_id, provider, provider_subject, email_claim, email_verified, issuer, last_login_at, raw_profile_json, unique(provider, issuer, provider_subject))
One realistic end-to-end example
Scenario: Alice already has an account created via emailed magic link as alice@acme.com. Later she signs in with Google using the same email. Months later her company enables enterprise SSO and she signs in through their IdP with the same email.
Step 1: Alice signs in with email link the first time.
Your email flow proves control of the mailbox because she clicked a tokenized link you sent. You create:
users.id = u_123users.primary_email = alice@acme.comusers.email_verified_at = now()identities(provider=email_link, provider_subject=alice@acme.com, user_id=u_123)
Step 2: Alice clicks "Continue with Google".
Your callback handler exchanges the auth code and gets claims like:
{"iss":"https://accounts.google.com","sub":"109876543210","email":"alice@acme.com","email_verified":true}
Resolution logic should run in this order:
- Find exact identity by
(provider=google, issuer=https://accounts.google.com, provider_subject=109876543210). - If found, log in that
user_id. - If not found, consider linking by email only if policy allows auto-link for this provider and
email_verified=true. - If exactly one local user matches normalized email and is eligible, create a new
identitiesrow pointing to that user. - If zero users match, create a new user and attach the identity.
- If multiple users match, stop and require manual resolution.
For Alice, step 3 succeeds. You add identities(provider=google, ... user_id=u_123) and issue a session for u_123.
Step 3: Acme later enables enterprise SSO.
Alice signs in via SAML or enterprise OIDC. You receive an assertion with a stable subject and maybe an email claim. This is where teams get burned: enterprise admins can reassign emails, aliases exist, and some IdPs emit mutable identifiers. Your linking key should prefer the stable subject from the IdP (sub in OIDC, NameID or a configured immutable attribute in SAML), not email.
Suppose claims are:
{"iss":"https://login.microsoftonline.com/tenant-id/v2.0","sub":"A1B2C3D4","email":"alice@acme.com","email_verified":true}
If no enterprise identity row exists yet, you have choices:
- auto-link to existing
u_123because email matches a verified local email and your org policy trusts this IdP - require Alice to first sign in with an already-linked method, then initiate "link enterprise SSO"
- require an org admin to approve the first SSO link for that domain
For B2B apps, the second or third option is often safer. Why? Because domain ownership does not imply person identity continuity. If Acme recycles alice@acme.com to a new employee, auto-linking by email can hand over the old account.
The practical linking rules
A good default policy in 2026:
- Always trust exact existing identity match on provider subject.
- Auto-link email-link/passwordless to an existing local user only if the mailbox was just verified through your own flow.
- Auto-link social OIDC only if
email_verified=true, the provider is on an allowlist, and exactly one user has that normalized email. - Do not auto-link enterprise SSO by email unless the account is new or your tenant policy explicitly allows JIT linking by verified domain.
- Never auto-link when multiple users share the same email across tenants or historical imports.
- Record every link event in an audit log with actor, provider, claims snapshot hash, and reason code.
Failure shapes you should expect
Misconfigured callback URI usually shows up before linking logic even runs:
curl -I "https://app.example.com/auth/oidc/callback?code=abc&state=bad"
HTTP/2 400
content-type: application/json
cache-control: no-store
{"error":"invalid_state","error_description":"state mismatch or expired login transaction"}
Or from the provider during code exchange:
{"error":"invalid_grant","error_description":"redirect_uri mismatch"}
Conflict during linking should be explicit, not a generic 500:
{"error":"identity_conflict","message":"email alice@acme.com matches multiple users; auto-link refused","candidate_user_ids":["u_123","u_987"]}
When to use it (and when not to)
If you offer more than one sign-in method to the same product, you probably need account linking. The question is how much automation to allow.
| Scenario | Recommendation |
|---|---|
| Consumer app with email magic link + Google/Apple | Use linking. Auto-link on verified email for trusted social providers; keep explicit "link another method" in settings. |
| B2B SaaS with personal login first, enterprise SSO added later | Use linking, but disable blind auto-link for enterprise SSO. Prefer signed-in linking or admin-approved first link. |
| Internal app with only one corporate IdP | You probably do not need general account linking; map one enterprise identity to one local user. |
| Multi-tenant app where the same email can appear in multiple tenants | Use linking, but scope uniqueness and resolution by tenant/org membership. Global email-based linking is risky. |
| Legacy system with many duplicate users already imported | Do not turn on auto-linking first. Build merge tooling and conflict reports before enabling it. |
You probably do not need this if:
- there is exactly one login method and it will stay that way
- local users are disposable and own no durable data
- your app is pure downstream SSO with no local profile/billing/workspace ownership
Trade-offs
Every benefit here comes with operational cost.
- Fewer duplicate accounts benefit; cost: more complicated callback logic, conflict states, and support workflows.
- Better user experience benefit; cost: higher blast radius if you auto-link incorrectly and merge two humans into one account.
- Easier enterprise rollout benefit; cost: tenant-specific policy branches for JIT provisioning, domain verification, and reassigned email handling.
- Cleaner audit trail on one canonical user benefit; cost: schema design, migration work, and backfill of historical identities.
- More sign-in options benefit; cost: more provider outages, more edge cases around claim quality, and more integration tests.
Latency impact is usually small for OIDC because you already do a code exchange, but linking often adds one or two DB reads plus a transaction. The real cost is correctness, not milliseconds.
Lock-in risk depends on where linking lives. If you let an external auth layer own all linking semantics, migration gets harder because your app may not have enough provenance data to rebuild identity relationships. Keeping a local identities table reduces that risk.
In practice
Postgres schema for canonical users and linked identities
create table users (
id uuid primary key,
primary_email citext,
email_verified_at timestamptz,
status text not null default 'active',
created_at timestamptz not null default now()
);
create table identities (
id uuid primary key,
user_id uuid not null references users(id) on delete cascade,
provider text not null,
issuer text,
provider_subject text not null,
email_claim citext,
email_verified boolean not null default false,
last_login_at timestamptz,
raw_profile_json jsonb not null default '{}'::jsonb,
created_at timestamptz not null default now(),
unique (provider, issuer, provider_subject)
);
create index identities_user_id_idx on identities(user_id);
create index identities_email_claim_idx on identities(email_claim);
This gives you one canonical user with many identities. The gotcha: do not put a global unique constraint on email_claim; enterprise aliases, tenant scoping, and historical duplicates will break that assumption.
Linking transaction with conflict detection
begin;
-- lock candidate user rows by normalized email to avoid races
select id
from users
where lower(primary_email) = lower($1)
for update;
-- first, exact identity match wins
select user_id
from identities
where provider = $2
and coalesce(issuer, '') = coalesce($3, '')
and provider_subject = $4;
-- if no identity exists and policy allows, attach to exactly one user
insert into identities (
id, user_id, provider, issuer, provider_subject,
email_claim, email_verified, raw_profile_json
)
select gen_random_uuid(), u.id, $2, $3, $4, $1, $5, $6::jsonb
from users u
where lower(u.primary_email) = lower($1)
having count(*) = 1;
commit;
This pattern avoids duplicate identity creation under concurrent callbacks. The gotcha: having count(*) = 1 only works in a grouped query shape; many teams instead fetch candidate IDs in app code and then insert in the same transaction after locking.
Callback diagnostics with curl
curl -sS -D - \
-X POST "https://idp.example.com/oauth2/token" \
-H "content-type: application/x-www-form-urlencoded" \
--data-urlencode "grant_type=authorization_code" \
--data-urlencode "code=${CODE}" \
--data-urlencode "redirect_uri=https://app.example.com/auth/oidc/callback" \
--data-urlencode "client_id=${CLIENT_ID}" \
--data-urlencode "client_secret=${CLIENT_SECRET}"
This lets you verify whether a failure is in the provider exchange or your linking logic. The gotcha: many providers require the redirect_uri to match byte-for-byte what was used in the authorization request, including trailing slash.
Expected failure output on mismatch looks like:
HTTP/1.1 400 Bad Request
content-type: application/json
{"error":"invalid_grant","error_description":"redirect_uri mismatch"}
⚠️ If you are merging existing duplicate users, do not start by deleting rows from
users. Re-point foreign keys, preserve audit history, and run the merge in a transaction or a resumable job. A bad merge can orphan billing records, ownership rows, and API tokens.
Application-side policy example
{
"linking_policy": {
"auto_link_social_on_verified_email": true,
"trusted_social_providers": ["google", "apple", "github"],
"auto_link_enterprise_sso_on_email": false,
"require_admin_approval_for_first_enterprise_link": true,
"block_link_if_multiple_email_matches": true
}
}
This is the kind of explicit policy surface you want, even if implemented as environment variables or code constants. The gotcha: avoid a single global rule if you serve both consumer and enterprise tenants; enterprise orgs often need stricter first-link behavior.
Further reading
- OpenID Connect Core 1.0
- RFC 6749: The OAuth 2.0 Authorization Framework
- RFC 7636: Proof Key for Code Exchange by OAuth Public Clients
- SAML 2.0 Technical Overview
- OWASP Authentication Cheat Sheet
This article was written by an AI system and published pending human review. Verify anything you intend to act on.
Have a project in mind?
Get an instant AI price estimate for it, or talk directly to our team.
One email a month on what we learn building with AI