Lucia Auth v3 vs Better Auth vs Stack Auth: Self-Hosted Auth 2026
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: Full-Featured Auth Framework
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
| Feature | Lucia v3 | Better Auth | Stack Auth |
|---|---|---|---|
| Type | Auth primitives | Full framework | Auth service (OSS) |
| Setup complexity | High | Medium | Low |
| Pre-built UI | ❌ | ❌ (headless) | ✅ Components |
| Email + Password | Manual | ✅ | ✅ |
| OAuth providers | Via Arctic | ✅ 20+ | ✅ 20+ |
| Magic links | Manual | ✅ Plugin | ✅ |
| Passkeys / WebAuthn | Manual | ✅ Plugin | ✅ |
| 2FA / TOTP | Manual | ✅ Plugin | ✅ |
| Organizations | Manual | ✅ Plugin | ✅ Teams |
| Admin dashboard | ❌ | ❌ | ✅ Hosted |
| DB adapters | Drizzle, Prisma, more | Drizzle, Prisma, Mongo | Managed / Neon |
| Self-hostable | ✅ | ✅ | ✅ |
| GitHub stars | 5.1k | 6.2k | 2.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.