theAuth is open-source auth for AI agents and humans. Star the repo on GitHub · Read the docs · Run the quickstart · theauth.dev

Every auth migration I have seen goes wrong in the same place. The code diff is easy. The users are hard.

You can swap a provider component in an afternoon. Then you find out that your 40,000 password hashes are bcrypt, the new system wants PBKDF2, and every signed-in user will land on a login page at 9 a.m. on a Monday. Nobody wrote that down in the plan.

This guide walks through moving an app from Auth0 or Clerk to theAuth, an open-source, self-hosted auth library for humans and AI agents (@glinr/theauth on npm). I will cover the inventory, the user export, mapping sessions and orgs, a cutover you can roll back, and a plain list of what does not migrate. A short note on Better Auth sits near the end.

I am going to be blunt about fit. For some teams, staying put is the right call. I will say which ones.

This is guide 4 of 8 in the theAuth guides. It stands on its own, so you can start right here. Read it any time you are coming from Auth0 or Clerk. Guide 1 shows the target setup.

Guide Title Read it when
1 Add login to an existing Next.js app You have an app with no auth yet
2 Passwordless login: passkeys, links, OTP You want to drop passwords or add 2FA
3 Multi-tenant SaaS auth: orgs, RBAC, SSO, SCIM You sell to teams and companies
4 Migrate from Auth0 or Clerk (this guide) You already run another provider
5 Give every AI agent its own identity You run AI agents and need to start somewhere
6 Cap agent spend and require human approval Your agents spend money or act on risky things
7 Secure an MCP server for production You expose tools over MCP
8 Build an audit trail for AI agent actions Someone will ask what your agents did

Building for people? Start at guide 1. Building for AI agents? Start at guide 5. Every guide links to the docs page for each concept it touches.

TL;DR

Question Short answer
Do passwords migrate? No. Hashes are bcrypt, theAuth verifies PBKDF2 only. You force a reset or rehash on next sign-in
Do sessions migrate? No. Users sign in once after cutover, unless you run both stacks side by side
Do OAuth tokens migrate? No. Users re-authorize each provider on first sign-in
Do users and org structure migrate? Yes, through SQL inserts into theAuth tables you run yourself
Is there a user import API? No. You write directly to the tables
Is there a hosted login page? No. You build the forms with hooks
Can I roll back? Yes, if you keep the old provider alive through one full refresh cycle

Prerequisites

You need four things before you start.

  1. A Postgres database (theAuth also supports SQLite, MySQL, and D1, but the examples here use Postgres)
  2. A TypeScript app. The code here uses a Next.js App Router layout, but adapters exist for other frameworks
  3. Admin access to your current provider so you can export users and edit OAuth app callback URLs
  4. A staging environment. Do not rehearse this in production

If you have never touched theAuth, skim the quickstart first. It takes ten minutes and gives you a working instance to point your migration script at.

Should you migrate at all?

Let me save you a weekend. Here are the cases where I would tell you to wait.

If you rely on Auth0's Universal Login, Guardian push MFA, or its bot and breached-password protection at the edge, theAuth does not replace those. It ships headless building blocks, not a hosted page. TOTP and passkeys exist. Push MFA does not.

If you lean hard on Clerk's hosted <SignIn /> and <SignUp /> components and its organization admin console, you will be rebuilding those screens. The organization model ports cleanly. The UI does not come with it.

If you are on an enterprise plan with a support SLA, remember that an open-source library has no 24/7 hotline. You are the operator now.

The cases where moving makes sense look different. Your monthly active user bill has outgrown the value. You are adding AI agents and want delegation, per-agent rate caps, and an MCP OAuth 2.1 server in the same system. Or you want your own cookie domain, token issuer, and audit trail. The comparison page lays out the structural differences against Clerk and Auth0 if you want the long version.

If you are still reading and still interested, good. On to the work.

Step 1: Inventory what you actually use

Before you write any theAuth code, list every place your app touches the old provider. I do this with a plain text search and a spreadsheet. The goal is a table with three columns: feature, where it lives, and whether theAuth covers it.

Search your repo for the provider imports. For Clerk that means @clerk/nextjs, @clerk/backend, clerkMiddleware, and any app/api/webhooks/clerk route. For Auth0 it means @auth0/nextjs-auth0, handleAuth, and any Management API calls.

Then check the dashboard for the things that never show up in code. Auth0 Rules and Actions. Clerk JWT templates. Social connections and their callback URLs. Machine-to-machine clients. Enterprise connections. Email templates.

Here is the mapping I use, pulled from the official migration pages.

Old concept theAuth counterpart
Auth0 tenant One createTheAuth instance per deployment
Clerk <ClerkProvider> <TheAuthProvider> from @glinr/theauth-react
Auth0 or Clerk social connection A provider inside the oauth plugin
Auth0 M2M client An agent with type: 'service'
Auth0 Action or Rule Your own route handler, or customClaims on the JWT session module
Clerk JWT template createJwtSessionModule with customClaims
Clerk or Auth0 Organizations org: {...} config plus the organization() plugin
Clerk webhooks Signed webhooks and event streaming from your own process
Tenant logs theauth.audit.query() and the audit export

Two rows in that table need a closer look, because they are where surprises hide.

First, Auth0 Actions. theAuth has no sign-in or sign-up hook. The hooks option exists, but it covers agents only: beforeAuthorize, afterAuthorize, beforeAgentCreate, afterAgentCreate, onAgentRevoke, and onViolation. If an Action blocks sign-ins from certain domains, you now write that check in your own handler around theauth.username.signIn. See the lifecycle hooks page for the exact shapes.

Second, Clerk webhooks. They fire from Clerk's internal events. They will not fire from theAuth. If you sync users to Stripe or a CRM from those webhooks, you port that logic to a webhook consumer or a call inside your own sign-up handler. The webhooks docs and the event streaming docs cover what theAuth can send.

Finish the inventory by counting. Count your users, then split them into password users and social-only users. Count your M2M clients or service integrations, and your orgs. Those numbers decide your cutover shape.

Step 2: Stand up theAuth next to the old provider

Do not remove anything yet. Install the package and create an instance in a new file.

npm install @glinr/theauth @glinr/theauth-nextjs @glinr/theauth-react 
// lib/theauth.ts import { createTheAuth } from '@glinr/theauth'; import { organization } from '@glinr/theauth/auth'; let instance: Awaited<ReturnType<typeof createTheAuth>> | null = null; export async function getTheAuth() { if (!instance) { instance = await createTheAuth({ database: { provider: 'postgres', url: process.env.DATABASE_URL!, }, secret: process.env.THEAUTH_SECRET!, baseUrl: process.env.AUTH_BASE_URL!, auth: { session: { secret: process.env.SESSION_SECRET! } }, username: { password: { minLength: 8 } }, org: {}, plugins: [organization()], }); } return instance; } 

Three things in that block matter.

The org: {} key is what makes theauth.org available for server-side calls. The organization() plugin on its own only adds the HTTP endpoints. People trip on this one, so I am saying it twice.

The password module is username-based. theAuth has no emailAndPassword switch. The username and password page explains the shape. In practice, you store the lowercased email as the username, and users keep signing in with their email address.

createTheAuth creates its own tables, all prefixed theauth_. Run it once against your database before you import anything. You do not create a users table yourself.

Mount the route handler

Clerk hides route handling inside middleware. theAuth gives you an explicit handler you can read.

// app/api/auth/[...theauth]/route.ts import { theAuthNextjs } from '@glinr/theauth-nextjs'; import { getTheAuth } from '@/lib/theauth'; const theauth = await getTheAuth(); // basePath must match the folder this catch-all route lives in export const { GET, POST, PATCH, DELETE, OPTIONS } = theAuthNextjs(theauth, { basePath: '/api/auth', }); 

Note the comment. The default base path is /api/theauth. If your folder is /api/auth, pass it explicitly or your client calls will 404.

Step 3: Export users from the old provider

This is the step with the real risk. Take it slowly.

Exporting from Clerk

The Backend API does the work here. Pagination caps at 500 users per page, so you loop.

// scripts/export-clerk-users.ts import { clerkClient } from '@clerk/backend'; import { writeFileSync } from 'node:fs'; const clerk = clerkClient({ secretKey: process.env.CLERK_SECRET_KEY! }); const out: unknown[] = []; let offset = 0; const limit = 500; while (true) { const { data } = await clerk.users.getUserList({ limit, offset }); if (!data.length) break; out.push(...data); offset += data.length; if (data.length < limit) break; } writeFileSync('./clerk-users.json', JSON.stringify(out, null, 2)); console.log(`exported ${out.length} users`); 

Load that file into a staging table called clerk_user_import. Then insert into the theAuth tables. This version keeps Clerk's user id, so your foreign keys survive.

INSERT INTO theauth_users ( id, email, name, email_verified, created_at, updated_at ) SELECT id, lower(email_addresses->0->>'email_address'), NULLIF(trim(COALESCE(first_name, '') || ' ' || COALESCE(last_name, '')), ''), (email_addresses->0->'verification'->>'status') = 'verified', to_timestamp(created_at / 1000.0), to_timestamp(updated_at / 1000.0) FROM clerk_user_import ON CONFLICT (id) DO NOTHING; 

Then the social links, one row per provider connection.

INSERT INTO theauth_oauth_accounts ( id, user_id, provider, provider_account_id, access_token, refresh_token, created_at, updated_at ) SELECT gen_random_uuid()::text, u.id, replace(e.value->>'provider', 'oauth_', ''), e.value->>'provider_user_id', '', NULL, to_timestamp(u.created_at / 1000.0), to_timestamp(u.updated_at / 1000.0) FROM clerk_user_import u, jsonb_array_elements(u.external_accounts) e; 

Two details. The empty string for access_token is deliberate: the column is NOT NULL and Clerk does not export live tokens. And theauth_users has no image column, so the import skips Clerk's image_url. If avatars matter, store them in the metadata column or a table of your own.

Exporting from Auth0

The Management API bulk export returns your users as JSON lines. Password hashes are the catch. Auth0 releases them through a support request, so open that ticket early. It can take days. Social-only users have no hash at all.

The import script below inserts users and, for password users, writes an unusable placeholder into theAuth plus the real bcrypt hash into a legacy_password_hashes table that you own.

// scripts/import-auth0.ts import fs from 'node:fs'; import readline from 'node:readline'; import { randomUUID } from 'node:crypto'; import { Pool } from 'pg'; const pool = new Pool({ connectionString: process.env.DATABASE_URL }); const stream = readline.createInterface({ input: fs.createReadStream('auth0-users.ndjson'), }); for await (const line of stream) { const u = JSON.parse(line); const email = String(u.email).toLowerCase(); const inserted = await pool.query( `INSERT INTO theauth_users (id, email, name, email_verified, metadata, force_password_reset, created_at, updated_at) VALUES ($1, $2, $3, $4, $5, $6, now(), now()) ON CONFLICT (email) DO UPDATE SET updated_at = now() RETURNING id`, [randomUUID(), email, u.name ?? null, Boolean(u.email_verified), JSON.stringify(u.app_metadata ?? {}), Boolean(u.passwordHash)], ); const userId: string = inserted.rows[0].id; if (u.passwordHash) { await pool.query( `INSERT INTO theauth_username_accounts (id, user_id, username, password_hash, created_at, updated_at) VALUES ($1, $2, $3, 'imported:unusable', now(), now()) ON CONFLICT DO NOTHING`, [randomUUID(), userId, email], ); await pool.query( `INSERT INTO legacy_password_hashes (user_id, username, bcrypt_hash) VALUES ($1, $2, $3) ON CONFLICT DO NOTHING`, [userId, email, u.passwordHash], ); } } await pool.end(); 

Create legacy_password_hashes yourself before running this. That table belongs to you, not to theAuth. Also swap randomUUID() for Auth0's id if your own foreign keys depend on it.

One more field to watch. The script sets force_password_reset to true for anyone with a hash, so nobody can sign in with the placeholder. You lift that flag later, either through a reset or a rehash.

Check the counts

After either import, compare numbers. Rows in theauth_users should equal your exported user count, minus any duplicate emails. If they do not match, stop and find out why. A 2 percent gap you ignore today becomes 2 percent of your support queue next week.

Step 4: Decide how password users get back in

Neither provider's hashes verify in theAuth. The username module accepts PBKDF2 only, in the format pbkdf2:<iterations>:<saltHex>:<hashHex> using SHA-256. A bcrypt hash in password_hash is simply rejected at sign-in.

theAuth also ships no import API and no legacy-hash option. The Clerk page says it directly: options like verifyLegacyHash do not exist, and calling theauth.username.signUp for an imported user would create a duplicate account. That leaves two honest choices.

Option A: forced reset

Simplest and safest. Configure the passwordReset module on createTheAuth (it needs resetUrl and sendResetEmail), then send each user through POST /auth/forgot-password. The placeholder row is what lets the reset flow find them. A successful reset clears force_password_reset.

The cost is friction. Every password user gets an email and has to act. Expect a drop in active users from people who never open it. The reset module revokes existing sessions on success by default, which is what you want here. The email and password page lists the supporting modules, including reset and email verification.

Option B: lazy rehash

Verify the bcrypt hash yourself the next time the user types their password, write a PBKDF2 hash in theAuth's format, then call theauth.username.signIn.

import { compare as bcryptCompare } from 'bcrypt'; import { pbkdf2Sync, randomBytes } from 'node:crypto'; import { Pool } from 'pg'; import { getTheAuth } from '@/lib/theauth'; const pool = new Pool({ connectionString: process.env.DATABASE_URL }); function pbkdf2Format(password: string): string { const iterations = 600_000; const salt = randomBytes(16); const hash = pbkdf2Sync(password, salt, iterations, 32, 'sha256'); return `pbkdf2:${iterations}:${salt.toString('hex')}:${hash.toString('hex')}`; } export async function signInWithLegacyBridge(username: string, password: string) { const theauth = await getTheAuth(); const { rows } = await pool.query( 'SELECT user_id, bcrypt_hash FROM legacy_password_hashes WHERE username = $1', [username.toLowerCase()], ); const legacy = rows[0]; if (legacy && (await bcryptCompare(password, legacy.bcrypt_hash))) { await pool.query( 'UPDATE theauth_username_accounts SET password_hash = $1, updated_at = now() WHERE user_id = $2', [pbkdf2Format(password), legacy.user_id], ); await pool.query( 'UPDATE theauth_users SET force_password_reset = FALSE, updated_at = now() WHERE id = $1', [legacy.user_id], ); await pool.query('DELETE FROM legacy_password_hashes WHERE user_id = $1', [legacy.user_id]); } return theauth.username?.signIn({ username, password }); } 

Read that function twice. It runs in front of theAuth, in your code. You own the bcrypt check, you own the rehash, and you own the bugs. That is real security-sensitive code, so get a second pair of eyes on it and test the failure paths. A wrong password must fall through to signIn, which will reject it.

After 30 to 60 days, drop legacy_password_hashes and force a reset for anyone who never came back.

I would pick Option B for a consumer app where friction costs sign-ups, and Option A for a B2B tool where a reset email is routine. Mix them if you like. Run the lazy bridge for 45 days, then force the rest.

Step 5: Map sessions

Existing logins do not migrate. Full stop.

For Clerk, the __session cookie carries Clerk's signature, and theAuth cannot read it without calling Clerk. For Auth0, the same holds for its session cookies. A theAuth session row holds only an id, a user id, an expiry, and metadata, and the cookie is a signed token pointing at that row.

That leaves two paths.

Hard cutover

Every signed-in user signs in once on the new flow. Put a banner up three days ahead. This is the simplest option and I recommend it for most apps. A single forced sign-in is a minor annoyance, and it removes a whole category of "which system am I on" bugs.

Side by side with a rollout flag

Run both stacks. Each user sticks to one stack until you move them. A percentage-based middleware routes traffic.

// middleware.ts import { NextRequest, NextResponse } from 'next/server'; export function middleware(req: NextRequest) { const userHint = req.cookies.get('migration_cohort')?.value ?? ''; const onTheAuth = userHint === 'theauth' || hashBucket(userHint) < Number(process.env.THEAUTH_ROLLOUT ?? '0'); if (onTheAuth) return NextResponse.next(); const url = new URL(req.nextUrl.pathname + req.nextUrl.search, process.env.LEGACY_ORIGIN!); return NextResponse.redirect(url); } function hashBucket(s: string): number { let h = 0; for (let i = 0; i < s.length; i++) h = (h * 31 + s.charCodeAt(i)) >>> 0; return h % 100; } 

Start at THEAUTH_ROLLOUT=10. Watch error rates and sign-in conversion. Raise it over a week or two. Keep the legacy app on its own subdomain so the redirect has somewhere to land.

Replace the middleware check

If you used clerkMiddleware, there is no drop-in replacement. Next.js middleware runs on the Edge runtime by default, where you cannot open a Postgres connection to check a session. Keep middleware cheap and verify the cookie in server code.

// middleware.ts, cookie presence only import { NextRequest, NextResponse } from 'next/server'; export function middleware(req: NextRequest) { if (req.cookies.get('theauth_session'Source:https://dev.to/thegdsks/migrate-from-auth0-or-clerk-to-theauth-step-by-step-3dm5

Related posts