Cognito to Auth0: Zero-Disruption Trickle Migration

Migrated a live user base from AWS Cognito to Auth0 to unlock enterprise SSO. Cognito had no SAML or OIDC support, and its hidden password hashes ruled out bulk import—so active users moved transparently via trickle migration, with a cutoff date handling the inactive long tail.

  • Auth0
  • Auth0 Actions
  • AWS Cognito
  • TypeScript
  • Node.js
  • SAML
  • OIDC

Cognito Was Showing Its Limits

Cognito works well for a certain profile of product: consumer-scale auth, password-and-social logins, and an AWS-native stack where you're happy to stay inside the ecosystem. For a long stretch, that described Cribl's needs well enough. But as the product moved upmarket and enterprise accounts started landing, a hard wall appeared.

Enterprise customers don't log in with an email and password they created themselves. They expect a product to appear in their IdP's app catalog—Okta, Azure AD, Google Workspace, whatever they already run—and for their employees to authenticate through the same SSO flow they use for everything else. For a security product, that expectation isn't optional. It's a procurement checkbox.

Cognito doesn't support SAML or OIDC SSO. Not in a constrained or difficult way—just not at all. There is no configuration path to give a customer's employees a federated login through their corporate IdP. That's a hard stop. For Cribl to close enterprise deals and actually onboard those customers, we had to move to an identity platform that could.

Auth0 was the clear choice: first-class SAML and OIDC connections, a polished self-serve setup flow that enterprise IT admins can operate without filing a support ticket, and an Actions runtime for the custom logic that any non-trivial auth implementation eventually needs. The question wasn't whether to move. It was how to get the existing user base across without breaking anything.

Why You Can't Just Ask Users to Reset Their Passwords

The first thing you'd check in any auth migration is whether you can export password hashes from the old system and import them into the new one. If you can, credential continuity is free—users keep their passwords and the migration is invisible at the infrastructure level. Cognito rules this out. AWS does not expose the hashing algorithm used for Cognito user pool credentials. There is no export format that includes usable hashes, and Auth0 has no way to verify a Cognito-format hash without knowing the algorithm. That single fact closes the door on silent bulk migration. Without the hashes, the only way to move credentials is to either re-collect them or ask users to create new ones.

That left one real choice on the table: ask users to reset their passwords. And while that's technically straightforward, the operational consequences made it a non-starter for active accounts. A coordinated mass-reset is an incident in slow motion. Users who receive an unexpected "reset your password" email from a security product don't always read the accompanying context. Some file support tickets asking if they've been compromised. Others ignore the email and discover they're locked out the next time they try to log in—often weeks later, when the reset link has long since expired. Enterprise IT admins who manage shared service accounts need to coordinate internally before touching credentials, and that coordination takes time that a reset deadline doesn't always accommodate.

It also damages trust in exactly the wrong direction for a product moving upmarket. "We migrated our auth system and some of your users got locked out" is not a conversation that helps an enterprise renewal. The goal was to make the migration invisible to active users—not as a nice-to-have, but as a trust constraint worth engineering around.

The Trickle Migration Pattern

Trickle migration—sometimes called lazy migration or on-demand migration—is a well-established pattern for exactly this class of problem. The premise is simple: instead of migrating users in bulk up front, you migrate them one at a time at the exact moment they prove they still exist and still have valid credentials. You migrate them when they log in.

The new system (Auth0) becomes the source of truth on day one. The old system (Cognito) becomes a fallback credential oracle for accounts that haven't been seen yet. When a login attempt arrives for a user who doesn't exist in Auth0, you reach back to Cognito to validate the submitted credentials. If Cognito says they're correct, you create the user in Auth0 right now—with the same credentials—and let the login succeed. From that moment, the user exists only in Auth0. Cognito's record for them is no longer authoritative.

The elegance of the pattern is that it leverages users themselves as the migration engine. Highly active users migrate on day one. Weekly users migrate within the first month. Occasional users trickle across over the following weeks. The open question is what to do with the long tail—accounts that simply never come back. For this migration, the answer was a cutoff date. Users who hadn't logged in by the deadline would need to reset their password on their next login. One prompt, one reset, and they'd be fully on Auth0. The cutoff kept decommissioning on a bounded timeline without disrupting anyone who was actively using the product.

How Auth0 Actions Make This Clean

The technical mechanism relies on Auth0's Login flow Actions—TypeScript functions that execute at defined points in an auth flow and can modify or extend its behavior. The specific hook you want is the database connection's custom login script, which runs when Auth0 needs to look up a user in a custom user store. When no record is found, instead of returning an error, you're given control.

Inside that script, you have access to the credentials the user submitted: their username and the password they just typed. The script issues a call to Cognito's authentication endpoint—specifically `InitiateAuth` with the `USER_PASSWORD_AUTH` flow—passing those credentials directly. If Cognito returns a successful session, you have confirmation that the credentials are legitimate.

At that point, the script does two things: it creates a new user record in Auth0 via the Management API, setting the password to the exact value the user just submitted, and it signals Auth0 to allow the login to succeed. The user gets their session. They never see an error. They have no idea that anything was different about this particular login compared to any other.

On every subsequent login, Auth0 finds the user immediately and the Cognito call never happens. The migration for that account is complete and permanent.

The New Login Flow: Email First

One of the features that made Auth0 the right choice was its support for identifier-first login—sometimes called domain discovery or Home Realm Discovery. Rather than presenting a combined email-and-password form, the login screen asks for an email address first. Auth0 checks whether that email's domain matches any configured SSO connection. If it does, the user is redirected directly to their corporate IdP: no password field, no Auth0 credential to manage. If it doesn't match any SSO connection, Auth0 presents the password field and proceeds with its own credential store.

This flow meant both populations of users—new enterprise SSO users and existing password users being migrated from Cognito—were handled by the same login screen without any friction. An Okta-authenticated enterprise employee types their work email and lands on their familiar IdP login. A long-standing Cribl user types their email, sees a password field, and logs in exactly as they always have. The trickle migration Action only fires on the password path, and only when the user doesn't yet exist in Auth0's credential store.

The SSO connections themselves were set up in Auth0 independently of the trickle migration—each enterprise customer's SAML or OIDC configuration was provisioned before their employees were given the new login URL. Once a connection was live, domain discovery handled the routing automatically.

The Implementation in Detail

The logic inside the login script follows a clear sequence. First, try to find the user in Auth0 by email. If they're found, authenticate against their Auth0 credentials and proceed normally—the trickle migration path is never entered. If no Auth0 record exists, call Cognito's auth endpoint with the submitted credentials.

If Cognito returns success, fetch the full user profile from Cognito using `AdminGetUser`, map the relevant attributes to Auth0's user schema (email, name, custom metadata, linked identifiers), and create the user in Auth0 via the Management API with the migrated attributes and the submitted password set explicitly. Then signal a successful login.

If Cognito returns an authentication failure—wrong password, account disabled, account not confirmed—surface the appropriate error to the user. Their credentials are wrong; Auth0 should behave exactly as Cognito would have.

If Cognito returns a UserNotFoundException, this is either a net-new user who has never been in Cognito, or a user whose email changed. Return a login-failed signal and let Auth0 handle it as a new account creation.

The password-setting step deserves emphasis. Auth0's Management API supports providing a `password` field when creating a user through the import or custom database path. This is what makes the migration transparent: the user's existing password is preserved exactly as-is. The next login—and every login after—works against Auth0's credential store without any re-enrollment step.

Edge Cases Worth Planning For

Password policy mismatches are the most common silent failure mode. Cognito and Auth0 can have different password complexity requirements. If Auth0's policy is more restrictive, creating a user with their existing Cognito password will fail validation silently—the user authenticated successfully against Cognito, but the Auth0 profile creation errors out and they see a login failure with no clear explanation. The fix is to relax Auth0's password policy to match or be more permissive than Cognito's during the migration window, then tighten it afterward with the confidence that all migrated passwords already comply.

Concurrent login attempts for the same unmigrated user create a race condition. If two requests hit the migration path at nearly the same time—a double-submit, a mobile app and browser both trying to refresh a session—both will see "user not found in Auth0" and both will attempt to create the user. The second creation attempt will return a conflict error from the Management API. The script needs to catch that conflict, recognize it means the user now exists, and proceed with a successful login rather than surfacing an error.

Rate limits on the Cognito API path need to be validated against your actual login volume before enabling the migration in production. During a traffic spike, every unmigrated user's login generates an outbound Cognito API call. At high concurrency, you can hit Cognito's `InitiateAuth` rate limits before the user pool has meaningfully drained. Implement exponential backoff in the script and model your peak login traffic against your Cognito service quota before going live.

SSO cutover timing for enterprise accounts needs explicit coordination. Once an enterprise customer's SSO connection is live in Auth0, domain discovery will start routing all logins for that domain to the IdP—bypassing the password path and the trickle migration entirely. If those users still have password accounts in Cognito that haven't been migrated yet, their Auth0 profiles should be pre-created before the SSO connection goes live, even if it's just a shell record with no credentials attached. Otherwise, their first SSO login will land in Auth0 as a brand-new account and lose any profile attributes or metadata that should have carried over from Cognito.

Watching the User Pool Drain

One of the more satisfying parts of the rollout was watching the metrics over the first few weeks. After the migration Action was deployed to production, a counter tracking Cognito-to-Auth0 migration events started incrementing on the first day. Every successful trickle migration showed up as a newly created profile in Auth0 and a decremented active-account count in Cognito.

The shape of the curve matched the expected login distribution: a steep drop in unmigrated accounts during the first few days as daily-active users moved across, followed by a longer, slower tail as weekly and monthly users logged in. The migration was fully organic—no outreach, no prompts, no scheduled maintenance window. Users just logged in, as they always had, and the system handled the rest.

Monitoring both systems in parallel was essential during this window. Auth0's log stream showed every trickle migration event. Cognito's CloudWatch metrics confirmed that `InitiateAuth` call volume was declining over time. If the Action had a bug that surfaced Cognito errors to end users, it would have shown up immediately as a spike in Auth0 login failures—the kind of signal that's easy to act on in the first hour rather than the first day.

Over the entire migration window, no authentication-related tickets were filed. No anomalies in login success rate. The migration ran silently in the background while users went about their normal workflows.

Decommissioning Cognito

Once Auth0's user pool represented all active accounts—confirmed by cross-referencing login activity across both systems over a trailing 90-day window—the migration Action was updated to skip the Cognito fallback entirely. A brief monitoring period followed to confirm that no legitimate logins were being sent to the Cognito path. Then the Action was removed.

After that, Cognito decommissioning was a straightforward infrastructure teardown: the user pool itself, the associated Lambda triggers, the IAM policies scoped to Cognito API access, the application configuration referencing Cognito endpoints, and the CloudWatch alarms watching Cognito-specific metrics. Each deletion was a reduction in operational complexity.

The net effect was a meaningful shrinkage of infrastructure surface area. One fewer managed service. One fewer set of credentials to rotate. One fewer source of auth-related on-call alerts. One fewer system whose behavior engineers needed to hold in their heads.

What This Unlocked

The immediate business impact was unblocking enterprise deals that had been stalling on SSO configuration. With Auth0 in place, setting up SAML or OIDC federation for a new enterprise customer went from an engineering project to a configuration exercise. Customers could plug in their IdP, work through Auth0's connection setup, and have their users authenticating through SSO within a single business day—without filing a support ticket or waiting for an engineering sprint.

The Auth0 Actions runtime also opened up a platform for auth customization that Cognito simply couldn't offer cleanly: post-login claim enrichment, custom metadata injection into tokens, step-up authentication prompts for sensitive operations, and organization-level policy enforcement. These weren't theoretical future features—they became the foundation for the authorization work that followed, including the move to fine-grained permissions across multi-workspace accounts.

Longer term, retiring Cognito reduced the cognitive overhead for the team responsible for auth infrastructure. There's a real cost to maintaining two systems in parallel, even temporarily: documentation, incident runbooks, onboarding materials, test environments. Collapsing that to a single authoritative system simplified everything downstream.

What I'd Do Differently

Plan the decommission criteria before the migration starts, not after. Defining what "done" looks like—specific thresholds for inactive accounts, a minimum quiet-period window, a sign-off checklist—keeps the project from drifting into indefinite "mostly migrated" limbo. Without an exit criterion, there's always a reason to leave the fallback path in place a little longer.

Run the migration Action against a shadow user pool before touching production. A non-production Cognito environment with realistic-but-fake user data lets you exercise every branch of the script under load: the race condition, the password policy edge case, the rate limit behavior at spike traffic. The investment is a few days of setup. The alternative is discovering those bugs in production logs at midnight.

Minimize forced resets—don't try to eliminate them entirely. The cutoff date was the right tradeoff: it protected every active user from disruption while still giving the migration a bounded timeline and a clean decommission date. The temptation is to treat zero resets as the goal, but that framing can lead to over-engineering for edge cases that affect a fraction of a percent of accounts. The real goal is zero disruption for users who are actively paying attention. The cutoff handles the rest.

Outcomes

  • Migrated all active users to Auth0 without a password reset; only accounts that hadn't logged in past the cutoff date required a one-time reset.
  • Unblocked enterprise SAML and OIDC federation, enabling bring-your-own-IdP support for large accounts.
  • Eliminated Cognito as an operational dependency, reducing infrastructure surface area and on-call burden.