Linking an SSO Identity Without Creating a Second Account
When someone clicks "Sign in with Google," there are exactly three people they could be — and the app has to figure out which one before it does anything else.
CB### 1. The Real Problem Isn't the Login Button
Wiring up "Sign in with Google" is the easy 20%. Google hands you back an email address and a provider-issued ID, and from there it looks simple: create a user, log them in, done. The hard part shows up the second time someone uses it — and the third, and the tenth — because by then there are three completely different situations hiding behind the same button click, and your app has to tell them apart correctly every single time:
- This person has signed in with Google through your app before.
- This person already has an account — they signed up with a password, and this is the first time they've tried Google.
- This person has never touched your app before, in any form.
Get this wrong and you either create a duplicate account for someone who already has one, or you silently log someone into the wrong account. Neither is a small bug. So this piece is about that check — not about OAuth handshakes, which every library already does fine.
2. Case 1: "I've Used This Provider Before"
This is the easy case, and it's also the fast one. Once someone has linked Google to their account, DjangoPlay remembers the exact pair — which provider, and which ID that provider gave them — on the local user row. The very next Google login just needs to match that pair:
# users/services/identity_sso_onboarding_service.py
try:
user_identity = UserIdentity.objects.get(
sso_id=sso_id,
sso_provider=provider_code,
is_active=True,
deleted_at__isnull=True,
)
except UserIdentity.DoesNotExist:
return False # not this case — try the next one
# match found — just attach the social account and continue
sociallogin.account.user = user_identity
sociallogin.account.save()
sociallogin.user = user_identity
return TrueNotice what's missing here: no call to AuthX. The sso_id and sso_provider values are already sitting on the local row from the last time this identity was synced, so DjangoPlay can answer "have I seen this exact provider + ID before?" with one local database lookup. That's the payoff of keeping a local mirror at all — the common case doesn't need a network round trip.
3. Case 2: "I Have an Account, But I've Never Used Google Before"
This is the case that actually matters most, because it's the one where getting it wrong creates a duplicate account. Someone signed up months ago with an email and a password. Today they click "Sign in with Google" for the first time, using that same email address. DjangoPlay checks: is there already an account with this email?
# users/services/identity_sso_onboarding_service.py
try:
user_identity = UserIdentity.objects.get(
email__iexact=email,
is_active=True,
deleted_at__isnull=True,
)
except UserIdentity.DoesNotExist:
return False # no match by email either — this really is a new personFound one. Now comes the part that has to go through AuthX, not just the local database — because sso_id and sso_provider are credential-adjacent fields, and AuthX is the one place that's allowed to be the source of truth for those:
from users.services.authx_sync import update_identity_and_mirror
user_identity = update_identity_and_mirror(
user_identity,
user=user_identity,
sso_id=sso_id,
sso_provider=provider_code,
)update_identity_and_mirror calls AuthX first — PUT /internal/identities/{id} — to record the new SSO linkage on the authoritative record, and only updates the local row after AuthX confirms it. This is the actual "linking" moment. From this point forward, this person can log in with either their password or Google, and both routes land on the same account.
4. Case 3: "I've Genuinely Never Been Here Before"
If neither check above found anyone, this really is a new person. Now DjangoPlay needs to create the identity — and again, AuthX goes first:
from users.services.authx_sync import create_identity_and_mirror
user_identity = create_identity_and_mirror(
email=email,
username=username,
sso_id=sso_id,
sso_provider=provider_code,
is_active=True,
is_verified=True, # Google already verified this email for us
)create_identity_and_mirror calls AuthXClient.create_identity(), which becomes the real, authoritative identity row in AuthX's own database — no password, since this account was never given one — and only after that succeeds does DjangoPlay create its local mirror row and go on to build the rest of what a new member needs (a profile, default permissions, and so on). If someone signs up this way and later wants to add a password, that's a separate, deliberate step — not something that happens by accident.
5. Why Provider and ID Have to Be Checked Together
One more thing worth explaining simply, because it trips people up: a provider ID by itself isn't enough to identify anyone. Google's ID for a user and Apple's ID for a completely different user could, in theory, end up looking the same as a raw string — they come from two totally separate systems that have no idea the other exists. So the real identity key isn't the ID alone, it's the pair: this provider, this ID. That's why every check above passes both sso_id and sso_provider together, never one without the other.
There's an honest wrinkle worth naming too: right now, both AuthX's own database and DjangoPlay's local mirror enforce uniqueness on the ID column by itself, not on the (provider, ID) pair as a combined rule. In practice that's safe today, because Google and Apple ID formats don't actually collide — but it's a good example of a design decision that's correct for the data you actually have, not a guarantee the database is enforcing for you. If a future provider ever issued IDs in a format that could overlap with an existing one, that assumption is the first thing worth revisiting.
What I'd Tell You If You're Building Something Similar
- There are always three cases with SSO, not one. Returning user, existing-account-first-time-SSO, and brand new. Design for all three from day one — retrofitting the second case later is where duplicate accounts come from.
- Check the fast case locally, but never let identity changes skip the authority. Reading a cached provider+ID pair locally is fine. Writing a new one always has to go through the system that owns identity.
- A composite identity needs both halves checked together, every time. A provider ID on its own means nothing — it only means something paired with which provider issued it.
- A returning user shouldn't need a network call just to be recognized. If the common case in your system requires calling out to another service every single time, look for what you could be caching locally instead.