Migrating from Better Auth
This guide walks through migrating from Better Auth to ideal-auth. Better Auth is a full-featured authentication framework that manages your database schema, sessions, and plugins. ideal-auth takes the opposite approach — it provides primitives and lets you manage everything else.
Why migrate?
Section titled “Why migrate?”Better Auth manages your schema. It creates and manages users, sessions, and accounts tables (plus more for plugins). If you need to change the schema, you work within Better Auth’s constraints. ideal-auth does not touch your database — you write the queries, you own the schema entirely.
Better Auth uses server-side sessions. Every session check requires a database query to validate the session ID. ideal-auth uses encrypted cookies (iron-session), so session validation is a cryptographic operation with no database round-trip. This reduces latency and database load, especially at scale.
Simpler dependency tree. Better Auth includes adapters, plugins, OAuth handling, and a client-side SDK. ideal-auth has two runtime dependencies (iron-session and bcryptjs) and zero framework dependencies. Less code means less to audit and fewer potential vulnerabilities.
No plugin architecture. Better Auth’s plugin system (2FA, magic link, anonymous auth, etc.) adds features through a plugin API with lifecycle hooks. ideal-auth provides TOTP, recovery codes, token verification, and rate limiting as standalone functions. You compose them yourself — no plugin lifecycle, no implicit state changes.
Key differences
Section titled “Key differences”| Concept | Better Auth | ideal-auth |
|---|---|---|
| Session storage | Database (sessions table) | Encrypted cookie (iron-session) |
| Schema management | Managed by Better Auth | You manage your own schema |
| Database access | Built-in adapters (Prisma, Drizzle, Kysely, etc.) | You write the queries (resolveUser, resolveUserByCredentials) |
| Session check | Database lookup per request | Cryptographic verification (no DB query) |
| Login | signIn.email({ email, password }) | session.attempt({ email, password }) |
| Logout | signOut() | session.logout() |
| Get session | useSession() (client) or auth.api.getSession() | session.user() (server-side only) |
| 2FA | Plugin (twoFactor()) | Built-in (createTOTP, generateRecoveryCodes) |
| OAuth | Built-in with socialProviders | Not included — use Arctic |
| Rate limiting | Not built-in | Built-in (createRateLimiter) |
| Token verification | Not built-in (sessions used for everything) | Built-in (createTokenVerifier) for reset/verify flows |
| Client SDK | createAuthClient() | None — server-side only, pass data via props |
Migration steps
Section titled “Migration steps”-
Map your existing schema
Section titled “Map your existing schema”Better Auth typically creates these tables:
users— id, name, email, emailVerified, image, createdAt, updatedAtsessions— id, userId, token, expiresAt, ipAddress, userAgent, createdAt, updatedAtaccounts— id, userId, accountId, providerId, accessToken, refreshToken, etc.
With ideal-auth, you keep the
userstable (adding apasswordcolumn if you do not have one) and drop thesessionsandaccountstables (sessions are now cookie-based). If you use OAuth, keep theaccountstable for linking OAuth providers to users.-- Ensure your users table has a password columnALTER TABLE users ADD COLUMN IF NOT EXISTS password VARCHAR(255);-- After migration is complete, you can drop these:-- DROP TABLE sessions;-- DROP TABLE accounts; -- only if you no longer need OAuth -
Install ideal-auth
Section titled “Install ideal-auth”Terminal window bun add ideal-authAdd the session secret to your environment:
.env IDEAL_AUTH_SECRET="your-32-character-or-longer-secret" -
Create the auth configuration
Section titled “Create the auth configuration”lib/auth.ts import { createAuth, createHash } from 'ideal-auth';type User = {id: string;email: string;name: string;password: string;};export const hash = createHash({ rounds: 12 });export const auth = createAuth<User>({secret: process.env.IDEAL_AUTH_SECRET!,cookie: createCookieBridge(), // see framework guides for your frameworkhash,async resolveUser(id) {// This replaces Better Auth's adapter.findUserByIdreturn db.user.findUnique({ where: { id } });},async resolveUserByCredentials(credentials) {// This replaces Better Auth's credential verificationreturn db.user.findUnique({where: { email: credentials.email },});},}); -
Replace signIn.email with session.attempt
Section titled “Replace signIn.email with session.attempt”Before (Better Auth):
// Client-sideimport { authClient } from '@/lib/auth-client';const { data, error } = await authClient.signIn.email({email: 'user@example.com',password: 'password123',});After (ideal-auth):
app/actions/login.ts 'use server';import { redirect } from 'next/navigation';import { auth } from '@/lib/auth';export async function loginAction(_prev: unknown, formData: FormData) {const email = formData.get('email') as string;const password = formData.get('password') as string;if (!email || !password) {return { error: 'Email and password are required.' };}const session = auth();const success = await session.attempt({ email, password });if (!success) {return { error: 'Invalid email or password.' };}redirect('/dashboard');} -
Replace signOut with session.logout
Section titled “Replace signOut with session.logout”Before (Better Auth):
await authClient.signOut();After (ideal-auth):
app/actions/logout.ts 'use server';import { redirect } from 'next/navigation';import { auth } from '@/lib/auth';export async function logoutAction() {const session = auth();await session.logout();redirect('/login');} -
Replace useSession with server-side session access
Section titled “Replace useSession with server-side session access”Before (Better Auth):
// Client componentimport { authClient } from '@/lib/auth-client';function Dashboard() {const { data: session, isPending } = authClient.useSession();if (isPending) return <p>Loading...</p>;if (!session) redirect('/login');return <p>Welcome, {session.user.name}</p>;}After (ideal-auth):
ideal-auth does not provide a client-side session hook. Fetch the user server-side and pass data as props:
app/dashboard/page.tsx import { auth } from '@/lib/auth';import { redirect } from 'next/navigation';import { DashboardClient } from './dashboard-client';export default async function DashboardPage() {const session = auth();const user = await session.user();if (!user) {redirect('/login');}return (<DashboardClientuser={{ id: user.id, name: user.name, email: user.email }}/>);}app/dashboard/dashboard-client.tsx 'use client';type Props = {user: { id: string; name: string; email: string };};export function DashboardClient({ user }: Props) {return <p>Welcome, {user.name}</p>;} -
Migrate 2FA
Section titled “Migrate 2FA”If you are using Better Auth’s
twoFactor()plugin, migrate to ideal-auth’s built-in TOTP:Before (Better Auth):
// Server configimport { twoFactor } from 'better-auth/plugins';export const auth = betterAuth({plugins: [twoFactor()],});// Clientconst { data } = await authClient.twoFactor.enable({ password });await authClient.twoFactor.verifyTotp({ code });After (ideal-auth):
lib/totp.ts import { createTOTP, generateRecoveryCodes, verifyRecoveryCode } from 'ideal-auth';import { hash } from './auth';export const totp = createTOTP({digits: 6,period: 30,window: 1,});app/actions/2fa-setup.ts 'use server';import { totp } from '@/lib/totp';import { hash } from '@/lib/auth';import { encrypt } from 'ideal-auth';import { generateRecoveryCodes } from 'ideal-auth';import { db } from '@/lib/db';export async function enable2FA(userId: string) {// Generate TOTP secretconst secret = totp.generateSecret();// Generate recovery codesconst { codes, hashed } = await generateRecoveryCodes(hash);// Generate QR URI for authenticator appconst user = await db.user.findUnique({ where: { id: userId } });const qrUri = totp.generateQrUri({secret,issuer: 'YourApp',account: user!.email,});// Store encrypted secret and hashed recovery codesconst encryptedSecret = await encrypt(secret, process.env.TOTP_KEY!);await db.user.update({where: { id: userId },data: {totpSecret: encryptedSecret,recoveryCodes: hashed,twoFactorEnabled: true,},});// Return codes to show the user (one time only)return { qrUri, recoveryCodes: codes };}app/actions/2fa-verify.ts 'use server';import { totp } from '@/lib/totp';import { decrypt } from 'ideal-auth';import { db } from '@/lib/db';export async function verify2FA(userId: string, code: string) {const user = await db.user.findUnique({ where: { id: userId } });if (!user?.totpSecret) return { error: '2FA is not enabled.' };const secret = await decrypt(user.totpSecret, process.env.TOTP_KEY!);const isValid = totp.verify(code, secret);if (!isValid) {return { error: 'Invalid code.' };}return { success: true };}Better Auth stores 2FA state in the
accountstable via its plugin system. With ideal-auth, you addtotpSecret,recoveryCodes, andtwoFactorEnabledcolumns to youruserstable (or a separate table) and manage the state yourself. -
Remove Better Auth
Section titled “Remove Better Auth”Once all flows are migrated and tested:
Terminal window bun remove better-authDelete:
lib/auth.ts(Better Auth server config) — replace with your ideal-auth configlib/auth-client.ts(Better Auth client) — no longer neededapp/api/auth/[...all]/route.ts(Better Auth API route) — no longer needed- Better Auth migration files if you used
npx @better-auth/cli migrate
After confirming all users can log in with the new system:
-- Drop Better Auth's session table (sessions are now cookie-based)DROP TABLE IF EXISTS sessions;-- Drop accounts table only if you no longer need OAuth provider linking-- DROP TABLE IF EXISTS accounts;
Schema migration
Section titled “Schema migration”Better Auth manages specific tables. Here is what to keep and what to drop:
| Better Auth table | Action | Notes |
|---|---|---|
users | Keep | Add password column if missing. Add totpSecret, recoveryCodes, twoFactorEnabled if migrating 2FA. |
sessions | Drop (after migration) | ideal-auth uses encrypted cookies, not database sessions |
accounts | Keep if using OAuth | Stores OAuth provider links. Keep if you plan to use Arctic for OAuth. |
verifications | Drop (after migration) | Replace with createTokenVerifier for email verification and password reset flows |
Session storage comparison
Section titled “Session storage comparison”This is the most significant architectural difference:
Better Auth stores sessions in your database. Every auth.api.getSession() call queries the sessions table. This means:
- Session invalidation is easy (delete the row)
- Every page load hits the database
- Horizontal scaling requires a shared database or session store
ideal-auth stores sessions in encrypted cookies. Every session.user() call decrypts the cookie (no database query for session validation — only for user resolution via resolveUser). This means:
- No session table, no session garbage collection
- Session validation is CPU-only (cryptographic decryption)
- Horizontal scaling requires no shared state
- Session invalidation requires patterns described in the Session Invalidation guide
For most applications, the encrypted cookie approach is simpler and faster. If you need server-side session revocation (e.g., “logout everywhere”), see the Session Invalidation guide for practical patterns.
Handling OAuth users
Section titled “Handling OAuth users”If you used Better Auth’s socialProviders for OAuth login, you have users in the accounts table linked to OAuth providers. These users may not have a password.
Recommended approach: Use Arctic for OAuth. After verifying the OAuth token, look up the user via their accounts record and call session.login(user):
import { GitHub } from 'arctic';import { auth } from '@/lib/auth';
const github = new GitHub( process.env.GITHUB_CLIENT_ID!, process.env.GITHUB_CLIENT_SECRET!, null,);
export async function handleGitHubCallback(code: string) { const tokens = await github.validateAuthorizationCode(code); const githubUser = await fetchGitHubUser(tokens.accessToken());
// Find user via accounts table (your existing Better Auth data) const account = await db.account.findFirst({ where: { providerId: 'github', accountId: String(githubUser.id), }, include: { user: true }, });
if (!account) { // New OAuth user — create user and account const user = await db.user.create({ data: { email: githubUser.email, name: githubUser.name }, }); await db.account.create({ data: { userId: user.id, providerId: 'github', accountId: String(githubUser.id), }, }); const session = auth(); await session.login(user); return; }
// Existing user — log them in const session = auth(); await session.login(account.user);}This lets OAuth-only users continue logging in without ever setting a password.