Skip to content

Migrating from Auth.js

This guide walks through migrating an existing Auth.js (NextAuth.js) application to ideal-auth. The migration can be done incrementally — you can run both libraries side by side during the transition.


Auth.js is opinionated about data access. It requires database adapters (Prisma adapter, Drizzle adapter, etc.) that manage your schema and queries. ideal-auth is bring-your-own — you write the database queries, you control the schema, you decide what ORM to use (or none).

Auth.js bundles everything. OAuth providers, email/password, magic links, database sessions, JWT sessions — all in one package. If you only need session auth with email/password, you carry the weight of features you do not use. ideal-auth focuses on session auth primitives and lets you compose other tools (e.g., Arctic for OAuth).

Simpler mental model. Auth.js has providers, callbacks (signIn, jwt, session, redirect), adapter interfaces, and strategy-specific configuration. ideal-auth has createAuth, createHash, and explicit methods like session.attempt(), session.login(), session.logout(). You call what you need, when you need it.

No magic. Auth.js injects routes (/api/auth/*), manages CSRF tokens internally, and handles redirects behind configuration. ideal-auth does not inject routes, does not manage CSRF (that is framework-level), and does not redirect — you call redirect() yourself.


ConceptAuth.jsideal-auth
Session storageDatabase session or JWTEncrypted cookie (iron-session)
Database accessAdapter pattern (Prisma, Drizzle, etc.)You write the queries (resolveUser, resolveUserByCredentials)
Password hashingNot built-in (you handle it in authorize)Built-in (createHash with bcrypt + SHA-256 prehash)
LoginsignIn("credentials", { ... })session.attempt({ email, password })
LogoutsignOut()session.logout()
Session checkauth() or getSession() returns sessionsession.check() returns boolean, session.user() returns user
OAuthBuilt-in providersNot included — use Arctic
CSRFBuilt-in (managed internally)Framework-level (see CSRF guide)
Route protectionMiddleware with auth()Middleware with cookie check + server-side session.user()
Cookie controlLimited (some options exposed)Full control via ConfigurableCookieOptions (except httpOnly, which is forced)
2FANot built-inBuilt-in (createTOTP, generateRecoveryCodes)
Rate limitingNot built-inBuilt-in (createRateLimiter)
Token verificationNot built-inBuilt-in (createTokenVerifier)

  1. Keep Auth.js running while you set up ideal-auth. This allows you to migrate incrementally without breaking existing functionality.

    Terminal window
    bun add ideal-auth
  2. lib/ideal-auth.ts
    import { createAuth, createHash } from 'ideal-auth';
    import { cookies } from 'next/headers';
    import { db } from './db';
    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: {
    async get(name) {
    const jar = await cookies();
    return jar.get(name)?.value;
    },
    async set(name, value, options) {
    const jar = await cookies();
    jar.set(name, value, options);
    },
    async delete(name) {
    const jar = await cookies();
    jar.delete(name);
    },
    },
    hash,
    async resolveUser(id) {
    return db.user.findUnique({ where: { id } });
    },
    async resolveUserByCredentials(credentials) {
    return db.user.findUnique({
    where: { email: credentials.email },
    });
    },
    });
  3. Migrate resolveUser to use your existing queries

    Section titled “Migrate resolveUser to use your existing queries”

    If you were using Auth.js with a database adapter, you already have a users table. Point resolveUser at it:

    Prisma example:

    async resolveUser(id) {
    return db.user.findUnique({ where: { id } });
    },

    Drizzle example:

    async resolveUser(id) {
    const [user] = await db.select().from(users).where(eq(users.id, id));
    return user ?? null;
    },

    Raw SQL example:

    async resolveUser(id) {
    const { rows } = await pool.query('SELECT * FROM users WHERE id = $1', [id]);
    return rows[0] ?? null;
    },
  4. Before (Auth.js):

    import { signIn } from 'next-auth/react';
    // Client-side
    await signIn('credentials', {
    email,
    password,
    redirect: false,
    });
    // Or server-side (Auth.js v5)
    import { signIn } from '@/auth';
    await signIn('credentials', { email, password });

    After (ideal-auth):

    app/actions/login.ts
    'use server';
    import { redirect } from 'next/navigation';
    import { auth } from '@/lib/ideal-auth';
    export async function loginAction(_prev: unknown, formData: FormData) {
    const email = formData.get('email') as string;
    const password = formData.get('password') as string;
    const session = auth();
    const success = await session.attempt({ email, password });
    if (!success) {
    return { error: 'Invalid email or password.' };
    }
    redirect('/dashboard');
    }
  5. Before (Auth.js):

    import { signOut } from 'next-auth/react';
    await signOut({ redirect: true, callbackUrl: '/login' });

    After (ideal-auth):

    app/actions/logout.ts
    'use server';
    import { redirect } from 'next/navigation';
    import { auth } from '@/lib/ideal-auth';
    export async function logoutAction() {
    const session = auth();
    await session.logout();
    redirect('/login');
    }
  6. Before (Auth.js):

    import { auth } from '@/auth';
    // In a Server Component
    const session = await auth();
    if (!session) redirect('/login');
    const user = session.user;

    After (ideal-auth):

    import { auth } from '@/lib/ideal-auth';
    // In a Server Component
    const session = auth();
    const user = await session.user();
    if (!user) redirect('/login');

    Before (Auth.js client-side):

    import { useSession } from 'next-auth/react';
    function Dashboard() {
    const { data: session, status } = useSession();
    if (status === 'loading') return <p>Loading...</p>;
    if (!session) redirect('/login');
    return <p>Welcome, {session.user.name}</p>;
    }

    After (ideal-auth):

    ideal-auth does not have a client-side session hook. Fetch the user server-side and pass it as props:

    app/dashboard/page.tsx
    import { auth } from '@/lib/ideal-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 <DashboardClient user={{ id: user.id, name: user.name, email: user.email }} />;
    }
  7. Before (Auth.js):

    export { auth as middleware } from '@/auth';
    export const config = {
    matcher: ['/dashboard/:path*'],
    };

    After (ideal-auth):

    Since ideal-auth uses iron-session (Node.js crypto), you cannot decrypt the session in Edge middleware. Instead, check for the cookie’s existence:

    middleware.ts
    import { NextResponse } from 'next/server';
    import type { NextRequest } from 'next/server';
    export function middleware(request: NextRequest) {
    const hasSession = request.cookies.has('ideal_session');
    if (!hasSession) {
    const loginUrl = new URL('/login', request.url);
    loginUrl.searchParams.set('redirect', request.nextUrl.pathname);
    return NextResponse.redirect(loginUrl);
    }
    return NextResponse.next();
    }
    export const config = {
    matcher: ['/dashboard/:path*', '/settings/:path*'],
    };
  8. ideal-auth does not include OAuth. If your Auth.js setup uses OAuth providers (Google, GitHub, etc.), you have two options:

    Option A: Keep Auth.js for OAuth only. Remove the credentials provider from Auth.js and keep it exclusively for OAuth. Use ideal-auth for email/password login. Both can coexist — they use different session cookies.

    Option B: Switch to Arctic. Arctic is a lightweight OAuth 2.0 library that provides provider-specific helpers without the adapter/session layer. You handle the OAuth flow yourself and call session.login(user) with ideal-auth after verifying the OAuth token.

    Example: GitHub OAuth with Arctic + ideal-auth
    import { GitHub } from 'arctic';
    import { auth } from '@/lib/ideal-auth';
    const github = new GitHub(
    process.env.GITHUB_CLIENT_ID!,
    process.env.GITHUB_CLIENT_SECRET!,
    null,
    );
    // After the OAuth callback
    export async function handleGitHubCallback(code: string) {
    const tokens = await github.validateAuthorizationCode(code);
    const githubUser = await fetchGitHubUser(tokens.accessToken());
    // Find or create user in your database
    let user = await db.user.findUnique({
    where: { githubId: githubUser.id },
    });
    if (!user) {
    user = await db.user.create({
    data: {
    email: githubUser.email,
    name: githubUser.name,
    githubId: githubUser.id,
    },
    });
    }
    // Log them in with ideal-auth
    const session = auth();
    await session.login(user);
    }
  9. Once all flows are migrated:

    Terminal window
    bun remove next-auth @auth/prisma-adapter # or your adapter

    Delete:

    • auth.ts or auth.config.ts (Auth.js configuration)
    • app/api/auth/[...nextauth]/route.ts (Auth.js API route)
    • SessionProvider wrapper in your layout (no longer needed)
    • Any useSession or getSession imports

OperationAuth.jsideal-auth
InitializeNextAuth({ providers: [...], adapter: ... })createAuth({ secret, cookie, resolveUser, ... })
LoginsignIn("credentials", { email, password })auth().attempt({ email, password })
LogoutsignOut()auth().logout()
Get sessionauth() / getSession()auth().check() (boolean)
Get user(await auth())?.userawait auth().user()
Get user ID(await auth())?.user?.idawait auth().id()
Login with user objectNot directly availableauth().login(user)
Hash passwordManual (bcrypt.hash())hash.make(password)
Verify passwordManual (bcrypt.compare())hash.verify(password, hash)

If you have existing users who signed up via OAuth and have no password in your database, they need a way to authenticate with ideal-auth. Two approaches:

1. “Set password” flow. After migration, prompt OAuth users to set a password. Send them to a page where they enter a new password, which you hash and store. They can then log in with email/password.

2. Keep OAuth separate. Use Arctic (or keep Auth.js) for OAuth login. After verifying the OAuth token, look up the user and call session.login(user) with ideal-auth. The user never needs a password.


If your Auth.js application used a different hashing algorithm (e.g., argon2 or scrypt directly), you need to rehash passwords as users log in:

lib/ideal-auth.ts
export const auth = createAuth<User>({
// ...
// Use attemptUser for custom hash migration logic
async attemptUser(credentials) {
const { password, ...lookup } = credentials;
const user = await db.user.findUnique({
where: { email: lookup.email },
});
if (!user || !user.password) return null;
// Check if the hash is already bcrypt (starts with $2a$ or $2b$)
if (user.password.startsWith('$2')) {
// Already bcrypt — use ideal-auth's hash.verify
const valid = await hash.verify(password, user.password);
return valid ? user : null;
}
// Legacy hash — verify with old algorithm
const valid = await verifyLegacyHash(password, user.password);
if (!valid) return null;
// Rehash with bcrypt for next time
const newHash = await hash.make(password);
await db.user.update({
where: { id: user.id },
data: { password: newHash },
});
return user;
},
});

This transparently migrates password hashes on each login until all users have been rehashed.