Migrating 225K Users from AWS Cognito to Auth0 Without Forcing a Single Logout
How we moved 225K+ users with $400M+ in fintech assets from AWS Cognito to Auth0 without forcing a password reset, breaking MFA, or interrupting active sessions. The lazy-migration pattern, the gotchas, and what I'd do differently.
If you're migrating an identity provider, the user-facing rule is non-negotiable: no one should know it happened.
At BlockFi, we moved 225,000+ users with $400M+ in assets from AWS Cognito to Auth0. Zero forced password resets. No MFA re-enrollment. No interrupted sessions. The migration ran for about ten weeks of active work after a much longer planning phase, and most users never noticed. Here is the playbook that got us there, including the parts that almost broke us.
Why migrate at all
Cognito is fine for most use cases. We did not move because Cognito was bad — we moved because the product needs outgrew it.
Specifically:
- Custom flows. Cognito's hosted UI and Lambda triggers got us 80% of the way to a custom signup, then made the last 20% painful. Auth0's Actions and Universal Login were a better fit for the multi-step compliance flows fintech requires.
- Operational ergonomics. Auth0's logging, dashboards, and rule debugging are meaningfully better for a team that's not full-time AWS-native.
- Compliance posture. Auth0's tenant separation, audit log retention, and SOC 2 evidence collection were a closer fit to what our auditors wanted to see.
None of these are the right reason on their own. Combined, they made the migration worth the cost.
The rule: no forced logouts
The first decision was the only one that mattered: whatever we did, users should not be forced through password reset or re-authentication during the migration.
This rules out the "big bang" approach where you export Cognito users, import them into Auth0, and require everyone to reset their password on next login. That works. It's also a customer-experience disaster for a fintech product where every interaction with the auth flow makes users wonder if their money is safe.
It also rules out exporting password hashes directly. Cognito uses SRP (Secure Remote Password), and the password verifier format is not directly compatible with Auth0's expected hash formats (bcrypt, scrypt, etc.). You cannot just move the hashes.
What it leaves: lazy migration via custom database connection.
The lazy-migration pattern
Auth0 supports a "custom database" connection where, on login, Auth0 calls a function you provide. That function can authenticate the user against any external system — including Cognito. On successful authentication, Auth0 imports the user into its own database.
The flow becomes:
- User attempts to log in via Auth0.
- Auth0 looks up the user in its own DB. Not found.
- Auth0 calls our custom database script with the email + password.
- Our script calls the Cognito API to authenticate the user via the standard Cognito flow.
- If Cognito authenticates successfully, our script returns a profile to Auth0.
- Auth0 imports the user, sets their password (now hashed by Auth0), and continues the login flow.
- Subsequent logins for that user hit Auth0's local DB directly — no Cognito round-trip.
Effectively, every user migrates themselves on their next login. Active users migrate fast. Dormant users migrate when they come back. We never force the issue.
Performance: the first login took roughly 200–400ms longer than the post-migration login (Cognito API round-trip). Acceptable for a one-time hit. Subsequent logins were faster than they had been on Cognito.
The MFA problem
This is the part that almost broke us.
MFA enrollment data does not transfer. If a user has TOTP set up in Cognito, that secret is in Cognito's vault and cannot be exported. If we did nothing, every MFA-enabled user would have to re-enroll their authenticator app on first Auth0 login.
That violates the no-forced-friction rule for the most security-conscious users — exactly the ones we least want to inconvenience.
The fix had two parts:
- During the lazy-migration call, after Cognito authenticated the user, we'd also call Cognito's API to check whether MFA was enabled and prompt for the TOTP code in the same request. If the user provided it, we knew the secret was valid for that user. We did not import it (we couldn't), but we marked the user as "MFA-required, not yet enrolled in Auth0."
- On their next Auth0 session, before issuing a token, Auth0 prompted them through a guided MFA enrollment in Auth0 itself. The user re-scans a QR with their authenticator app once. Then they're fully migrated.
This wasn't zero-friction — users with MFA hit a one-time enrollment screen — but it was bounded, explainable, and visibly framed as a security upgrade rather than a system failure.
If we'd skipped this design and just left MFA users to figure it out, we'd have flooded support with "I can't log in" tickets and spooked the security-conscious cohort. Plan for this on day one of any IDP migration.
The three things that almost broke us
Problem 1: Cognito API rate limits during peak
The custom database script calls Cognito on every first-login. We did not anticipate how many simultaneous first-logins we'd see during peak hours in the first week, and the Cognito API rate-limited us. Users got 500 errors. Support tickets spiked.
The fix: we added an in-memory token cache in the custom database script that batched verification requests and front-loaded a Cognito JWT verification step that didn't require an API call. We also requested a temporary rate-limit increase from AWS support. After that, no more 500s.
Lesson: the lazy-migration script is a high-traffic service for the duration of the migration. Capacity-plan it like one.
Problem 2: email canonicalization
Cognito stored some emails with mixed case. Auth0 lowercases on lookup. A small percentage of users (about 0.4%) had originally signed up as User@Example.com, which Cognito stored verbatim, and which Auth0's lookup couldn't find when they tried to log in as user@example.com.
The fix was a one-line normalization in the custom database script: lowercase the email before looking it up in Cognito. Should have been there from day one. We caught it in canary testing on day three of the rollout — late enough to embarrass me, early enough that nobody got locked out.
Lesson: assume the source IDP allowed inputs that the destination IDP doesn't, and write a normalization layer in your migration script.
Problem 3: session token mismatch on the front-end
Users who were already logged in via Cognito had a Cognito-issued JWT in their browser. The application checked that JWT on every API call. The day we cut over the auth provider, the format of newly-issued tokens changed. Existing tokens kept working until they expired, but mid-session token refreshes started failing because the front-end was calling the Cognito refresh endpoint, which still existed and still returned valid Cognito tokens, which the back-end now expected to be Auth0 tokens.
The fix: the back-end accepted both formats during the transition. We ran a middleware that checked both Cognito and Auth0 token signatures and let either pass for a defined cutover window (we picked 30 days — the lifetime of a refresh token). After 30 days, we hard-cut Cognito acceptance.
Lesson: any IDP migration with active sessions needs a dual-acceptance period on the resource server. Plan it explicitly. Don't assume it.
Rollout cadence
We did not flip the switch for everyone on day one.
- Week 1: 1% of new logins routed through Auth0 (lazy migration enabled).
- Week 2: 10%.
- Week 3: 50%.
- Week 4: 100% of new logins routed through Auth0.
- Weeks 5–10: Active users continue to migrate themselves. Tail of dormant users runs out slowly.
- Week 12: Cognito put into read-only mode. Users who hadn't logged in yet got a one-time "click here to confirm your account" email.
- Month 6: Cognito decommissioned.
The percentage rollout was managed by a feature flag in the auth-routing layer. At each step we'd watch login error rates, MFA enrollment rates, and customer support ticket volume. If anything spiked we'd roll back the percentage. We rolled back twice, both times to fix the issues described above.
What I'd do differently
If I were running this again from day one:
- Build the dual-acceptance layer first, before any traffic moves. We bolted it on under pressure. It should have been part of the v0 migration design.
- Test MFA enrollment with at least 50 internal users before any external rollout. The MFA path is the one that's most likely to surprise you.
- Capacity-plan the migration script as a production service. Don't assume "it's only running during the migration" means it can be best-effort.
- Build a "migration status dashboard" on day one. We had ad-hoc queries against the Auth0 management API. We should have had a real dashboard from the start showing migrated/total, MFA-enrolled/migrated, and error rates by category.
- Communicate proactively. Send users an email two weeks before the change saying "we're upgrading our login system, you'll see a slight visual change but otherwise nothing." This kills 70% of the support tickets.
The first 30 days of post-migration monitoring
The migration is not done when the cutover happens. The first 30 days afterward are when long-tail issues surface — users on rare configurations, MFA edge cases, and the occasional support ticket that turns out to be a real bug.
The monitoring you want in place from day one of the cutover:
- Login error rate, broken out by error type. A spike in any single category is signal.
- MFA enrollment completion rate. Should trend toward 100% within two weeks. If it stalls, your enrollment UX has a bug.
- Support ticket volume tagged "auth" or "login." Compare to the same period before migration. If it's up 2x, something's wrong; if it's up 1.2x, that's normal noise.
- P95 login latency. Should be flat or better than pre-migration. A regression here means your custom database script needs caching.
Set thresholds for each metric and decide in advance what triggers a rollback. Decisions made in advance under no pressure are dramatically better than the ones made at 2am with a Slack channel full of customers.
The takeaway
IDP migrations are one of those projects that look simple on a whiteboard and turn into ten-week trench warfare in execution. The reason is that authentication is the most stateful, most opinionated layer of your application — every other system depends on it being correct, and there is no graceful degradation. If auth is wrong for an hour, your product is down.
The lazy-migration pattern with a custom database connection is the right answer for almost any IDP-to-IDP move where the source has a callable auth API. The MFA problem and the dual-acceptance window are the two areas that will surprise you. Plan for them.
And the rule remains: no one should know it happened. If the success metric of an IDP migration is "users complain on Twitter," you've already lost. The success metric is silence.