Lucia Auth: Flexible Authentication for TypeScript Apps
Authentication libraries have two failure modes: doing too little (just session utilities, you implement everything) or too much (full-stack magic that breaks when you deviate from their assumptions). Lucia aims for a middle ground.
It provides session management, password hashing, and OAuth utilities — the hard parts — without prescribing your database schema, ORM, or framework. You adapt it to your stack rather than your stack adapting to it.
What Lucia Handles
- Session management: Create, validate, and invalidate sessions with proper security properties
- Password hashing: Argon2 via the Arctic library (modern, secure)
- OAuth integration: GitHub, Google, Discord, and 30+ providers via the Arctic OAuth library
- Database adapters: Prisma, Drizzle, Mongoose, raw SQL (you implement the adapter interface if yours isn't listed)
- Framework adapters: Works with Next.js, SvelteKit, Nuxt, Astro, Hono, Express, and any Node.js server
What Lucia Doesn't Handle
- Email/password signup flow (you build the UI and API routes)
- Authorization/permissions (RBAC, ACL — separate concern)
- Email verification emails (you integrate your mailer)
- Rate limiting (you add middleware)
Installation
npm install lucia arctic # arctic is the OAuth library from the same author
Core Concepts
Lucia works with two entities:
- Users: identified by a unique user ID
- Sessions: time-limited tokens stored in cookies, linked to a user
You create a user record and a session record in your database. Lucia validates sessions on each request. That's the core loop.
Database Adapter
Lucia is database-agnostic. You configure an adapter for your database:
With Drizzle:
import { DrizzlePostgreSQLAdapter } from "@lucia-auth/adapter-drizzle";
import { db } from "./db";
import { sessions, users } from "./schema";
const adapter = new DrizzlePostgreSQLAdapter(db, sessions, users);
Your database needs user and session tables with specific columns. For Drizzle:
// schema.ts
import { pgTable, text, timestamp } from "drizzle-orm/pg-core";
export const users = pgTable("users", {
id: text("id").primaryKey(),
username: text("username").notNull().unique(),
passwordHash: text("password_hash"),
});
export const sessions = pgTable("sessions", {
id: text("id").primaryKey(),
userId: text("user_id")
.notNull()
.references(() => users.id),
expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(),
});
Initialize Lucia
// auth.ts
import { Lucia } from "lucia";
import { DrizzlePostgreSQLAdapter } from "@lucia-auth/adapter-drizzle";
import { db } from "./db";
import { sessions, users } from "./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 {
username: attributes.username,
};
},
});
// TypeScript: declare module for type safety
declare module "lucia" {
interface Register {
Lucia: typeof lucia;
DatabaseUserAttributes: typeof users.$inferSelect;
}
}
Email/Password Authentication
Lucia doesn't generate signup/login routes — you write them. Here's a complete signup handler with Next.js API routes:
// app/api/auth/signup/route.ts
import { generateId } from "lucia";
import { hash } from "@node-rs/argon2";
import { lucia } from "@/lib/auth";
import { db } from "@/lib/db";
import { users } from "@/lib/db/schema";
import { cookies } from "next/headers";
export async function POST(request: Request) {
const body = await request.json();
const { username, password } = body;
// Validate input
if (!username || username.length < 3) {
return Response.json({ error: "Invalid username" }, { status: 400 });
}
if (!password || password.length < 8) {
return Response.json({ error: "Invalid password" }, { status: 400 });
}
const userId = generateId(15);
const passwordHash = await hash(password, {
memoryCost: 19456,
timeCost: 2,
outputLen: 32,
parallelism: 1,
});
try {
await db.insert(users).values({
id: userId,
username,
passwordHash,
});
const session = await lucia.createSession(userId, {});
const sessionCookie = lucia.createSessionCookie(session.id);
(await cookies()).set(sessionCookie.name, sessionCookie.value, sessionCookie.attributes);
return Response.json({ success: true });
} catch (e) {
// Handle unique constraint violation
return Response.json({ error: "Username already taken" }, { status: 400 });
}
}
Session Validation Middleware
Validate the session on each request:
// lib/auth/validate-request.ts
import { lucia } from "@/lib/auth";
import { cookies } from "next/headers";
import { cache } from "react";
export const validateRequest = cache(async () => {
const sessionId = (await cookies()).get(lucia.sessionCookieName)?.value ?? null;
if (!sessionId) {
return { user: null, session: null };
}
const { user, session } = await lucia.validateSession(sessionId);
try {
if (session?.fresh) {
// Extend session if still valid
const sessionCookie = lucia.createSessionCookie(session.id);
(await cookies()).set(sessionCookie.name, sessionCookie.value, sessionCookie.attributes);
}
if (!session) {
const sessionCookie = lucia.createBlankSessionCookie();
(await cookies()).set(sessionCookie.name, sessionCookie.value, sessionCookie.attributes);
}
} catch {}
return { user, session };
});
Use in server components:
const { user } = await validateRequest();
if (!user) redirect("/login");
OAuth with GitHub
Using Arctic for OAuth:
// lib/auth/oauth.ts
import { GitHub } from "arctic";
export const github = new GitHub(
process.env.GITHUB_CLIENT_ID!,
process.env.GITHUB_CLIENT_SECRET!
);
OAuth callback handler:
// app/api/auth/github/callback/route.ts
import { github, lucia } from "@/lib/auth";
import { OAuth2RequestError, generateCodeVerifier } from "arctic";
import { db } from "@/lib/db";
import { users } from "@/lib/db/schema";
import { generateId } from "lucia";
import { cookies } from "next/headers";
import { eq } from "drizzle-orm";
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 = (await cookies()).get("github_oauth_state")?.value ?? null;
if (!code || !state || !storedState || state !== storedState) {
return new Response(null, { status: 400 });
}
try {
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();
// Check if user exists
const existingUser = await db.query.users.findFirst({
where: eq(users.githubId, githubUser.id),
});
if (existingUser) {
const session = await lucia.createSession(existingUser.id, {});
const sessionCookie = lucia.createSessionCookie(session.id);
(await cookies()).set(sessionCookie.name, sessionCookie.value, sessionCookie.attributes);
return new Response(null, { status: 302, headers: { Location: "/" } });
}
// Create new user
const userId = generateId(15);
await db.insert(users).values({
id: userId,
githubId: githubUser.id,
username: githubUser.login,
});
const session = await lucia.createSession(userId, {});
const sessionCookie = lucia.createSessionCookie(session.id);
(await cookies()).set(sessionCookie.name, sessionCookie.value, sessionCookie.attributes);
return new Response(null, { status: 302, headers: { Location: "/" } });
} catch (e) {
if (e instanceof OAuth2RequestError) {
return new Response(null, { status: 400 });
}
return new Response(null, { status: 500 });
}
}
Lucia vs. Auth.js (NextAuth)
| Aspect | Lucia | Auth.js (NextAuth) |
|---|---|---|
| Flexibility | High (you write routes) | High (but with conventions) |
| Magic | Minimal | More opinionated |
| Database | Any via adapters | Many adapters |
| Session approach | Database sessions | JWT or database sessions |
| Email/password | Manual | Credentials provider |
| OAuth | Via Arctic library | Built-in providers |
| TypeScript | Excellent | Good |
| Complexity | Medium | Medium |
Auth.js (NextAuth v5) works well for projects that fit its conventions. Lucia gives more control when you need it — especially useful if you have specific session storage requirements or need non-standard authentication flows.
Lucia's documentation at lucia-auth.com includes framework-specific guides for Next.js, SvelteKit, Astro, Hono, and others.