Skip to main content

Lucia Auth v3 vs Better Auth vs Stack Auth: Self-Hosted Auth 2026

·PkgPulse Team

Lucia Auth v3 vs Better Auth vs Stack Auth: Self-Hosted Auth 2026

TL;DR

The "auth-as-a-service" era is being challenged. Developers tired of Auth0's pricing, Clerk's vendor lock-in, and Firebase Auth's Google dependency are reaching for self-hosted alternatives. Lucia Auth v3 is the authentication framework — not a library of pre-built flows, but a set of utilities for building session-based auth your way; it gives you full control but requires you to wire up OAuth, database storage, and session middleware yourself. Better Auth is the full-featured framework — comprehensive built-in plugins for OAuth, 2FA, magic links, passkeys, and organizations, with adapters for Drizzle, Prisma, and MongoDB; it's designed to be the self-hosted Clerk. Stack Auth is the open-source Clerk alternative — a hosted (or self-hostable) auth service with a pre-built UI component library and a full user management dashboard; it's the closest drop-in replacement for Clerk with no pricing cliff. For maximum control over auth primitives: Lucia. For feature-complete self-hosted auth without building flows: Better Auth. For teams that want Clerk's UX without Clerk's pricing: Stack Auth.

Key Takeaways

  • Lucia v3 is a framework, not a service — you build auth using its primitives, not pre-built flows
  • Better Auth has 30+ plugins — OAuth, 2FA, magic links, passkeys, organizations, admin, RBAC
  • Stack Auth includes a hosted dashboard — user management UI out of the box
  • Lucia is database-agnostic — adapters for PostgreSQL, MySQL, SQLite, MongoDB, Redis
  • Better Auth supports multi-tenancy — organization support with member roles and invitations
  • Stack Auth is open-source — self-host or use their managed cloud ($0 to start)
  • All three are TypeScript-first — full type safety end to end

Why Self-Hosted Auth?

Auth-as-a-service pricing reality:
  Clerk:   $0 up to 10k MAU → $25/month for 10k-20k → can reach $1,000+/month at scale
  Auth0:   $0 up to 7,500 MAU → $240/month for 7,500+ users
  Firebase: $0 for most cases → costs emerge with SMS, advanced features

Self-hosted alternatives:
  One-time setup → marginal cost of your database + compute
  Full ownership of user data (GDPR, data residency)
  No pricing cliff at scale milestones
  No vendor API changes breaking your auth flow
  Custom session behavior, token formats, and storage

Lucia Auth v3: Auth Primitives Framework

Lucia v3 is a library for building session-based authentication — it handles session ID generation, database storage, cookie management, and CSRF protection, but you implement the user model, OAuth flow, and UI.

Installation

npm install lucia
# Database adapter (pick one)
npm install @lucia-auth/adapter-drizzle
# npm install @lucia-auth/adapter-prisma
# npm install @lucia-auth/adapter-sqlite

Core Setup with Drizzle

// lib/auth.ts
import { Lucia } from "lucia";
import { DrizzleSQLiteAdapter } from "@lucia-auth/adapter-drizzle";
import { db } from "./db";
import { sessions, users } from "./schema";

// Adapter links Lucia to your database schema
const adapter = new DrizzleSQLiteAdapter(db, sessions, users);

export const lucia = new Lucia(adapter, {
  sessionCookie: {
    attributes: {
      secure: process.env.NODE_ENV === "production",
    },
  },
  getUserAttributes: (attributes) => ({
    // Expose user attributes to session — typed
    email: attributes.email,
    username: attributes.username,
    role: attributes.role,
  }),
});

// TypeScript augmentation for full type safety
declare module "lucia" {
  interface Register {
    Lucia: typeof lucia;
    DatabaseUserAttributes: {
      email: string;
      username: string;
      role: string;
    };
  }
}

Database Schema (Drizzle SQLite)

// lib/schema.ts
import { sqliteTable, text, integer, blob } from "drizzle-orm/sqlite-core";

export const users = sqliteTable("users", {
  id: text("id").primaryKey(),
  email: text("email").notNull().unique(),
  username: text("username").notNull(),
  hashedPassword: text("hashed_password"),
  role: text("role").notNull().default("user"),
  createdAt: integer("created_at", { mode: "timestamp" }).notNull(),
});

export const sessions = sqliteTable("sessions", {
  id: text("id").primaryKey(),
  userId: text("user_id").notNull().references(() => users.id),
  expiresAt: integer("expires_at", { mode: "timestamp" }).notNull(),
});

// OAuth accounts (for OAuth providers)
export const oauthAccounts = sqliteTable("oauth_accounts", {
  providerName: text("provider_name").notNull(),
  providerUserId: text("provider_user_id").notNull(),
  userId: text("user_id").notNull().references(() => users.id),
});

Email + Password Sign Up

// app/api/auth/signup/route.ts
import { lucia } from "@/lib/auth";
import { db } from "@/lib/db";
import { users } from "@/lib/schema";
import { hash } from "@node-rs/argon2";
import { generateId } from "lucia";
import { cookies } from "next/headers";

export async function POST(req: Request) {
  const { email, password, username } = await req.json();

  // Validate inputs
  if (!email || !password || password.length < 8) {
    return Response.json({ error: "Invalid input" }, { status: 400 });
  }

  // Hash password with Argon2 (recommended for auth)
  const hashedPassword = await hash(password, {
    memoryCost: 19456,
    timeCost: 2,
    outputLen: 32,
    parallelism: 1,
  });

  const userId = generateId(15);  // Lucia's secure ID generator

  // Insert user
  await db.insert(users).values({
    id: userId,
    email,
    username,
    hashedPassword,
    role: "user",
    createdAt: new Date(),
  });

  // Create session
  const session = await lucia.createSession(userId, {});
  const sessionCookie = lucia.createSessionCookie(session.id);

  const cookieStore = await cookies();
  cookieStore.set(
    sessionCookie.name,
    sessionCookie.value,
    sessionCookie.attributes
  );

  return Response.json({ success: true });
}

Session Validation Middleware

// lib/auth-utils.ts
import { lucia } from "@/lib/auth";
import { cookies } from "next/headers";
import { cache } from "react";

// Cached per request — safe to call multiple times
export const validateRequest = cache(async () => {
  const cookieStore = await cookies();
  const sessionId = cookieStore.get(lucia.sessionCookieName)?.value ?? null;

  if (!sessionId) return { user: null, session: null };

  const { user, session } = await lucia.validateSession(sessionId);

  // Refresh session cookie if it's close to expiry
  if (session?.fresh) {
    const sessionCookie = lucia.createSessionCookie(session.id);
    const store = await cookies();
    store.set(sessionCookie.name, sessionCookie.value, sessionCookie.attributes);
  }

  // Clear cookie on invalid session
  if (!session) {
    const blankCookie = lucia.createBlankSessionCookie();
    const store = await cookies();
    store.set(blankCookie.name, blankCookie.value, blankCookie.attributes);
  }

  return { user, session };
});

// Next.js middleware (middleware.ts)
export async function authMiddleware(req: NextRequest) {
  const { user } = await validateRequest();

  if (!user && req.nextUrl.pathname.startsWith("/dashboard")) {
    return NextResponse.redirect(new URL("/login", req.url));
  }
}

GitHub OAuth

import { GitHub, generateState } from "arctic";  // Arctic = OAuth 2.0 library for Lucia

const github = new GitHub(
  process.env.GITHUB_CLIENT_ID!,
  process.env.GITHUB_CLIENT_SECRET!
);

// app/api/auth/github/route.ts
export async function GET() {
  const state = generateState();
  const url = await github.createAuthorizationURL(state, { scopes: ["user:email"] });

  const cookieStore = await cookies();
  cookieStore.set("github_oauth_state", state, {
    path: "/",
    secure: process.env.NODE_ENV === "production",
    httpOnly: true,
    maxAge: 60 * 10,
    sameSite: "lax",
  });

  return Response.redirect(url.toString());
}

// app/api/auth/github/callback/route.ts
export async function GET(req: Request) {
  const url = new URL(req.url);
  const code = url.searchParams.get("code");
  const state = url.searchParams.get("state");

  const cookieStore = await cookies();
  const storedState = cookieStore.get("github_oauth_state")?.value;

  if (!code || !state || state !== storedState) {
    return new Response("Invalid state", { status: 400 });
  }

  const tokens = await github.validateAuthorizationCode(code);
  const githubUser = await fetchGitHubUser(tokens.accessToken());

  // Upsert user and create session
  const userId = await upsertGitHubUser(githubUser);
  const session = await lucia.createSession(userId, {});
  const sessionCookie = lucia.createSessionCookie(session.id);

  cookieStore.set(sessionCookie.name, sessionCookie.value, sessionCookie.attributes);
  return Response.redirect(new URL("/dashboard", req.url));
}

Better Auth provides a complete auth solution — sign-in methods, session management, and plugins for organizations, 2FA, passkeys, and more — all self-hosted.

Installation

npm install better-auth

Server Setup

// lib/auth.ts
import { betterAuth } from "better-auth";
import { drizzleAdapter } from "better-auth/adapters/drizzle";
import { twoFactor, organization, passkey, magicLink } from "better-auth/plugins";
import { db } from "./db";

export const auth = betterAuth({
  database: drizzleAdapter(db, {
    provider: "pg",  // "pg" | "sqlite" | "mysql"
  }),

  emailAndPassword: {
    enabled: true,
    requireEmailVerification: true,
    sendResetPassword: async ({ user, url }) => {
      await sendEmail({ to: user.email, subject: "Reset password", html: `<a href="${url}">Reset</a>` });
    },
  },

  emailVerification: {
    sendOnSignUp: true,
    sendVerificationEmail: async ({ user, url }) => {
      await sendEmail({ to: user.email, subject: "Verify your email", html: `<a href="${url}">Verify</a>` });
    },
  },

  socialProviders: {
    github: {
      clientId: process.env.GITHUB_CLIENT_ID!,
      clientSecret: process.env.GITHUB_CLIENT_SECRET!,
    },
    google: {
      clientId: process.env.GOOGLE_CLIENT_ID!,
      clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
    },
  },

  plugins: [
    twoFactor({
      issuer: "MyApp",
      otpOptions: { digits: 6 },
    }),
    organization({
      allowUserToCreateOrganization: true,
      organizationLimit: 5,
    }),
    passkey({
      rpName: "My App",
      rpID: process.env.NEXT_PUBLIC_APP_URL!,
    }),
    magicLink({
      sendMagicLink: async ({ email, url }) => {
        await sendEmail({ to: email, subject: "Sign in to MyApp", html: `<a href="${url}">Sign in</a>` });
      },
    }),
  ],
});

export type Auth = typeof auth;

Next.js Route Handler

// app/api/auth/[...all]/route.ts
import { auth } from "@/lib/auth";
import { toNextJsHandler } from "better-auth/next-js";

export const { GET, POST } = toNextJsHandler(auth);

Client Setup and Sign-In

// lib/auth-client.ts
import { createAuthClient } from "better-auth/react";
import { twoFactorClient, organizationClient } from "better-auth/client/plugins";

export const authClient = createAuthClient({
  baseURL: process.env.NEXT_PUBLIC_APP_URL!,
  plugins: [twoFactorClient(), organizationClient()],
});

export const {
  signIn,
  signUp,
  signOut,
  useSession,
  organization,
} = authClient;
// components/SignInForm.tsx
import { signIn, signUp } from "@/lib/auth-client";

function SignInForm() {
  const handleEmailSignIn = async (email: string, password: string) => {
    await signIn.email({ email, password, callbackURL: "/dashboard" });
  };

  const handleGitHubSignIn = async () => {
    await signIn.social({ provider: "github", callbackURL: "/dashboard" });
  };

  const handleMagicLink = async (email: string) => {
    await signIn.magicLink({ email, callbackURL: "/dashboard" });
  };

  return (
    <div>
      <button onClick={() => handleGitHubSignIn()}>Sign in with GitHub</button>
      <button onClick={() => handleMagicLink("user@example.com")}>Send Magic Link</button>
    </div>
  );
}

Organizations and RBAC

import { organization } from "@/lib/auth-client";

// Create organization
const org = await organization.create({
  name: "Acme Corp",
  slug: "acme-corp",
});

// Invite member
await organization.inviteMember({
  email: "colleague@example.com",
  role: "member",  // "owner" | "admin" | "member"
  organizationId: org.id,
});

// Check member permissions
const member = await organization.getActiveMember();
const canInvite = member?.role === "owner" || member?.role === "admin";

Stack Auth: Open-Source Clerk Alternative

Stack Auth provides a hosted (or self-hostable) auth service with pre-built UI components and a user management dashboard — the closest open-source equivalent to Clerk.

Installation

npm install @stackframe/stack

Server Setup

// stack.ts
import { StackServerApp } from "@stackframe/stack";

export const stackServerApp = new StackServerApp({
  tokenStore: "nextjs-cookies",  // Uses Next.js cookies for session storage
  // Or: tokenStore: "cookie" for other frameworks
});

Next.js App Router Integration

// app/layout.tsx
import { StackProvider, StackTheme } from "@stackframe/stack";
import { stackServerApp } from "../stack";

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html>
      <body>
        <StackProvider app={stackServerApp}>
          <StackTheme>
            {children}
          </StackTheme>
        </StackProvider>
      </body>
    </html>
  );
}

Pre-Built Auth UI Components

// Stack Auth ships pre-built, customizable auth pages
import { SignIn, SignUp, UserButton, AccountSettings } from "@stackframe/stack";

// Drop-in sign-in page — handles OAuth, email/password, magic links
function SignInPage() {
  return (
    <div>
      <SignIn
        fullPage={true}
        automaticRedirect={true}
        afterSignIn="/dashboard"
      />
    </div>
  );
}

// User avatar button with session management menu
function Header() {
  return (
    <nav>
      <UserButton />
    </nav>
  );
}

// Full account settings page
function SettingsPage() {
  return <AccountSettings fullPage={true} />;
}

Access Current User

import { useUser, useStackApp } from "@stackframe/stack";
import { stackServerApp } from "@/stack";

// Client component
function UserDisplay() {
  const user = useUser({ or: "redirect" });  // Redirects to sign-in if not authenticated

  return (
    <div>
      <p>Hello, {user.displayName}</p>
      <p>Email: {user.primaryEmail}</p>
    </div>
  );
}

// Server component
async function ServerUserDisplay() {
  const user = await stackServerApp.getUser({ or: "redirect" });

  return <p>Hello, {user.displayName}</p>;
}

// Get current user server-side
async function getCurrentUser() {
  const user = await stackServerApp.getUser();
  return user;  // null if not authenticated
}

User Management and Admin

// Server-side user management
const users = await stackServerApp.listUsers();

// Get specific user by ID
const user = await stackServerApp.getUser({ id: "user_id" });

// Update user
await user.update({
  displayName: "New Name",
  clientMetadata: { plan: "premium", company: "Acme" },
});

// Delete user
await user.delete();

Feature Comparison

FeatureLucia v3Better AuthStack Auth
TypeAuth primitivesFull frameworkAuth service (OSS)
Setup complexityHighMediumLow
Pre-built UI❌ (headless)✅ Components
Email + PasswordManual
OAuth providersVia Arctic✅ 20+✅ 20+
Magic linksManual✅ Plugin
Passkeys / WebAuthnManual✅ Plugin
2FA / TOTPManual✅ Plugin
OrganizationsManual✅ Plugin✅ Teams
Admin dashboard✅ Hosted
DB adaptersDrizzle, Prisma, moreDrizzle, Prisma, MongoManaged / Neon
Self-hostable
GitHub stars5.1k6.2k2.9k

When to Use Each

Choose Lucia Auth v3 if:

  • You want full control over every aspect of auth (session format, token storage, cookie behavior)
  • Building a custom auth flow that doesn't fit standard patterns
  • You prefer to own all auth logic and don't want framework magic
  • Academic understanding of session-based auth — Lucia's source code is educational

Choose Better Auth if:

  • You want a complete self-hosted auth system without building OAuth flows from scratch
  • Multi-tenant organization support with member roles is required
  • Passkeys, 2FA, and magic links are needed out of the box
  • You're replacing Clerk/Auth0 but want to keep all their features

Choose Stack Auth if:

  • You're replacing Clerk and want the closest UX match (pre-built components, hosted dashboard)
  • Your team doesn't want to build auth UI — drop in <SignIn /> and you're done
  • User management dashboard is important (customer support team needs to manage users)
  • Open-source but hosted option — start with Stack Auth cloud, self-host later if needed

Methodology

Data sourced from official Lucia v3 documentation (lucia-auth.com), Better Auth documentation (better-auth.com), Stack Auth documentation (stack-auth.com), GitHub star counts as of February 2026, npm download statistics, and community discussions from the Lucia Discord, Better Auth GitHub issues, and r/nextjs.


Related: Clerk vs Auth0 vs Firebase Auth for hosted auth-as-a-service comparisons, or Drizzle ORM vs Prisma vs TypeORM for the database layer that stores auth credentials and sessions.

Comments

Stay Updated

Get the latest package insights, npm trends, and tooling tips delivered to your inbox.