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.
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.
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.
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.
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.
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.
- Passwords you cannot read. You never hold anyone's plaintext password, so you cannot bulk-copy credentials into the new store. Just-in-time migration solves the active users: on first login you verify against the old hash, then rehash into the standard, bcrypt at cost 10. But the legacy hashes may use an algorithm the new provider does not natively support, so you carry a shim that can still verify the old scheme, and you keep it until the dormant tail has either logged in or been aged out and asked to reset.
- The same human in more than one system. Four accreted systems means one person may exist in two or three of them, signed up organically and again through an acquired app. Consolidation forces an identity resolution step: match on verified email, pick a canonical record, merge entitlements without dropping any, and decide what happens when two matched accounts have different passwords or different verified states. Getting the merge wrong either splits a person in two or, worse, joins two different people.
- Account linking is an attack surface. If someone signs in with Google using an email that already exists as a password account, silently linking them is convenient and also a well-known account-takeover path. We required a verification step before linking rather than trusting the email alone.
- Stateless tokens are hard to revoke. The whole scaling win is that nobody calls the server to validate a token, which means you cannot instantly kill one either. Short lifetimes keep the exposure small, and for the cases that have to be immediate, "log me out everywhere" and a compromised account, we kept a deny list checked at refresh time, so the stateless fast path stays fast and only the rare revoked token pays a cost.
- Key rotation can lock everyone out. Local validation depends on relying parties holding the current signing keys. Rotate a key and retire the old one before every cached JWKS has refreshed, and a wave of perfectly valid tokens starts failing. The fix is unglamorous: publish the new key well ahead, keep an overlap window longer than the longest cache, and only then retire the old one.
- Clocks that disagree. Once every service validates
explocally, a few seconds of clock skew between machines turns valid tokens into rejected ones at the edges. Disciplined time sync and a small, deliberate leeway on expiry checks keep it from becoming a mystery outage. - Thundering herd on the first login. Flip a cohort's flag on a Monday morning and the just-in-time migration path all fires at once, hammering the legacy store exactly when you least want it to. We ramped cohorts on a schedule, warmed and rate-limited the migration path, and watched the legacy store's load as a gate.
- Deletion has to reach everywhere. While two stores and a warehouse all hold the same person, a deletion or data-subject request has to fan out to all of them, not just the new one. A delete that only lands in Cognito while Oracle and the warehouse keep the record is a compliance problem wearing a green checkmark.
- Dual sync drifts if you let it. Writing to two stores means they can silently disagree. Continuous reconciliation with the drift held near zero, and an alert when it is not, is what turns dual sync from a liability into something you can actually trust to fall back on.
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.