Lucia vs NextAuth (2026)
TL;DR
NextAuth for quick setup with OAuth; Lucia for full control over session management. NextAuth/Auth.js (~2.5M weekly downloads) handles OAuth providers, database sessions, and JWT out of the box — ideal for apps needing social login quickly. Lucia (~500K downloads) is a minimal session management library that gives you complete control over how auth works — ideal for apps with custom auth logic or email/password-focused auth.
Key Takeaways
- NextAuth/Auth.js: ~2.5M weekly downloads — Lucia: ~500K (npm, March 2026)
- Lucia only handles sessions — you write all auth logic (login, signup, validation)
- NextAuth handles OAuth providers — GitHub, Google, etc. in 5 lines
- Lucia v3 is framework-agnostic — not React/Next.js specific
- Lucia has better TypeScript — fully typed session/user objects without hacks
The Core Difference in Philosophy
The most important thing to understand before comparing code is what each library actually does.
Auth.js (NextAuth v5) is an authentication framework. It manages the full lifecycle: OAuth handshakes, database-backed sessions, JWT tokens, and callback hooks. You configure providers and adapters; it handles the rest. Setting up GitHub OAuth is 10 lines of code and Auth.js manages the callback URL, CSRF tokens, state parameters, and database writes.
Lucia is a session management library. It handles exactly one thing: creating, validating, and invalidating sessions. It does not integrate with OAuth providers, does not verify passwords, and does not send magic link emails. You build all of that yourself — Lucia just gives you the session primitive to build on.
This is not a design flaw in Lucia; it's the design. The author's philosophy is that auth libraries should be explicit tools rather than black boxes. Every line of your auth flow is code you write and understand.
Setup Complexity
Auth.js v5 Setup
Auth.js v5 (Next.js App Router) requires three files:
// auth.config.ts — provider and callback configuration
import type { NextAuthConfig } from 'next-auth';
import GitHub from 'next-auth/providers/github';
import Google from 'next-auth/providers/google';
export const authConfig: NextAuthConfig = {
providers: [
GitHub({ clientId: process.env.GITHUB_ID!, clientSecret: process.env.GITHUB_SECRET! }),
Google({ clientId: process.env.GOOGLE_ID!, clientSecret: process.env.GOOGLE_SECRET! }),
],
callbacks: {
authorized({ auth, request: { nextUrl } }) {
const isLoggedIn = !!auth?.user;
const isProtected = nextUrl.pathname.startsWith('/dashboard');
if (isProtected) return isLoggedIn;
return true;
},
session({ session, token }) {
// Attach user ID to the session object
if (token.sub) session.user.id = token.sub;
return session;
},
},
pages: {
signIn: '/login',
},
};
// auth.ts — the main export
import NextAuth from 'next-auth';
import { DrizzleAdapter } from '@auth/drizzle-adapter';
import { db } from '@/db';
import { authConfig } from './auth.config';
export const { handlers, auth, signIn, signOut } = NextAuth({
...authConfig,
adapter: DrizzleAdapter(db),
session: { strategy: 'database' },
});
// middleware.ts — route protection
import { auth } from '@/auth';
export default auth((req) => {
// req.auth is the session (null if not signed in)
});
export const config = {
matcher: ['/((?!api|_next/static|_next/image|favicon.ico).*)'],
};
Auth.js also needs a route handler (app/api/auth/[...nextauth]/route.ts) that re-exports the handlers object. Altogether, that's about four files and an hour of setup to have OAuth working end-to-end with a database adapter.
Lucia Setup
Lucia requires more initial setup because you're building the auth system, not configuring a framework:
// lib/lucia.ts — Lucia initialization
import { Lucia } from 'lucia';
import { DrizzlePostgreSQLAdapter } from '@lucia-auth/adapter-drizzle';
import { db } from '@/db';
import { sessions, users } from '@/db/schema';
const adapter = new DrizzlePostgreSQLAdapter(db, sessions, users);
export const lucia = new Lucia(adapter, {
sessionCookie: {
attributes: {
secure: process.env.NODE_ENV === 'production',
},
},
getUserAttributes(attributes) {
return {
email: attributes.email,
name: attributes.name,
role: attributes.role,
};
},
});
// Required for TypeScript to know the session type
declare module 'lucia' {
interface Register {
Lucia: typeof lucia;
DatabaseUserAttributes: {
email: string;
name: string;
role: 'admin' | 'user';
};
}
}
You also need a session table in your database (Auth.js creates this automatically through its adapter):
-- Lucia requires you to create this table yourself
CREATE TABLE sessions (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
expires_at TIMESTAMP WITH TIME ZONE NOT NULL
);
Then you write every auth route: sign-in, sign-up, sign-out, session validation. This is explicit but more code up front. For a greenfield project with standard email/password auth, Lucia's setup takes two to three hours rather than one, but you come away with code you fully understand and control.
OAuth: Easy vs Explicit
Auth.js — OAuth in 5 Lines
The biggest strength of Auth.js is how little code an OAuth integration requires:
// auth.config.ts — GitHub OAuth
import GitHub from 'next-auth/providers/github';
export const authConfig: NextAuthConfig = {
providers: [
GitHub({
clientId: process.env.GITHUB_ID!,
clientSecret: process.env.GITHUB_SECRET!,
}),
],
};
Auth.js handles the OAuth dance: generating the state parameter, redirecting to GitHub, receiving the callback, exchanging the code for an access token, fetching the user profile, and writing a user record to your database. There is nothing else to configure. If you need Google, Discord, Twitter, or any of the 80+ supported providers, each is another two-line addition.
Lucia + Arctic — OAuth with Full Control
Adding OAuth to a Lucia project requires the arctic library (built by the same author):
// lib/oauth/github.ts
import { GitHub } from 'arctic';
export const github = new GitHub(
process.env.GITHUB_CLIENT_ID!,
process.env.GITHUB_CLIENT_SECRET!
);
// app/api/auth/github/route.ts — redirect to GitHub
import { generateState } from 'arctic';
import { cookies } from 'next/headers';
import { github } from '@/lib/oauth/github';
export async function GET() {
const state = generateState();
const url = await github.createAuthorizationURL(state, {
scopes: ['user:email'],
});
cookies().set('github_oauth_state', state, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
maxAge: 600,
sameSite: 'lax',
});
return Response.redirect(url);
}
// app/api/auth/github/callback/route.ts — handle callback
import { github } from '@/lib/oauth/github';
import { lucia } from '@/lib/lucia';
import { cookies } from 'next/headers';
import { db } from '@/db';
export async function GET(request: Request) {
const url = new URL(request.url);
const code = url.searchParams.get('code');
const state = url.searchParams.get('state');
const storedState = cookies().get('github_oauth_state')?.value;
if (!code || !state || state !== storedState) {
return new Response('Invalid state', { status: 400 });
}
const tokens = await github.validateAuthorizationCode(code);
const githubUserResponse = await fetch('https://api.github.com/user', {
headers: { Authorization: `Bearer ${tokens.accessToken}` },
});
const githubUser = await githubUserResponse.json();
// Find or create user
let existingAccount = await db.query.oauthAccounts.findFirst({
where: eq(oauthAccounts.providerUserId, String(githubUser.id)),
with: { user: true },
});
if (!existingAccount) {
const [newUser] = await db.insert(users).values({
name: githubUser.name ?? githubUser.login,
email: githubUser.email,
}).returning();
await db.insert(oauthAccounts).values({
userId: newUser.id,
provider: 'github',
providerUserId: String(githubUser.id),
});
existingAccount = { ...newUser, user: newUser };
}
const session = await lucia.createSession(existingAccount.user.id, {});
const sessionCookie = lucia.createSessionCookie(session.id);
cookies().set(sessionCookie.name, sessionCookie.value, sessionCookie.attributes);
return Response.redirect(new URL('/dashboard', request.url));
}
That's considerably more code than Auth.js for a single OAuth provider. The tradeoff is that you understand every step of the flow and can customize anything — storing additional OAuth data, handling account linking, or implementing custom error states.
Email/Password Auth: Where Lucia Wins
Email/password is the most common custom auth flow, and it exposes Auth.js's most significant limitation:
// Auth.js — email/password via CredentialsProvider
// Note: CredentialsProvider ONLY works with JWT session strategy
providers: [
Credentials({
credentials: {
email: { label: 'Email', type: 'email' },
password: { label: 'Password', type: 'password' },
},
async authorize({ email, password }) {
const user = await db.query.users.findFirst({
where: eq(users.email, email as string),
});
if (!user) return null;
const passwordMatch = await bcrypt.compare(
password as string,
user.passwordHash
);
if (!passwordMatch) return null;
return { id: user.id, email: user.email, name: user.name };
},
}),
]
// CRITICAL LIMITATION: CredentialsProvider forces JWT sessions.
// You cannot use database sessions with email/password in Auth.js.
// JWT sessions cannot be invalidated server-side — they're valid until expiry.
// Lucia — email/password: full control, database sessions work correctly
// app/api/auth/login/route.ts
import { lucia } from '@/lib/lucia';
import { cookies } from 'next/headers';
import { db } from '@/db';
import { users } from '@/db/schema';
import { eq } from 'drizzle-orm';
import bcrypt from 'bcrypt';
export async function POST(request: Request) {
const { email, password } = await request.json();
const user = await db.query.users.findFirst({
where: eq(users.email, email),
});
if (!user || !user.passwordHash) {
return Response.json({ error: 'Invalid credentials' }, { status: 400 });
}
const passwordMatch = await bcrypt.compare(password, user.passwordHash);
if (!passwordMatch) {
return Response.json({ error: 'Invalid credentials' }, { status: 400 });
}
// Lucia creates a stateful session in the database
const session = await lucia.createSession(user.id, {});
const sessionCookie = lucia.createSessionCookie(session.id);
cookies().set(
sessionCookie.name,
sessionCookie.value,
sessionCookie.attributes
);
return Response.json({ success: true });
}
With Lucia, database sessions work with email/password auth. This means you can invalidate sessions server-side — log out all devices on a password reset, revoke individual sessions, or expire all sessions when suspicious activity is detected. These are standard security requirements that Auth.js cannot fulfill with its CredentialsProvider without significant workarounds.
Session Management: Stateful vs Stateless
This distinction is one of the most important practical differences between the two libraries.
Auth.js supports both JWT (stateless) and database (stateful) sessions. JWT sessions are faster because they don't require a database lookup on every request — the session data lives inside the signed token. The cost is that JWTs cannot be revoked before expiry. If a user's token is stolen or they need an emergency logout, you have no server-side mechanism to invalidate it.
Database sessions require a lookup on every request but can be revoked instantly. The problem: Auth.js's CredentialsProvider (email/password) is incompatible with database sessions. You're forced to use JWTs for email/password auth, which means you cannot do server-side session revocation.
Lucia uses exclusively stateful sessions stored in a database. Every session is a row in the sessions table. Lucia validates sessions by looking up that row, checking expiry, and optionally refreshing it. This adds a database round-trip but gives you complete control:
// Lucia — session invalidation patterns
import { lucia } from '@/lib/lucia';
// Invalidate a single session (logout current device)
await lucia.invalidateSession(sessionId);
// Invalidate ALL sessions for a user (force logout all devices)
await lucia.invalidateUserSessions(userId);
// Get all active sessions for a user (show "active sessions" UI)
const sessions = await lucia.getUserSessions(userId);
Auth.js does not expose equivalent APIs for session management. You can sign out the current session, but bulk invalidation requires direct database queries or workarounds with a denylist.
Custom Auth Flows: TOTP, Passkeys, and Beyond
When auth requirements get complex, Lucia's explicit approach becomes a significant advantage.
Adding TOTP (authenticator app) to Auth.js means fighting against the framework's session lifecycle. You need a custom multi-step sign-in flow, but Auth.js wants to complete authentication in a single authorize callback. Developers typically resort to storing a "pending" state in the JWT and checking it in middleware — an approach that works but feels like working around the library.
With Lucia, you implement exactly what you need:
// TOTP verification with Lucia — step 2 of 2-factor auth
// After password verification, create a temporary "pending" session
// Store TOTP requirement in session metadata
const session = await lucia.createSession(user.id, {
totp_verified: false,
// Metadata stored alongside the session in your database
});
// After TOTP is verified:
await db.update(sessions)
.set({ totp_verified: true })
.where(eq(sessions.id, session.id));
Passkeys (WebAuthn) follow a similar pattern. Lucia doesn't include passkey logic, but you can build it cleanly: use a WebAuthn library for the cryptographic handshake, then call lucia.createSession() when verification succeeds. There's no framework to fight.
Custom claims in JWT — adding a role or org_id to every session token — requires module augmentation in Auth.js and careful callback wiring. With Lucia's DatabaseUserAttributes, your user data flows directly into every validated session with full TypeScript types.
TypeScript Support
TypeScript is where Lucia has a clear architectural advantage:
// Auth.js — requires module augmentation to add custom fields to session
declare module 'next-auth' {
interface Session {
user: {
id: string; // Not included by default — must add manually
role: 'admin' | 'user'; // Also not included — manual augmentation
} & DefaultSession['user'];
}
}
// Lucia — generic DatabaseUserAttributes, fully typed from declaration
declare module 'lucia' {
interface Register {
Lucia: typeof lucia;
DatabaseUserAttributes: {
email: string;
name: string;
role: 'admin' | 'user';
};
}
}
// After declaring, session data is fully typed everywhere:
const { session, user } = await lucia.validateSession(sessionId);
user?.email; // string | undefined — TypeScript knows this
user?.role; // "admin" | "user" | undefined — exact union type
Lucia's session type is a true TypeScript generic. You declare the shape of your user data once in the Register interface, and it flows through session creation, validation, and all downstream code without any additional type assertions or casts.
Database Compatibility
Both libraries support major databases through adapters:
| Database | Auth.js | Lucia |
|---|---|---|
| PostgreSQL (Drizzle) | Yes | Yes |
| PostgreSQL (Prisma) | Yes | Yes |
| MySQL | Yes | Yes |
| MongoDB | Yes | Yes |
| SQLite | Yes | Yes |
| Turso/libSQL | Yes | Yes |
| Supabase | Via Prisma | Via Drizzle |
The practical difference: Auth.js manages the session schema automatically through its adapter. Lucia requires you to create the session table yourself, but the schema is documented and simple (three columns: id, user_id, expires_at).
Package Health
| Package | Weekly Downloads | Maintained | TypeScript | Notes |
|---|---|---|---|---|
next-auth | ~2.5M | Active | Native | Also published as @auth/core |
lucia | ~500K | Active | Native | Framework-agnostic |
arctic | ~200K | Active | Native | OAuth complement for Lucia |
Auth.js has a significantly higher download count, largely because it's the de facto standard for Next.js OAuth setups. Lucia's lower count reflects its more focused use case — developers who need full session control, not the wider pool of developers who just want GitHub login working.
When to Choose
Choose Auth.js/NextAuth when:
- Primary auth method is OAuth (GitHub, Google, Discord, etc.) and you want it working in under an hour
- You don't need per-device session invalidation
- Email magic links are a requirement (Auth.js has a built-in EmailProvider)
- Your team prefers convention over configuration
- The app doesn't have unusual auth requirements
Choose Lucia when:
- Email/password is your primary auth method and you need proper database sessions
- You need per-device session invalidation (force-logout individual devices)
- Building custom auth flows: TOTP, passkeys, biometric auth, custom MFA
- TypeScript correctness is non-negotiable
- You want to understand exactly what your auth code does
- Building for multiple frameworks and need auth-logic portability
Related: Auth0 vs Clerk comparison, Auth0 vs Clerk vs WorkOS 2026, lucia package health
See the live comparison
View lucia vs. nextauth on PkgPulse →