In this article
August 26, 2026
August 26, 2026

How to migrate from a custom auth system to a third-party provider

What is actually portable, how to import password hashes without forcing a reset, and how to cut over with a rollback you can trust.

Explore with AI
Open in ChatGPT
Open in Claude
Open in Perplexity

There is a piece of received wisdom about auth migrations that is no longer true: that you cannot bring your password hashes with you.

Follow it and you end up at one of two bad destinations. Either you force a password reset on your entire user base, which generates support load, looks like a breach to your users, and permanently loses a slice of your dormant accounts. Or you build a proxy that authenticates against your old system on first login, and in the worst version of that pattern you handle plaintext passwords in flight in order to rehash them somewhere new. That is a lot of risk to accept for a problem that, for most teams, an import solves.

Hash portability is worth checking before you design anything else, because the answer determines your entire strategy. This guide covers what to inventory first, what is genuinely portable and what is not, the three migration strategies and when each applies, and how to cut over with a rollback that is a flag rather than a deploy.

Two questions that decide the whole plan

Before comparing providers, answer these:

  1. Can you export your password hashes, and do you know the exact algorithm and parameters? Not "it is bcrypt". The algorithm, the work factor or iteration count, the digest, the salt, and whether the salt is stored separately or embedded in the hash string.
  2. How many enterprise SSO connections do you have, and do you control the callback endpoint they point at?

The first decides whether this is an import or a months-long dual-run. The second decides whether the SSO piece is a two-week task or a two-quarter one.

What "custom auth" actually contains

"We built our own auth" almost never means one thing. Write down which of these you own before you scope anything:

Component Typical implementation Portability
Users and credentials A users table with email and password hash Good, if you can identify the hash precisely
Sessions Signed cookies, server-side store, or your own JWTs None, every live session is affected at cutover
Password reset and email verification Token tables plus transactional email Replaced outright, nothing to migrate
Social logins OAuth client credentials plus account linking logic Good, but your linking rules need auditing
MFA enrollments TOTP secrets, recovery codes, phone numbers Poor, plan on re-enrollment
Enterprise SSO SAML or OIDC handling, one config per customer Depends entirely on who controls the callback
Authorization Roles, permissions, tenant membership Usually stays in your database, and should
Audit trail Login events, admin actions Append forward rather than backfill

Teams that scope this as "move the users table" and then discover forty SAML connections and a TOTP table miss their date by a quarter. The inventory is the cheapest part of the project and the one most often skipped.

Your password hashes are more portable than you think

This is the part where the common advice is out of date. A modern provider should accept an existing hash at import and let the user keep signing in with the password they already have.

WorkOS accepts all of these on the Create User API, or later through the Update User API:

Algorithm Format expected
bcrypt Hash string as stored
argon2 PHC string, variants argon2id, argon2d, argon2i, version 19
scrypt PHC string, with n, r, p, kl parameters
pbkdf2 PHC string, digest sha1, sha256, or sha512
firebase-scrypt PHC string, see the Firebase migration guide
ssha {SSHA}base64(sha1(password + salt) + salt)
ssha256 {SSHA256}base64(sha256(password + salt) + salt)

The PHC string format encodes the parameters alongside the digest, so mapping your parameters to PHC parameters correctly is the work:

  
$argon2id$v=19$m=65536,t=3,p=4$c29tZXNhbHQ$RdescudvJCsgt3ub+b+dWRWJTmaaJObG
$scrypt$v=1$n=16384,r=8,p=1,kl=64$Swhqd4iUYTtWfbCYIPeuMw$q7pfdBQMJujd5...
$pbkdf2$i=600000,d=sha256$T2ptRFh6MXhDQVh2SWZuUGdpQXBUTg$xXiyTisD7390...
  

Three details that decide whether an import succeeds quietly or fails loudly:

  • Weak hashes can be upgraded on the way in. pbkdf2 with a sha1 digest sits far below the current OWASP recommendation of 1,300,000 iterations. WorkOS re-hashes those with bcrypt at import: the stored credential gets stronger, and the user still signs in with their existing password. An old hash is a reason to migrate, not a reason to force a reset.
  • Salt position is not detectable at import. If your legacy code computed sha256(salt + password) rather than sha256(password + salt), the import returns success and the sign-ins fail silently later. Declare the salt position explicitly, and confirm that a real test account can sign in with its existing password before you run the full import.
  • MD5, unsalted SHA-1, and SHA-256 are not importable anywhere, and should not be. If that is what you have, you have a security finding rather than a migration problem, and a forced reset is the correct outcome. Say so internally in those terms.

For the import itself, batching and rate limiting are the usual cause of half-finished runs, so use tooling that handles both:

  
npx workos migrations import --csv users.csv
npx workos migrations wizard   # guided and interactive
  

Two operational notes. Disable webhook delivery for the duration of a bulk import, or your consumers absorb the entire volume of user.created and organization_membership.created events in one burst. And above roughly 200,000 users or organizations, have your provider coordinate a managed import rather than pushing it through the public API.

The three strategies, and when each one applies

Bulk import

Export users, create them with the provider including the existing hash, then switch your application to authenticate against the provider.

Use it when your hashes are importable, or when you are dropping passwords entirely for magic links, passkeys, or social login.

This is the only strategy that lets you decommission the old system quickly. The whole migration collapses to one deploy and one rollback point. If hash import is available to you, the other two strategies are usually solving a problem you do not have.

Lazy migration, also called trickle migration

The provider calls your legacy system on first sign-in, verifies the credential there, then writes the user into its own store. Over weeks, active users migrate themselves.

Use it when you genuinely cannot export hashes: an unsupported algorithm, a system you do not control, or a policy that forbids export.

Be clear-eyed about the costs. You run two systems at once. Your login script becomes a production dependency with its own failure modes. Inactive users never migrate, so you still need a bulk import or a forced reset to finish. And the variant that captures the plaintext password mid-flow to rehash it elsewhere expands your credential handling surface at exactly the moment you were trying to shrink it. If you are going to touch plaintext anyway, a reset flow is the safer trade.

Dual-write

Every new signup writes to both stores before cutover. Historical users still need a bulk import, but nobody is stranded in the gap between "import finished" and "cutover deployed".

Use it when you cannot pause signups and the window is measured in days.

The cost is two sources of truth for the duration. Email changes, password changes, and deletions all have to be mirrored, and a missed mirror surfaces later as a user who cannot sign in.

Most real migrations combine these: dual-write to stop the leak, bulk import for history, forced reset for the small tail that could not move.

Sessions, social logins, duplicates, and MFA

Sessions do not migrate. Your tokens were signed by your own code and the provider cannot validate them. Either expire everything at cutover and accept that all users sign in again, or run both validators in parallel during a transition window and accept a legacy session when the provider does not recognize the token. The second is more work and usually worth it, because a forced global sign-out is itself a support event.

Social logins usually survive. Configure the same OAuth client credentials with the provider and users keep signing in with Google or Microsoft. Matching is normally done on the email address, so the risk is not the OAuth flow, it is your linking history.

Duplicate accounts are the most common post-migration support ticket. If your custom system ever allowed the same person to exist twice, once via password and once via a social provider, or once per tenant, decide now which record wins and how you merge the other. Email uniqueness is enforced per environment by most providers, so the import surfaces every duplicate you have as a constraint violation. That is useful. Handle them deliberately before cutover instead of discovering them through users who lost their history.

MFA enrollments rarely survive. TOTP secrets are sometimes exportable and often not, and SMS factors depend on the provider's own telephony. Plan on re-enrollment, and communicate it clearly, because to a security-conscious user an unexpected re-enrollment prompt looks exactly like an attack.

Enterprise SSO, if you have it

If you support SAML or OIDC for enterprise customers, this drives your timeline, because the configuration you need to change sits inside your customers' identity providers. A custom implementation has no export API, so start by documenting per connection: your ACS URL and whether it is shared or unique, your SP entity ID, the NameID format you expect, whether request signing or response encryption is enabled and which keys are in use, which IdP attributes you depend on, and whether IdP-initiated SSO is in play.

Then the count decides the approach.

Under about fifteen connections: recreate each one and ask each customer's IT team to repoint their IdP. A self-serve admin portal turns this into a short task for the admin rather than a support thread.

Fifteen or more: transparent migration, with no IdP-side changes. Because you own the callback handler in a custom implementation, you do not need a proxy in front of anyone else's domain. You modify your existing callback to forward the IdP response to the provider, gated per organization:

  
async function handleSSOCallback(req: Request): Promise<Response> {
  const organizationId = extractOrganizationFromCallback(req);

  if (featureFlags.isEnabled('workos-sso-enabled', { organizationId })) {
    const { user } = await workos.userManagement.authenticateWithCode({
      code: req.query.code,
      clientId: process.env.WORKOS_CLIENT_ID!,
    });
    return handleWorkOSUser(user);
  }

  return handleExistingSSOCallback(req);   // not yet migrated
}
  

What makes this manageable is carrying your own connection identifier across. In WorkOS you store it as the connection's external_id, so your handler maps an incoming IdP response to the right connection with one lookup and no extra bookkeeping. Combined with a per-organization flag, you get per-customer rollout and per-customer rollback with no deploy.

Expect a residue that cannot move transparently: connections signing requests or encrypting responses with private keys held inside your own code, and non-standard NameID formats. Route those through a self-serve admin portal flow and treat them as a small manual set.

Signups during the migration window

Anyone who signs up after your import finishes and before your cutover deploys is invisible to both systems.

Disabling signups for the window is the honest option for a smaller application, and it makes the export a clean snapshot. Put it behind a flag so you can lift it the moment cutover completes.

Dual-writing avoids the downtime at the cost of consistency work. If you dual-write, your import has to tolerate users who already exist on the provider side, so decide upfront whether a conflict is an error or an update.

Cutover and rollback

Migrate a slice, not a system:

  1. Import into staging and verify sign-in across every credential type you support: password, each social provider, each SSO connection shape, MFA-enrolled users.
  2. Import to production without switching authentication. Nothing is user-visible yet.
  3. Route a small internal cohort through the provider behind a flag. Watch sign-in success rate rather than error rate, because the interesting failures end in a redirect rather than a 500.
  4. Expand by cohort. For B2B, organization by organization matches how your customers will report a problem.
  5. Keep the old path reachable until you have gone weeks with no traffic hitting it.

The flag is your rollback, which is why the flag must be the only thing deciding which path a request takes. If rolling back requires a deploy, you will hesitate during an incident, which is exactly when you need it.

Instrument both paths on one dashboard before you start: sign-in success rate by method, password reset volume, support tickets mentioning login, SSO session outcomes per connection. Password reset volume is your earliest signal that a hash import went wrong for a subset of users.

Define what "done" means before you start

Every migration has a tail of users who do not sign in during the window. If you have not decided in advance what happens to them, the migration never formally ends and the old system stays up indefinitely, which was the thing you were trying to avoid.

Pick a rule and write it down. For example: accounts with no sign-in in eighteen months are exported to cold storage and their credentials are dropped, accounts active within eighteen months are bulk imported with hashes, and anyone whose hash could not be imported gets a reset email at cutover plus a reminder at thirty days. The specifics matter less than having them, because this is the decision that lets you set a decommission date.

Decommissioning checklist

Do not delete anything until all of these hold:

  • Every SSO connection is active with at least one successful session through the provider
  • No traffic has reached your legacy sign-in, callback, or reset handlers for several weeks, verified in access logs rather than assumed
  • Sign-in success rate matches or beats your pre-migration baseline
  • Password reset volume is back to baseline
  • Duplicate accounts surfaced during import are all resolved
  • Your own SAML or OAuth library dependencies are removed, not merely unused
  • Customer-facing SSO setup docs point at the new flow
  • Any dual-write path is deleted, since a dormant dual-write is a future data inconsistency

Keep DNS resolution for old callback domains longer than you think you need to. Some customer IdP configs will still reference them.

Choosing a provider for a migration, not a greenfield build

The criteria that matter here are not the ones on a feature comparison page:

  • Does it accept your hash format at your parameters? Supporting argon2 but not at your memory and iteration settings still means a forced reset.
  • Can enterprise SSO connections move without customer IdP changes? This is the single largest swing factor in the timeline.
  • Is rollout controllable per tenant? Per-organization flags let you migrate your largest customer last.
  • What happens above your user count? Public import APIs have practical ceilings. Learn the number before you hit it.
  • Does your authorization model stay put? If moving auth also moves roles and permissions, you have two migrations, and you should sequence them rather than merge them.

WorkOS AuthKit covers users, sessions, social logins, and enterprise SSO behind one integration, with hash import across seven algorithms, a migrations CLI that handles batching, per-organization feature flags, and connection identifiers you can carry across from your existing setup. The migration guide has the full export and import walkthrough, and support coordinates managed imports for large user bases.

Whichever provider you pick, the plan matters more than the platform. Inventory everything, check hash portability before you design around a reset, treat enterprise SSO as its own project, define what done means, and make the rollback a flag rather than a deploy.