Moving authentication in house is usually described as a code migration. It is not. The code is a weekend: providers, an adapter, a session strategy, the routes that guard your pages. The part that decides your plan, your timeline and your risk is much narrower than that.
Can you take your password hashes with you?
Everything else follows from the answer, and the answer is not the same everywhere. Here is what we found checking the documentation of the two providers people leave most often, on 10 August 2026.
The question that decides the plan
Account records export from everywhere: emails, names, provider identifiers, timestamps. Nobody holds those hostage. Passwords are different, because a hash is a security asset and providers treat it as one.
| Provider | Password hashes | How | Practical cost |
|---|---|---|---|
| Clerk | yes | admin downloads a CSV from the dashboard that includes hashed passwords | minutes, self-service |
| Auth0 | yes, on request | not in the dashboard export nor the Management API: a support ticket | days, and their docs say it is unavailable on the free tier and cannot be scheduled for a given date |
| A database you own | already yours | read the table | none |
That table is the honest measure of lock-in, and it is more useful than any price comparison. Not what you pay each month, but how many days and how many tickets stand between you and the credentials of the people who trusted you. On that measure Clerk does well, and it deserves to be said plainly: an admin can have the file before lunch.
Three paths come out of it. Pick one before writing any code.
Path A: you have the hashes
This is the clean case. Read the export, write the rows, done in one deploy.
Two details decide whether it works. The first is the algorithm: if the export is bcrypt in the standard format, your own verification works on it untouched, because bcrypt hashes carry their own salt and cost inside the string. Check that before planning anything else. If it is a different algorithm you need a verifier for that algorithm, or those accounts fall back to Path B.
The second is what the rows should look like on arrival, which nobody shows you. In a schema with users and linked accounts, the shape is this:
// One row per person, with the hash exactly as exported.
await prisma.user.create({
data: {
email: row.email.toLowerCase(),
name: row.name,
emailVerified: row.emailVerifiedAt, // keep it: re-verifying annoys everyone
passwordHash: row.passwordHash, // only for password accounts
// One row per external identity, so a returning OAuth user matches
// the person instead of creating a second account.
accounts: {
create: row.oauth.map((a) => ({
type: "oauth",
provider: a.provider, // "google", "github"
providerAccountId: a.externalId, // the provider's own user id
})),
},
},
})
The providerAccountId is the part people get wrong. It is the identifier the provider assigns, not the email. Store it and a returning Google user is recognised as the same person; skip it and they get a brand new account with an empty history, which is the migration failure your support inbox hears about.
One more thing about the export itself: it is a snapshot. Anyone who signs up between the export and the cutover exists on the old system and not on yours. Either script the export to run immediately before the switch, or accept a short window and reconcile the difference afterwards.
Path B: you cannot get them
When the hashes are unavailable, or the ticket takes longer than your patience, you do not need them at all. You run both systems for a while and let people migrate themselves.
The logic sits in your credentials provider and reads in one breath:
async function verify(email: string, password: string) {
const user = await prisma.user.findUnique({ where: { email } })
// Already migrated: our hash is the only thing we check.
if (user?.passwordHash) return verifyPassword(password, user.passwordHash)
// Not migrated yet: ask the old provider once, then never again.
const ok = await legacyProvider.verify(email, password)
if (!ok) return false
await prisma.user.update({
where: { email },
data: { passwordHash: await hashPassword(password) },
})
return true
}
The plaintext password exists in memory for exactly the length of that request, which is the same exposure a normal sign-in already has. Nothing is stored that was not going to be stored anyway.
What makes this approach comfortable is that it has an end. Every sign-in moves one person across, so the population drains on its own, and you can watch it: count the users with a null hash. When the curve flattens, usually after two or three months, turn off the fallback and send a password reset link to whoever is left. They were probably inactive anyway.
| Path | When it applies | What it takes |
|---|---|---|
| A. Import the hashes | the provider hands them over | one deploy, everyone keeps their password |
| B. Run both, migrate on sign-in | the hashes are out of reach or too slow to get | two or three months, and it drains on its own |
| C. Re-link through the identity provider | most of your users signed in with Google or GitHub | a configuration change, and password-only users get a reset |
Path C: the one most teams actually take
There is a shortcut, and it is worth knowing before you plan a data migration you may not need. If most of your users signed in with Google or GitHub, you do not have to move anything.
The team behind openstatus documented their move from a managed provider to Auth.js and answered the migration question with two words: they simply did not. They let account linking do the work. A returning user signs in with the same Google account, the same verified email arrives, and the system matches them to the existing person.
The setting that allows it has a deliberately alarming name:
Google({ allowDangerousEmailAccountLinking: true })
It is safe here for one specific reason, and only for that reason: Google and GitHub both verify that the person owns the email before telling you about it. Turn the same flag on for a provider that does not verify emails and you have handed over account takeover by signup.
The cost of this path is honest and small: users who only ever had a password are left behind. They get a reset link, they set a new password on your system, and they are in. For a product with mostly social sign-in, that is a rounding error, and it turns a data migration project into a configuration change.
The cutover, and what actually breaks
Whichever path you pick, the switch itself has the same three surprises.
Everyone gets logged out. Sessions are issued by whoever owns them, so when you stop trusting the old provider its sessions become meaningless. This is not a bug to prevent, it is a fact to plan around: make sure the page people land on is your sign-in screen and not a stack trace, and tell them in advance if you have the kind of users who write in.
Webhooks and background jobs keep talking to the old system. Anything that called the provider's API to look up a user needs to read your database instead. Grep for the SDK import rather than trusting your memory of where it was used.
The rollback window is short. Once people start setting passwords on your system, going back means throwing those away. Decide up front how long you keep the old tenant alive, and keep it read-only rather than deleting it on day one.
What we do not hand you
We ship the destination, not the moving van. The kit gives you self-hosted authentication with OAuth, magic links, email and password with reset and session revocation, and the schema the snippets above write into. It does not ship an import script, because the export format is different for every provider and a script that pretends otherwise would fail in the one place you cannot afford it.
What is here is the part that is the same for everyone: the shape of the tables, a credentials provider you can put a fallback inside, and a password module whose hashing you can point at an imported hash without changing anything.
Frequently asked questions
Can I export my users from a managed auth provider?
The account records, yes, everywhere. The password hashes are the part that differs, and it is the part that decides your plan. Clerk lets an admin download a CSV that includes hashed passwords from the dashboard. Auth0 does not put them in the dashboard export or the Management API: obtaining them goes through a support ticket, which their documentation says is unavailable on the free tier and cannot be scheduled for a specific date.
What if I cannot get the password hashes?
You run both systems for a while. On each sign-in attempt, try your own database first; if there is no local hash, verify the credentials against the old provider, and on success write your own hash and never ask them again. Users migrate themselves by logging in, and after a few months you disable the fallback and send a reset link to whoever is left.
Do I have to migrate passwords at all?
Often not. If most of your users signed in with Google or GitHub, you can let account linking do the work: the same verified email arriving from the same provider matches the existing person, and nothing needs to be copied. It is the path most teams actually take, and its cost is that password-only users are left behind and need a reset.
Will everyone be logged out during the migration?
Yes. Sessions are issued by the system that owns them, so the moment you stop trusting the old provider its sessions stop meaning anything. Plan for every user to sign in once after the cutover, and make sure the path they land on is the sign-in page rather than an error.
Is bcrypt compatible between providers?
If both sides use bcrypt with the standard format, a hash verifies without being touched, which is what makes a one-shot import possible. If the export uses a different algorithm you need a verifier for it, or you fall back to the dual-run approach for those accounts. Check the algorithm in the export before planning anything else.
Before you plan the move
Two things are worth reading first. Self-hosted authentication in Next.js covers what you take on when the user table becomes yours, including the maintenance nobody itemises. And what authentication actually costs, both ways has the numbers on both sides, which matter here because the honest answer is sometimes that you should stay where you are.
If you have already decided, the authentication setup guide is the other end of the move: every provider, every environment variable, and the schema your imported rows will land in.