Skip to main content
Technology & EngineeringAuth Services321 lines

Lucia

Build with Lucia for lightweight, self-hosted authentication. Use this skill when

Quick Summary29 lines
You are an auth specialist who integrates Lucia into projects. Lucia is a minimal,
open-source auth library focused on session management. It's not a framework — it
gives you the primitives to build auth exactly how you want it, with zero vendor
lock-in and no opinionated abstractions.

## Key Points

- Use Argon2 for password hashing — it's the current best practice over bcrypt
- Always refresh session cookies on validation (Lucia does this with `fresh` sessions)
- Use `generateIdFromEntropySize()` for user IDs — cryptographically secure
- Use Arctic for OAuth — it handles the OAuth flow without a heavy abstraction
- Store the session cookie as httpOnly, secure, sameSite: lax
- Invalidate sessions on logout — don't just clear the cookie
- Using MD5/SHA for password hashing — use Argon2 or bcrypt
- Not validating sessions on every request — expired sessions should be caught
- Storing session data in the cookie instead of the database
- Not clearing the session cookie on failed validation — user stays in limbo
- Building OAuth flows from scratch when Arctic handles it
- Skipping CSRF protection on auth forms

## Quick Example

```bash
npm install lucia
npm install arctic   # For OAuth providers
npm install @node-rs/argon2  # For password hashing
```
skilldb get auth-services-skills/LuciaFull skill: 321 lines
Paste into your CLAUDE.md or agent config

Lucia Authentication Integration

You are an auth specialist who integrates Lucia into projects. Lucia is a minimal, open-source auth library focused on session management. It's not a framework — it gives you the primitives to build auth exactly how you want it, with zero vendor lock-in and no opinionated abstractions.

Core Philosophy

Sessions, not magic

Lucia manages sessions — creating them, validating them, invalidating them. It doesn't dictate how users sign in. You build the login flow (password, OAuth, magic link), and Lucia handles the session lifecycle after authentication.

Zero dependencies, full control

Lucia has no external dependencies. It doesn't call third-party services, doesn't require an API key, and doesn't phone home. Your auth runs entirely in your infrastructure. You own every line of the flow.

Database-agnostic

Lucia works with any database through a simple adapter interface. Provide four functions (create session, get session, update session expiry, delete session) and Lucia handles the rest.

Setup

Install

npm install lucia
npm install arctic   # For OAuth providers
npm install @node-rs/argon2  # For password hashing

Initialize

// lib/auth.ts
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) => ({
    email: attributes.email,
    name: attributes.name,
  }),
});

declare module 'lucia' {
  interface Register {
    Lucia: typeof lucia;
    DatabaseUserAttributes: {
      email: string;
      name: string;
    };
  }
}

Database schema (Drizzle)

// db/schema.ts
import { pgTable, text, timestamp } from 'drizzle-orm/pg-core';

export const users = pgTable('users', {
  id: text('id').primaryKey(),
  email: text('email').notNull().unique(),
  name: text('name'),
  hashedPassword: text('hashed_password'),
});

export const sessions = pgTable('sessions', {
  id: text('id').primaryKey(),
  userId: text('user_id').notNull().references(() => users.id),
  expiresAt: timestamp('expires_at', { withTimezone: true, mode: 'date' }).notNull(),
});

Key Techniques

Session validation middleware

// lib/auth.ts
import { cookies } from 'next/headers';
import type { Session, User } from 'lucia';

export async function validateRequest(): Promise<
  { user: User; session: Session } | { user: null; session: null }
> {
  const sessionId = (await cookies()).get(lucia.sessionCookieName)?.value ?? null;
  if (!sessionId) return { user: null, session: null };

  const result = await lucia.validateSession(sessionId);

  try {
    if (result.session?.fresh) {
      const sessionCookie = lucia.createSessionCookie(result.session.id);
      (await cookies()).set(sessionCookie.name, sessionCookie.value, sessionCookie.attributes);
    }
    if (!result.session) {
      const blankCookie = lucia.createBlankSessionCookie();
      (await cookies()).set(blankCookie.name, blankCookie.value, blankCookie.attributes);
    }
  } catch {}

  return result;
}

Password signup

// app/signup/action.ts
'use server';
import { hash } from '@node-rs/argon2';
import { generateIdFromEntropySize } from 'lucia';
import { lucia } from '@/lib/auth';
import { db } from '@/lib/db';
import { users } from '@/lib/db/schema';
import { cookies } from 'next/headers';

export async function signup(formData: FormData) {
  const email = formData.get('email') as string;
  const password = formData.get('password') as string;

  const hashedPassword = await hash(password, {
    memoryCost: 19456,
    timeCost: 2,
    outputLen: 32,
    parallelism: 1,
  });

  const userId = generateIdFromEntropySize(10);

  await db.insert(users).values({
    id: userId,
    email,
    hashedPassword,
  });

  const session = await lucia.createSession(userId, {});
  const sessionCookie = lucia.createSessionCookie(session.id);
  (await cookies()).set(sessionCookie.name, sessionCookie.value, sessionCookie.attributes);

  redirect('/dashboard');
}

Password login

'use server';
import { verify } from '@node-rs/argon2';
import { lucia } from '@/lib/auth';
import { db } from '@/lib/db';
import { users } from '@/lib/db/schema';
import { eq } from 'drizzle-orm';

export async function login(formData: FormData) {
  const email = formData.get('email') as string;
  const password = formData.get('password') as string;

  const user = await db.query.users.findFirst({
    where: eq(users.email, email),
  });

  if (!user?.hashedPassword) {
    return { error: 'Invalid email or password' };
  }

  const valid = await verify(user.hashedPassword, password);
  if (!valid) {
    return { error: 'Invalid email or password' };
  }

  const session = await lucia.createSession(user.id, {});
  const sessionCookie = lucia.createSessionCookie(session.id);
  (await cookies()).set(sessionCookie.name, sessionCookie.value, sessionCookie.attributes);

  redirect('/dashboard');
}

Logout

'use server';
import { lucia } from '@/lib/auth';
import { validateRequest } from '@/lib/auth';
import { cookies } from 'next/headers';

export async function logout() {
  const { session } = await validateRequest();
  if (!session) redirect('/login');

  await lucia.invalidateSession(session.id);
  const blankCookie = lucia.createBlankSessionCookie();
  (await cookies()).set(blankCookie.name, blankCookie.value, blankCookie.attributes);

  redirect('/login');
}

OAuth with Arctic

// lib/oauth.ts
import { GitHub } from 'arctic';

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

// app/login/github/route.ts
import { generateState } from 'arctic';
import { github } from '@/lib/oauth';

export async function GET() {
  const state = generateState();
  const url = github.createAuthorizationURL(state, ['user:email']);

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

  return Response.redirect(url);
}

// app/login/github/callback/route.ts
import { github } from '@/lib/oauth';
import { lucia } from '@/lib/auth';

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 || 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(res => res.json());

  // Find or create user
  let user = await db.query.users.findFirst({
    where: eq(users.email, githubUser.email),
  });

  if (!user) {
    const userId = generateIdFromEntropySize(10);
    [user] = await db.insert(users).values({
      id: userId,
      email: githubUser.email,
      name: githubUser.name,
    }).returning();
  }

  const session = await lucia.createSession(user.id, {});
  const sessionCookie = lucia.createSessionCookie(session.id);
  (await cookies()).set(sessionCookie.name, sessionCookie.value, sessionCookie.attributes);

  return new Response(null, {
    status: 302,
    headers: { Location: '/dashboard' },
  });
}

Protected pages

import { validateRequest } from '@/lib/auth';
import { redirect } from 'next/navigation';

export default async function DashboardPage() {
  const { user } = await validateRequest();
  if (!user) redirect('/login');

  return <div>Welcome {user.name}</div>;
}

Best Practices

  • Use Argon2 for password hashing — it's the current best practice over bcrypt
  • Always refresh session cookies on validation (Lucia does this with fresh sessions)
  • Use generateIdFromEntropySize() for user IDs — cryptographically secure
  • Use Arctic for OAuth — it handles the OAuth flow without a heavy abstraction
  • Store the session cookie as httpOnly, secure, sameSite: lax
  • Invalidate sessions on logout — don't just clear the cookie

Anti-Patterns

  • Using MD5/SHA for password hashing — use Argon2 or bcrypt
  • Not validating sessions on every request — expired sessions should be caught
  • Storing session data in the cookie instead of the database
  • Not clearing the session cookie on failed validation — user stays in limbo
  • Building OAuth flows from scratch when Arctic handles it
  • Skipping CSRF protection on auth forms

Install this skill directly: skilldb add auth-services-skills

Get CLI access →