Lucia is a TypeScript authentication library for database-backed sessions — new Lucia(adapter, options) creates the instance. lucia.createSession(userId, {}) creates a new session and returns a Session. lucia.validateRequestCookie(Request) validates the session cookie and returns { session, user }. lucia.createSessionCookie(session.id) creates the cookie header. lucia.invalidateSession(sessionId) logs out. lucia.createBlankSessionCookie() clears the cookie. The database adapter (Drizzle, Prisma, MongoDB, etc.) stores sessions in your schema. getUserAttributes maps database columns to User. Arctic is Lucia’s companion OAuth library — arctic.GitHub(clientId, clientSecret), arctic.Google(...), and others generate authorization URLs and exchange codes for tokens. Passwords use Argon2 via argon2 package. Multi-device: lucia.getUserSessions(userId) lists all active sessions. Claude Code generates Lucia email/password auth, OAuth with GitHub/Google, session middleware, and protected page patterns.
CLAUDE.md for Lucia
## Lucia Stack
- Version: lucia >= 3.2, arctic >= 1.9, @lucia-auth/adapter-drizzle >= 1.1
- Init: const lucia = new Lucia(adapter, { sessionCookie: { attributes: { secure: isProd } }, getUserAttributes: (attrs) => ({ ...attrs }) })
- Create: const session = await lucia.createSession(userId, {}); const cookie = lucia.createSessionCookie(session.id)
- Validate: const { session, user } = await lucia.validateRequestCookie(reqCookies) — cookies() in Next.js
- Invalidate: await lucia.invalidateSession(sessionId)
- Blank: lucia.createBlankSessionCookie() — clear on logout
- Types: declare module "lucia" { interface Register { Lucia: typeof lucia; DatabaseUserAttributes: UserRow } }
Lucia Initialization
// lib/auth/lucia.ts — Lucia instance with Drizzle adapter
import { Lucia } from "lucia"
import { DrizzlePostgreSQLAdapter } from "@lucia-auth/adapter-drizzle"
import { db } from "@/lib/db"
import { sessions, users } from "@/lib/db/schema"
const adapter = new DrizzlePostgreSQLAdapter(db, sessions, users)
export const lucia = new Lucia(adapter, {
sessionCookie: {
name: "auth_session",
attributes: {
secure: process.env.NODE_ENV === "production",
sameSite: "lax",
path: "/",
},
},
sessionExpiresIn: new TimeSpan(30, "d"),
getUserAttributes: (attributes) => ({
id: attributes.id,
email: attributes.email,
name: attributes.name,
role: attributes.role,
avatarUrl: attributes.avatarUrl,
emailVerified: attributes.emailVerified,
}),
})
// TypeScript — register Lucia types
declare module "lucia" {
interface Register {
Lucia: typeof lucia
DatabaseUserAttributes: {
id: string
email: string
name: string
role: "user" | "admin"
avatarUrl: string | null
emailVerified: boolean
}
}
}
Database Schema (Drizzle)
// lib/db/schema/auth.ts — Lucia-compatible tables
import { pgTable, text, timestamp, boolean } from "drizzle-orm/pg-core"
export const users = pgTable("users", {
id: text("id").primaryKey(),
email: text("email").notNull().unique(),
name: text("name").notNull(),
passwordHash: text("password_hash"),
role: text("role", { enum: ["user", "admin"] }).notNull().default("user"),
avatarUrl: text("avatar_url"),
emailVerified: boolean("email_verified").notNull().default(false),
githubId: text("github_id").unique(),
createdAt: timestamp("created_at").notNull().defaultNow(),
})
export const sessions = pgTable("sessions", {
id: text("id").primaryKey(),
userId: text("user_id").notNull().references(() => users.id, { onDelete: "cascade" }),
expiresAt: timestamp("expires_at", { withTimezone: true, mode: "date" }).notNull(),
})
Auth Server Actions
// lib/auth/actions.ts — Server Actions for login/register/logout
"use server"
import { lucia } from "@/lib/auth/lucia"
import { db } from "@/lib/db"
import { users, sessions } from "@/lib/db/schema"
import { cookies } from "next/headers"
import { redirect } from "next/navigation"
import { hash, verify } from "@node-rs/argon2"
import { generateId } from "lucia"
import { eq } from "drizzle-orm"
import { z } from "zod"
const argon2Config = {
memoryCost: 19456, // 19 MiB
timeCost: 2,
outputLen: 32,
parallelism: 1,
}
// Login action
export async function login(formData: FormData) {
const parsed = z.object({
email: z.string().email(),
password: z.string().min(1),
}).safeParse({
email: formData.get("email"),
password: formData.get("password"),
})
if (!parsed.success) {
return { error: "Invalid input" }
}
const { email, password } = parsed.data
const user = await db.query.users.findFirst({
where: eq(users.email, email.toLowerCase()),
})
if (!user || !user.passwordHash) {
// Timing-safe: hash even on not found to prevent enumeration
await hash("dummy", argon2Config)
return { error: "Invalid email or password" }
}
const validPassword = await verify(user.passwordHash, password, argon2Config)
if (!validPassword) {
return { error: "Invalid email or password" }
}
const session = await lucia.createSession(user.id, {})
const sessionCookie = lucia.createSessionCookie(session.id)
const cookieStore = await cookies()
cookieStore.set(
sessionCookie.name,
sessionCookie.value,
sessionCookie.attributes,
)
redirect("/dashboard")
}
// Register action
export async function register(formData: FormData) {
const parsed = z.object({
email: z.string().email(),
password: z.string().min(8, "Password must be at least 8 characters"),
name: z.string().min(1).max(100),
}).safeParse({
email: formData.get("email"),
password: formData.get("password"),
name: formData.get("name"),
})
if (!parsed.success) {
return { error: parsed.error.issues[0]?.message ?? "Invalid input" }
}
const { email, password, name } = parsed.data
const existing = await db.query.users.findFirst({
where: eq(users.email, email.toLowerCase()),
})
if (existing) {
return { error: "Email already registered" }
}
const userId = generateId(15)
const passwordHash = await hash(password, argon2Config)
await db.insert(users).values({
id: userId,
email: email.toLowerCase(),
name,
passwordHash,
role: "user",
emailVerified: false,
})
// Auto-login after register
const session = await lucia.createSession(userId, {})
const sessionCookie = lucia.createSessionCookie(session.id)
const cookieStore = await cookies()
cookieStore.set(sessionCookie.name, sessionCookie.value, sessionCookie.attributes)
redirect("/dashboard")
}
// Logout action
export async function logout() {
const cookieStore = await cookies()
const sessionId = cookieStore.get(lucia.sessionCookieName)?.value
if (sessionId) {
await lucia.invalidateSession(sessionId)
}
const blankCookie = lucia.createBlankSessionCookie()
cookieStore.set(blankCookie.name, blankCookie.value, blankCookie.attributes)
redirect("/sign-in")
}
Session Validation Utility
// lib/auth/session.ts — validate session in Server Components and middleware
import { lucia } from "./lucia"
import { cookies } from "next/headers"
import { cache } from "react"
// cache() deduplicates across a single request
export const validateSession = cache(async () => {
const cookieStore = await cookies()
const sessionId = cookieStore.get(lucia.sessionCookieName)?.value
if (!sessionId) {
return { session: null, user: null }
}
const result = await lucia.validateSession(sessionId)
// Refresh session cookie if close to expiry
if (result.session?.fresh) {
const refreshedCookie = lucia.createSessionCookie(result.session.id)
cookieStore.set(refreshedCookie.name, refreshedCookie.value, refreshedCookie.attributes)
}
if (!result.session) {
const blankCookie = lucia.createBlankSessionCookie()
cookieStore.set(blankCookie.name, blankCookie.value, blankCookie.attributes)
}
return result
})
// Require auth — throw redirect if not authenticated
export async function requireSession() {
const { session, user } = await validateSession()
if (!session || !user) {
// Import redirect at call site to avoid Next.js issues
const { redirect } = await import("next/navigation")
redirect("/sign-in")
}
return { session, user }
}
GitHub OAuth with Arctic
// lib/auth/github.ts — GitHub OAuth with Arctic
import { GitHub } from "arctic"
import { lucia } from "./lucia"
import { db } from "@/lib/db"
import { users } from "@/lib/db/schema"
import { generateId } from "lucia"
import { eq } from "drizzle-orm"
import { cookies } from "next/headers"
export const github = new GitHub(
process.env.GITHUB_CLIENT_ID!,
process.env.GITHUB_CLIENT_SECRET!,
)
// app/auth/github/route.ts — redirect to GitHub
export async function GET() {
const state = generateId(40)
const cookieStore = await cookies()
cookieStore.set("github_oauth_state", state, { httpOnly: true, maxAge: 600, path: "/" })
const url = await github.createAuthorizationURL(state, { scopes: ["user:email"] })
return Response.redirect(url.toString())
}
// app/auth/github/callback/route.ts — exchange code for user
export async function handleGitHubCallback(request: Request) {
const { searchParams } = new URL(request.url)
const code = searchParams.get("code")
const state = 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 fetch("https://api.github.com/user", {
headers: { Authorization: `Bearer ${tokens.accessToken}` },
}).then(r => r.json())
// Get primary email
const emails = await fetch("https://api.github.com/user/emails", {
headers: { Authorization: `Bearer ${tokens.accessToken}` },
}).then(r => r.json())
const primaryEmail = emails.find((e: any) => e.primary)?.email
// Upsert user
let userId: string
const existing = await db.query.users.findFirst({
where: eq(users.githubId, String(githubUser.id)),
})
if (existing) {
userId = existing.id
} else {
userId = generateId(15)
await db.insert(users).values({
id: userId,
email: primaryEmail ?? `github-${githubUser.id}@placeholder.com`,
name: githubUser.name ?? githubUser.login,
githubId: String(githubUser.id),
avatarUrl: githubUser.avatar_url,
emailVerified: true,
role: "user",
})
}
const session = await lucia.createSession(userId, {})
const cookie = lucia.createSessionCookie(session.id)
cookieStore.set(cookie.name, cookie.value, cookie.attributes)
return Response.redirect(new URL("/dashboard", request.url))
}
For the Better Auth alternative when multiple social providers, organizations/teams, API keys, two-factor Auth, and a richer plugin ecosystem out of the box are needed without writing adapter code — Better Auth handles more use cases with less configuration than Lucia, though Lucia gives finer control over session storage and behavior, see the Better Auth guide. For the Passport.js alternative when integrating auth into an existing Express.js application with an established strategy ecosystem covering 500+ providers including SAML and OpenID Connect — Passport.js has wider strategy coverage while Lucia is purpose-built for Next.js/Remix and newer TypeScript-first apps, see the Passport.js guide. The Claude Skills 360 bundle includes Lucia skill sets covering session auth, OAuth, and multi-device sessions. Start with the free tier to try Lucia authentication generation.