<!-- PkgPulse AI-readable guide source -->
<!-- Canonical: https://www.pkgpulse.com/guides/lucia-vs-nextauth-2026 -->
<!-- Raw Markdown: https://www.pkgpulse.com/guides/lucia-vs-nextauth-2026/raw.md -->
<!-- Source path: content/guides/lucia-vs-nextauth-2026.mdx -->

---
og_image: "/images/guides/lucia-vs-nextauth-2026.webp"
title: Lucia vs NextAuth (2026)
description: "Lucia is a minimal auth library that gives you full control. NextAuth handles more for you. Compare them for building custom authentication in TypeScript."
date: "2026-03-08"
author: "PkgPulse Team"
tags: ["lucia", "nextauth", "authentication", "typescript", "sessions", "2026"]
featured_comparison: "lucia-vs-nextauth"
tier: 0
---

## 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:

```typescript
// 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',
  },
};
```

```typescript
// 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' },
});
```

```typescript
// 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:

```typescript
// 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):

```sql
-- 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:

```typescript
// 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):

```typescript
// 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:

```typescript
// 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.
```

```typescript
// 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:

```typescript
// 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:

```typescript
// 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:

```typescript
// 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](/compare/auth0-vs-clerk), [Auth0 vs Clerk vs WorkOS 2026](/guides/auth0-vs-clerk-2026), [lucia package health](/packages/lucia)*
