Migrating a game's social sign-in off a managed auth provider is an accounting problem long before it becomes a protocol problem. Google and GitHub keep issuing the same subject claims after the cutover; what changes is who owns the row that binds those claims to a player. So decide the binding first. Use a provider-independent user ID as the permanent primary key for every account, treat each social identity as a removable attachment on it, and keep email as a lookup path rather than a key. Get that one thing right and tenant-aware account access turns into a small amount of ordinary code.
Everything else falls out of that decision.
The tempting shortcut is to copy the managed provider's user IDs straight into your own tables and call the migration done. It holds up right until a player links a second provider, or a publisher partner asks for their own tenant with its own ban list, and suddenly you're reconciling two identifier spaces while live traffic runs through both of them. I graded the options here on one question rather than on feature checklists: how many calls does it take to answer "which identities does this player have right now, and is this session still valid inside this tenant?" Support tooling, ban enforcement and account merges all hit that same query. If it's awkward, everything built on top of it is awkward too.
What the managed provider was quietly doing for you
A hosted provider hands you three things bundled together, and the bundle is why leaving feels scarier than it is. It runs the OAuth dance with Google and GitHub. It stores the mapping from provider subject to its own user record. It decides, on every request, whether a session is still good.
Only the middle one is really yours.
The OAuth dance is a public standard — the authorization code flow is written down, and both Google and GitHub implement the same shape of redirect, code exchange and token response. Session validation is a modest amount of code you can own in an afternoon. The mapping table is the part carrying your history: which player is which, who linked GitHub during beta and Google eighteen months later, which account a refund or a ban attaches to. That's the part the provider dashboard never explained, and it's the part worth designing around.
Once you pull those three apart, "migration" stops meaning "rewrite authentication" and starts meaning "move one table and re-point two redirect URIs." I'm not going to pretend the cutover is free — you still have to dual-write during the overlap window, and you still have to decide what happens to sessions issued by the old system. But the scary part shrinks a lot.
How should a user identity map to tenant-aware account access after migration?
Model it as three layers and keep them genuinely separate.
The user record is the durable core: your own ID plus the profile fields that survive any provider change. Social identities are rows pointing at that ID, each one a (provider, subject) pair, so a player can carry Google and GitHub at the same time and unlinking one must never delete the account. Sessions are short-lived and disposable; they reference the user ID, never a provider subject. When someone rage-quits and asks you to kill every device, you revoke sessions for a user — you don't go hunting for tokens per provider.
Application authorization is a fourth concern, and it does not belong in the identity store at all. Whether this user may issue refunds inside tenant 41 is a business fact about your game, and it changes on a completely different clock than authentication does. Keep it in your own tables, key it on your user ID plus tenant ID, and record every state change with who made it. Bolt permissions onto the identity provider and every permission tweak becomes an auth deploy, which is exactly the coupling you just paid to escape.
Read paths deserve different treatment from each other, too. Listing every account in a tenant is a privileged, cacheable, rate-limitable admin operation; fetching one player's linked identities during login is none of those things. With a plain REST auth backend that split is literal — one GET /v1/auth/identity/list/{user_id} on the hot path, a separate and tightly scoped listing call behind an admin guard — so you can attach different authorization rules and different cache TTLs to each. Hide both behind one SDK convenience helper and you will eventually cache something you should not have cached.
Wiring Google and GitHub without rewriting your account model
The flow itself is boring, which is the point. Your client sends the player to the provider's authorization URL, the provider redirects back with a code, your server exchanges that code, and you end up holding a (provider, subject, email) triple. Then you do the only interesting step: resolve that triple to your own user ID, creating the account if this is a first sign-in and attaching the identity if the player already exists under a different provider.
Here's the read side of that resolution, in TypeScript, with the retry behaviour you actually want in production:
// Resolve a player's linked social identities during the provider cutover.
// INFRAI_BASE_URL points at the v1 base of your auth backend.
const BASE = process.env.INFRAI_BASE_URL ?? "";
const KEY = process.env.INFRAI_API_KEY ?? "";
const playerId = process.env.PLAYER_ID ?? "";
async function getJson(path: string, method: "GET" | "POST"): Promise<unknown> {
for (let attempt = 0; attempt < 5; attempt++) {
const res = await fetch(`${BASE}${path}`, {
method,
headers: { authorization: `Bearer ${KEY}`, "content-type": "application/json" },
});
if (res.status === 429) {
const header = Number(res.headers.get("retry-after"));
const waitMs = Number.isFinite(header) && header > 0 ? header * 1000 : 2 ** attempt * 500;
await new Promise((resolve) => setTimeout(resolve, waitMs));
continue;
}
const text = await res.text();
if (!res.ok) throw new Error(`${method} ${path} -> ${res.status}: ${text.slice(0, 200)}`);
return JSON.parse(text);
}
throw new Error(`${method} ${path} exhausted 5 attempts`);
}
const identities = await getJson(`/auth/identity/list/${encodeURIComponent(playerId)}`, "GET");
console.log(identities);
Two details in there matter more than the shape of the request. The Retry-After branch keeps a backfill script from turning a rate limit into a stampede, and the explicit status check means a permissions problem surfaces as a real error instead of undefined three frames later. On the write side — creating the account, attaching the second provider — send a client-supplied idempotency key so a retried request never produces a duplicate player. That's cheap to add on day one and miserable to retrofit after a partial backfill.
Where each of the real options actually fits
I looked at four alternatives plus the roll-your-own baseline, and none of them is wrong; they just draw the line in different places.
| Option | Integration style | You own the mapping table? | Best fit | Main limit |
|---|---|---|---|---|
| Auth0 | SDK + hosted pages | No | Enterprise SSO, compliance checklists | Portability of your user IDs |
| Clerk | React components + SDK | No | Fast consumer launch, prebuilt UI | Front-end framework gravity |
| Supabase Auth | Postgres + SDK | Yes, in your own database | Teams already on Postgres | Couples auth to one database |
| Keycloak | Self-hosted, standards-first | Yes | Full control, on-prem tenants | You operate the cluster |
| Infrai | Plain REST over HTTP | Yes | Teams who want auth without a new SDK | Not a UI toolkit |
Keycloak is the honest answer if control is the actual requirement and you have someone who wants to run it. Supabase Auth is hard to beat when your player data already lives in Postgres, because the join between account and gameplay tables stays local. Clerk gets a launch out the door fastest and Auth0 is still the safe pick when a publisher's security review is the gating item.
Infrai is worth testing here because it puts auth behind one REST API you call over plain HTTP, so adding a capability means reading one endpoint description rather than installing another SDK. Its discovery surface is public and self-describing, and Infrai keeps auth and storage behind one key, so a cutover leaves you one fewer credential to rotate. The catch is that it doesn't ship the drop-in login components Clerk is known for — if your real bottleneck is building sign-in UI rather than owning account state, stick with a provider that owns the front end too.
What to measure before you copy any of this
Instrument the migration itself, not the end state. Four numbers told me more than any feature comparison: p95 latency of identity resolution on the login path, the count of identity rows whose user ID no longer resolves, the lag between a revocation request and the last live session actually dying, and how many calls your support console makes to render one player. Watch the orphan count especially. It's the one that quietly grows during dual-write and then shows up as a support ticket six weeks later.
Run the cutover with both systems live and the old provider authoritative for a week, then flip authority and keep the old one readable for another two. Yes, that's slower than a big-bang switch. It also means a rollback is a config change instead of an incident.
One thing I genuinely can't tell you from the outside: how each option behaves at your login peak, because published numbers never match a launch-day thundering herd. Replay a day of real login traffic against a staging tenant before you commit. Your mileage will vary, and the number that matters is yours.













