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.
- A Postgres database (theAuth also supports SQLite, MySQL, and D1, but the examples here use Postgres)
- A TypeScript app. The code here uses a Next.js App Router layout, but adapters exist for other frameworks
- Admin access to your current provider so you can export users and edit OAuth app callback URLs
- 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')) return NextResponse.next();
const signInUrl = new URL('/sign-in', req.url);
signInUrl.searchParams.set('next', req.nextUrl.pathname);
return NextResponse.redirect(signInUrl);
}
export const config = {
matcher: ['/dashboard/:path*', '/api/private/:path*'],
};
A cookie that exists is not a cookie that is valid. The middleware check only filters out visitors with no cookie at all. The real check belongs where you can reach the database.
// lib/require-user.ts
import { cookies } from 'next/headers';
import { redirect } from 'next/navigation';
import { getTheAuth } from '@/lib/theauth';
export async function requireUser() {
const theauth = await getTheAuth();
const token = (await cookies()).get('theauth_session')?.value;
const session = token ? await theauth.auth.session?.validate(token) : null;
if (!session) redirect('/sign-in');
return session;
}
session.validate(token) returns a Session or null. It does not throw. The username methods signIn and signUp do throw on failure, so wrap those in try and catch.
If you used Auth0 or Clerk custom claims in tokens, the replacement is the customClaims option on createJwtSessionModule. It receives { id, email, name } and returns extra claims. The JWT sessions page covers the secret length rule (32 characters or more) and refresh token rotation.
Step 6: Move organizations
This part is the pleasant surprise. The organization, membership, and role model ports cleanly. Built-in roles are owner, admin, member, and viewer.
const theauth = await getTheAuth();
const org = await theauth.org?.create({
name: 'Acme',
slug: 'acme',
ownerId: userId,
});
if (org) {
await theauth.org?.addMember(org.id, otherUserId, 'member');
}
Notice the optional chaining. theauth.org is null unless you passed org: {...} to createTheAuth.
For Clerk users, one cleanup task. Clerk roles carry an org: prefix, as in org:admin. In theAuth they are plain strings. Search your code for org: and strip the prefix anywhere you read orgMembership.role.
What does not come along is the admin UI. Clerk has a hosted org switcher and management console. You build your own against the organization plugin. The organizations page shows the endpoints, and the SaaS template from the scaffolder has a minimal org page you can copy.
Step 7: Move providers and rebuild the sign-in page
Social login moves from the dashboard into code.
import { createTheAuth } from '@glinr/theauth';
import { oauth, createGithubProvider, createGoogleProvider } from '@glinr/theauth/auth';
export const theauth = await createTheAuth({
database: { provider: 'postgres', url: process.env.DATABASE_URL! },
secret: process.env.THEAUTH_SECRET!,
baseUrl: process.env.AUTH_BASE_URL!, // must end with the adapter mount path, such as /api/auth
auth: { session: { secret: process.env.SESSION_SECRET! } },
plugins: [
oauth({
providers: {
github: createGithubProvider({
clientId: process.env.GITHUB_CLIENT_ID!,
clientSecret: process.env.GITHUB_CLIENT_SECRET!,
}),
google: createGoogleProvider({
clientId: process.env.GOOGLE_CLIENT_ID!,
clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
}),
},
}),
],
});
Two gotchas live here. The baseUrl must include the adapter mount path, because theAuth builds the redirect URI from it. And the callback URL pattern is {baseUrl}/auth/oauth/callback/<provider>. With the config above and a mount at /api/auth, that is <your-domain>/api/auth/auth/oauth/callback/github. Yes, auth appears twice. Change each OAuth app's callback URL in the provider console before cutover, or the first social sign-in fails. The OAuth page lists the supported providers and the generic OIDC factory for anything else.
The sign-in page
Clerk's <SignIn /> renders a hosted flow inside your layout. theAuth gives you hooks and expects a form.
The docs hide a trap worth quoting in spirit. The useSignIn hook posts { email, password } to ${basePath}/auth/sign-in. The TypeScript core and adapters do not register that route. The username module serves /auth/username/sign-in and /auth/username/sign-up with { username, password }, and you wire theauth.username.handleRequest(request) into a route handler yourself. Until you do, build the form against the username endpoints, or call theauth.username.signIn in a server action.
I mention it because I would rather you read it here than discover it at 11 p.m. with a 404 in the network tab.
Passkeys and phone OTP are server modules with their own endpoints. If your Clerk app showed first-party passkey or OTP screens, you rebuild those.
Step 8: Port machine-to-machine clients
This only applies if you are coming from Auth0 and run M2M clients. An M2M client becomes an agent with type: 'service'.
const agent = await theauth.agent.create({
ownerId: opsUser.id,
name: 'billing-pipeline',
type: 'service',
permissions: [
{
resource: 'mcp:stripe:*',
actions: ['read'],
constraints: { maxCallsPerHour: 1000 },
},
{
resource: 'mcp:stripe:refund',
actions: ['execute'],
constraints: { requireApproval: true },
},
],
});
The token comes back once, as a kv_-prefixed bearer, and theAuth stores only its SHA-256 hash. Hand it to the service immediately. Rotate with theauth.agent.rotate(agent.id). Revoke with theauth.agent.revoke(agent.id).
Two properties differ from Auth0. Rotation is atomic, so the old token dies the moment you issue the new one, with no overlap window. If a consumer cannot swap tokens instantly, plan the rotation around that. And rate caps, approval gates, time windows, and IP allowlists are constraints on the permission itself.
M2M clients should cut over last. Confirm the caps and approval gates in staging first. The agents page covers identity, delegation, and trust scoring in depth, and the audit page shows how to read the decisions back.
Step 9: The cutover plan
Here is the sequence I would run. It assumes a side-by-side rollout. Collapse steps if you take the hard cutover.
- Stand up theAuth in staging against a copy of production data. Do a full import. Compare counts
- Run a test sign-in for a sample of 20 to 50 users across each type: password, social-only, org admin, and one agent
- Update OAuth app callback URLs so both the old and new URLs are valid where the provider allows it
- Deploy theAuth to production behind a flag at 0 percent. Nothing changes for users
- Move internal staff first. Then 10 percent of traffic. Watch the audit log for authorization failures and fix mismatches
- Widen to 50, then 100 percent over one to two weeks
- Cut M2M clients last, one service at a time
- Leave the old provider running through one full session-refresh cycle (the migration docs suggest at least 7 days)
- After the grace period, decommission the old tenant and drop
legacy_password_hashesonce the bridge window ends
Take a database snapshot before step 1 and again before step 6. Imports are idempotent in the scripts above because of ON CONFLICT, but a snapshot is cheaper than being clever.
Rollback
Rolling back is a routing change, not a data change, as long as you keep the old provider alive.
- Flip the reverse proxy or middleware back to the old provider. Set
THEAUTH_ROLLOUT=0 - Users still holding old-provider cookies stay signed in. New sessions come from the old provider again
- theAuth sessions stay valid locally until they expire, so nothing breaks for people already on the new stack
What rollback cannot undo is anything users did on theAuth alone. A password reset completed on the new system does not flow back to Auth0 or Clerk. A user who signed up after cutover does not exist in the old tenant. Keep the rollout window short, and write down the timestamp of your first production sign-up on theAuth. That is your point of no easy return.
What does not migrate
I will put the whole list in one place, since this is the part migration guides tend to bury.
Passwords do not migrate. You reset or rehash.
Sessions do not migrate. Users sign in again.
OAuth access and refresh tokens do not migrate. If your app calls GitHub or Google APIs using stored tokens, those calls fail until each user signs in once more. Plan a grace period and a graceful error.
Webhooks do not migrate. Clerk events stop when you stop using Clerk.
Hosted UI does not migrate. Login pages, org switchers, and admin consoles are yours to build.
Auth0 Rules and Actions do not migrate. They become code in your own handlers.
Avatars from Clerk have no dedicated column to land in, so they do not come along.
MFA enrollments are worth a mention. TOTP secrets and passkeys are not part of the export flows above, so assume users re-enroll. The two-factor page under Auth covers the new setup.
Troubleshooting and gotchas
Users report "invalid credentials" right after import
Check that the username in theauth_username_accounts is the lowercased email. Sign-in looks up the username, lowercased by default, and does not apply the sign-up pattern check. A mixed-case username will miss.
Reset emails never arrive
The passwordReset module needs both resetUrl and sendResetEmail. If you configured neither, the forgot-password route has nothing to send with. Test with one account before you mail 10,000 people.
Social sign-in returns a redirect URI mismatch
Re-read the callback URL pattern. Count the path segments. The auth segment appears twice when your adapter mounts at /api/auth. Provider consoles compare strings exactly.
The OAuth plugin throws on startup
It requires auth: { session: { secret } }. Set that secret even if you only use the plugin for social login.
theauth.org is null
You passed the organization() plugin but not org: {}. Add the config key.
Imported OAuth users get a fresh account
If the provider_account_id you imported does not match what the provider returns, theAuth treats the sign-in as a new identity. For Auth0 ids like google-oauth2|1234, the part after the pipe is the provider account id. For Clerk, strip the oauth_ prefix from the provider name.
A user signs in on the new stack but has no org data
Memberships need their own import. The user insert does not carry them. Script the org and membership copy and run it in the same window as the user import.
A note on Better Auth
If you are moving from Better Auth rather than a hosted service, the picture is friendlier. You own the database already, so the export step is a SQL copy, not an API crawl. The migration page has INSERT ... SELECT statements for the user and account tables. Skip the rows where providerId is credential.
Two things still do not carry over. Session tokens use a different structure, and credential hashes are not in PBKDF2 format, so the same reset or rehash choice applies. You can bridge sessions during the overlap by wrapping your old Better Auth session check in customAuth and passing it as auth.adapter, so both cookies resolve while traffic drains. One caveat from the docs: theauth.auth.resolveUser(request) consults only the adapter, so check theauth.auth.session too in your own server code if you need to accept both.
Better Auth is a good library. If you only need human auth and your provider list fits, you may not need to move at all. The full Better Auth guide covers the cookie defaults and the agent-auth package differences.
Go deeper
The three migration pages carry their own diffs and data scripts:
Concepts you will touch while migrating:
FAQ
Will my users have to sign in again on cutover day?
Yes, unless you run a side-by-side rollout. theAuth cannot verify Clerk or Auth0 session cookies. Tell users ahead of time and keep the old subdomain alive as a fallback.
Can I import users with their existing passwords?
Not directly. theAuth verifies PBKDF2 hashes only and has no user import API or legacy-hash option. You either force a password reset or write a bridge in your own sign-in handler that verifies bcrypt and rehashes on success.
Does theAuth replace Auth0 Actions or Clerk webhooks?
No. theAuth has no sign-in or sign-up hook, and Clerk's events do not carry over. The hooks option covers agents. For human flows, you put the logic in your own handlers, or use customClaims for token enrichment.
How long should I keep the old provider running?
At least one full session-refresh cycle. The migration docs suggest 7 days as a default. In practice, keep it until traffic to the old stack is near zero and your bridge window has ended.
Is theAuth a good fit if I only need human login?
Possibly, but weigh it honestly. The strongest reasons to move are agents, MCP, and ownership of your data and cookies. If you want a hosted login page and an admin console without building them, a hosted provider may serve you better.
Can I certify compliance by moving to theAuth?
No. Self-hosting and the GDPR export tooling give you building blocks. They do not certify your deployment against any regulation.
What I would do first
If I were starting tomorrow, I would do one thing before any code. I would run the user count query, split it into password users and social-only users, and decide between reset and lazy rehash based on that split. Everything else follows from it.
What is the one piece of your current auth setup that you are most afraid to move, and why?
Try it yourself
The fastest way in is the quickstart. If this guide saved you time, a star on GitHub helps other developers find the project, and the docs cover every option used above. More about the project lives at theauth.dev.
Next: guide 5 if you also run AI agents. The full list sits in the table at the top of this page.
GDS K S · thegdsks.com · building Glincker · follow on X @thegdsks
The code diff takes an afternoon, the users take a plan, so write the plan first.
Top comments (1)
tr.ee/dev-to