Identity at Scale: Consolidating Four Login Systems Onto One Standard

Architecture17 min readPost 8 of 10

At tens of millions of users, the hard part of identity wasn't building a login system. It was that we already had four. Years of growth and acquisitions had left four different authentication mechanisms wired into the product: each minting its own tokens, each covering a different slice of the experience, and none of them fully following a standard. Every feature that touched sign-in had to be built four times and reconciled by hand. The job was to make it one, on a real standard, without logging anyone out.

Four front doors

None of this was anyone's plan. It was a byproduct of how the company grew. Organic features added one auth path, each acquisition brought another, and over a decade they built up into four mechanisms that overlapped without agreeing. They used different token formats, different session models, and different user stores. Each covered only the functions it was originally built for. One knew about guest flows, another about a particular brand's SSO, another about an acquired app's accounts. No single system had the whole picture.

The cost compounded steadily. Anything that touched identity had to be implemented in up to four places and kept in sync by coordination, which is the most expensive kind of synchronization there is. The security surface was four times larger than it needed to be, with four sets of token-handling and session code to audit. And any capability that required a single identity for a person, social login most obviously, was simply impossible, because there was no single identity to attach it to.

Standardize on protocols, not products

The first decision was to anchor on open standards before choosing any vendor. We standardized authentication on OIDC and token issuance and authorization on OAuth 2.0: Authorization Code flow with PKCE for the clients, short-lived signed JWT access tokens, ID tokens carrying only the claims the relying parties actually needed, rotating refresh tokens, and scopes for authorization decisions. To prove it end to end we built a thin, standards-conformant login and token service and ran the real flows through it: login, consent, issuance, validation, refresh. We did that before betting the platform on it.

Standards first, because a protocol is portable in a way a product never is. If every relying party integrates against OIDC, the identity provider underneath becomes an implementation detail you can change your mind about later. It also collapsed four bespoke security models into one that is well understood, documented, and externally reviewed. That matters a great deal when the thing you're standardizing is the front door to every account in the product.

Standards are a hedge against your own future decisions. Build to OIDC and the identity provider becomes a detail you're allowed to be wrong about.

Choosing the system of record

With the protocol fixed, the provider was a procurement decision rather than an ideological one, and we ran it that way. We evaluated the field, including Okta, Auth0, and Shibboleth, against the constraints that actually bit at our scale: cost at tens of millions of identities, where per-active-user pricing models break down; horizontal scaling and raw throughput on the auth path; and the specific feature set we needed around federation, custom flows, and token customization.

We chose AWS Cognito, which at the time was genuinely in its infancy, and partnered closely with the AWS team to harden the gaps as we adopted it. That was a deliberate trade: a less-finished service we could shape and run economically at this scale, over a more polished one whose pricing and ceilings didn't survive contact with a user base this size. Cognito became the system of record for identity and credentials, sitting behind the standard OIDC surface the rest of the platform had been built against.

A facade that spoke every dialect

We could not ask the entire estate to cut over to Cognito at once, and it would have been reckless to try. So we introduced an SDK and a facade, an anti-corruption layer for authentication, that plugged into the whole ecosystem. The facade understood each of the four legacy variations, normalized them, and translated to the standard target implementation. Relying parties kept calling what they had always called; behind the SDK, those calls were mapped onto OIDC flows and Cognito.

That single integration surface is what turned a single coordinated cutover into an incremental migration. There was one place to reason about behavior, one place to enforce the new security model, one place to instrument, and, most important, the freedom to move callers onto the standard on their own schedule instead of in one high-risk release.

The password hashes were a dialect of their own. Each of the four systems had stored credentials with a different hashing scheme, accreted from whatever was current when that system was built, so there was no single way to check a password. The facade absorbed that too. On a login it verified against whichever scheme that user's record actually used, and on success it rehashed the password into one standard and wrote it back, so every successful sign-in quietly moved another credential onto the common algorithm. Over time the old schemes emptied out on their own, without a mass reset and without anyone being locked out.

The standard we settled on was bcrypt at a work factor of 10, and the number was a deliberate balance rather than a default. Bcrypt's cost is exponential: each step up the factor doubles the work, so factor 10 is 1024 rounds of the key schedule and lands around 50 to 100 milliseconds per hash on the hardware we were running. Go higher and a leaked database is more expensive for an attacker to crack offline, which is good, but every login also burns more CPU and adds latency on a tier-zero path, which is not. At peak login volume, a marketing event or the Monday-morning rush, that per-hash cost is what decides whether the auth tier keeps up or falls behind. Factor 10 was strong enough to make offline cracking painful and cheap enough to sustain the login rate inside our latency budget, and because the cost is stored in the hash itself, it can be raised later as hardware gets faster without touching anyone who is already migrated.

Flipping the token

The real pivot was the token itself. We updated the SDK to stop minting legacy tokens and start minting the standard target tokens, and, just as important, we taught the shared infrastructure to accept them. The central session store and the shared single-sign-on cookie were updated to understand the target token format. During the transition the SDK could validate an incoming legacy token and exchange it for a standard one, so sessions that were already in flight never broke and no one was forced to sign in again.

Once the central storage and the shared cookie spoke the target token, every system was carrying the same standard, signed token, no matter which of the four doors a user had come through. Single sign-on stopped being four overlapping approximations and became one fact. The security mechanics underneath were deliberately conventional, because conventional is auditable: JWTs signed with rotating keys published through JWKS so relying parties validate locally without a network hop; short access-token lifetimes with rotating refresh tokens and reuse detection; PKCE on public clients; the SSO cookie scoped, HttpOnly, Secure, and SameSite, bound to the session; MFA available on the standard flow; and platform-level rate limiting, brute-force and credential-stuffing protection, and anomaly detection on the authentication path.

The token also had to carry a way home. Plenty of things still knew a user by their old identifier, so into the claims of every minted JWT we put a small legacy_ref claim: a pointer to the system and record the user had come from. It let anything that had not yet been moved resolve back to the original row without a lookup service in the middle, and it gave us a clean join key for reconciling the two worlds while both were live. It is a deliberately boring addition, one string in the payload, and it removed a whole class of "but this job still reads the old id" problems that would otherwise have blocked the cutover.

THE MINTED TOKEN, DECODED { "sub": "9f2c-user-uuid", "iss": "id.ourco.com", "aud": ["web","mobile","api"], "exp": 1699999999, "scope": "profile orders", "legacy_ref": "sys2:44192" "roles": ["member"] } Old user store system 2, record 44192 resolve the old row when a downstream system still needs it The standard claims are all a relying party needs. The one extra claim is a bridge back to the record the user came from, so nothing that still reads the old id is stranded mid-migration.
Standard claims are all a relying party needs. The one extra claim, a reference to the record the user came from, is what kept everything still tied to the old identifier working while the move was in progress.

Stateless by design, and why it scaled

The consolidation was the point, but the token model quietly fixed a scaling problem that had nothing to do with logins as such. The old mechanisms were stateful: a service that received a request had to call back to a central session store to ask who the caller was and what they were allowed to do. That is a network hop on the hot path of every single request, and the store on the other end of it is both a bottleneck and a single point of failure. When it slows down, everything that needs to know who the user is slows down with it.

A signed, self-contained JWT removes the hop. The token carries the identity and the claims, the SDK verifies the signature locally against a cached copy of the public keys from JWKS, and it reads everything it needs straight out of the payload. No call to a session store, no shared dependency in the middle of every request. The security has to be right for this to be safe, and that is exactly why the earlier choices matter: short token lifetimes so a leaked token is only useful briefly, rotating keys published through JWKS so validation stays local but keys can still be retired, and reuse detection on refresh tokens. Get those right and validation becomes a local, in-process operation that costs microseconds and scales flat with traffic instead of funnelling through one store.

BEFORE: ASK THE SERVER EVERY TIME Service has a token Session store central look up who is this a network hop on every request the store becomes the bottleneck and the single point of failure AFTER: THE TOKEN CARRIES THE ANSWER Service + SDK verify signature, read claims all in process JWKS keys cached, refreshed no per-request hop, no shared store scales flat with traffic, and a store outage no longer blocks auth
Moving validation into the caller took a network round trip off the hot path of every request. The token carries the answer, the SDK checks the signature locally, and no shared session store sits between a service and knowing who the user is.

What one token made possible

A single standard token everywhere changed the economics of the roadmap. Anything that touched identity got built once instead of four times and reconciled. The clearest payoff was social login. Letting someone sign in with their Google, Facebook, or Apple identity only works if that identity resolves to one account that behaves the same everywhere in the product. With four fragmented auth systems it was a non-starter. You would have had to onboard each social provider into each system and keep four mappings consistent forever. On the consolidated platform it became a federation configuration: the social providers federated in through OIDC into the unified identity, and a person's social login worked across the entire product because there was finally one identity for it to attach to. Social login shipped on top of the consolidation, not alongside it. The consolidation is what made it possible at all.

The profile became a graph

Once there was one identity per person, we could build something on top of it that had been impossible before: a single, dynamic profile that modeled the person as a graph rather than a row in a table. The unified identity sits at the center. Around it are the things that describe how someone actually uses the product: their preferences, the devices they sign in from, the brands they follow, the orders they have placed, the content they have looked at. Each of those is an edge, and each edge is a fact about the relationship rather than a column someone has to remember to update.

Modeling it as a graph is what makes the interesting questions cheap to answer. Recommendations stop being a batch job that guesses and become a walk over the edges: people who follow this brand also bought that, someone with these devices tends to view this next, this preference plus that order history points here. Because the identity is unified, the walk crosses every brand and surface at once instead of being trapped inside one app's slice of the person. Before consolidation there were up to four partial versions of each person and none of them connected, so this kind of profile could not exist. The identity work was the thing that made personalization possible, not a side effect of it.

ONE IDENTITY, MODELED AS A GRAPH prefers uses follows bought viewed Preferences Devices Brands Orders Content Person one identity Recommendations walk the edges: people like you, bought with, viewed next, across every brand at once before consolidation there were up to four partial versions of this person, and none of them connected
With one identity per person, the profile becomes a graph. Preferences, devices, brands and history are edges, and recommendations are a walk across them that spans every brand at once rather than one app's narrow view.

Persistent login, and stepping up when it matters

One identity per person also let us be smarter about what being logged in even means. We split it into two tiers. A persistent login keeps a long-lived, low-assurance session on a remembered device, so the product knows who you are the moment you arrive. That is what makes the personalization feel immediate: the graph profile above is only useful if there is a known person to attach it to, and persistent login is what supplies that person without making anyone sign in again to see a personalized home page, their saved cart, or where they left off.

Knowing who someone is, though, is not the same as being sure it is really them right now, and the sensitive actions demand the second kind of certainty. So for protected operations, editing a profile, changing a password, managing payment methods, or checking out, we required an elevated session: a fresh re-authentication that raises the assurance level just for that action. A remembered device is enough to browse and be recommended things, and never enough on its own to move money or change credentials. The step-up is short-lived and drops back to the persistent tier once the sensitive action is done, so the common case stays quick and the risk is concentrated exactly where it belongs.

TWO TIERS OF BEING LOGGED IN Persistent login remembered device, low assurance We know who you are, so: personalized home and recommendations saved cart and recently viewed pick up where you left off enough to be useful, never enough to move money or change credentials Step up password or MFA Elevated session freshly re-authenticated, high assurance Required before you can: edit the profile or change a password check out and pay manage addresses and payment methods short lived, and it drops back to persistent once the sensitive action is done
Persistent login keeps a low-assurance session so personalization always has a known person to work with. Anything that moves money or changes credentials forces a step-up to a fresh, high-assurance session, then drops back.

Migrating the whole estate without a logout

All of this had to happen while people were signed in, so the migration had no maintenance window and no acceptable amount of forced re-authentication. We moved every identity transparently, with zero downtime, using the same disciplined cutover we used elsewhere on the platform. Dual sync kept the legacy stores and Cognito in agreement in both directions through the transition. On-demand, just-in-time migration moved a user the first time they authenticated: verify against the legacy credential, materialize them in Cognito, mint the standard token. A background backfill swept the dormant tail. And a strangler-fig rollout behind feature flags routed authentication traffic from the legacy mechanisms to the target a cohort at a time, each ramp gated on health and instantly reversible.

We advanced on metrics, not on a calendar: migration coverage, reconciliation drift held near zero, and authentication success rate and latency inside their budgets under real traffic. A legacy door was only retired once its traffic had genuinely gone to zero. The non-functional bar here is higher than almost anywhere else in a system, because authentication is tier-zero. If login is down, everything is down. So it was designed for peak rather than average load, ran multi-AZ with cached JWKS and graceful degradation so a single dependency hiccup couldn't lock out the entire user base, kept token validation stateless and local for latency, made every migration step idempotent and reversible, and was audited and observable end to end, because the auth path is also the first place an attack shows up.

Keeping the reporting stack alive during the move

The users were only half the migration. The bigger consumer of the old identity data was not a person at all, it was the data warehouse. The existing Oracle database was the source that fed reporting and analytics, and a lot of the business ran on those numbers. If the reports had gone dark for a week during the cutover, that would have been its own outage, just a quieter one.

Dual sync is what kept them alive. Because every write landed in both Oracle and Cognito for the duration, the warehouse could keep reading its familiar source while records moved across underneath, cohort by cohort. Nothing downstream had to change on day one. As confidence grew we shifted the warehouse's reads over to the new source, and only once everything that depended on the old database had been repointed did Cognito become the single system of record and Oracle get retired. The reporting and analytics functions never noticed the ground moving under them, which is exactly the outcome you want.

MOVE THE RECORDS WITHOUT BREAKING THE REPORTS Oracle old source of record Cognito new source of record dual sync, both directions every write lands in both until the move is done Data warehouse reporting and analytics keep running reads here first then here Phase 1 Oracle is truth Phase 2 both in sync, records shifting cohort by cohort Phase 3 Cognito is truth
The data warehouse was the largest consumer of the old Oracle store. Dual sync let it keep reading its familiar source while records moved across, and only once every consumer had been repointed did Cognito become the single system of record.

The corner cases that decide whether it works

The architecture above is the easy part to describe. What actually decides whether a migration like this lands is the list of awkward cases that do not fit the happy path. These are the ones that bit, or that we designed for so they would not.

None of these is exotic. They are the predictable consequences of running two identity worlds at once, and the reason a migration like this is measured in careful months rather than a single brave weekend.

The point

You get roughly one chance to consolidate identity, and the only acceptable outcome is that no one notices it happened. Four front doors became one, on open standards, on infrastructure we could actually afford at this scale. The real dividend wasn't the cutover itself. It was that the day it finished, the interesting work, starting with social login and everything after it, turned from a four-team coordination project into a configuration change.