← All posts
·15 min read

Claude Code with Lucia: Session Auth Without the Footguns

Claude CodeLuciaAuthenticationTypeScript
Claude Code with Lucia: session authentication without the footguns

Why Lucia without CLAUDE.md ships with broken sessions

Lucia is the session-based authentication library that became the default escape hatch for developers who wanted control over their auth flow without writing the whole thing themselves. It exposes the session primitives directly: create a session, validate a session, invalidate a session, manage the cookie. No vendor lock-in, no opaque middleware, no third-party redirect dance. The trade-off is that every decision about cookie attributes, session expiry, refresh windows, and adapter wiring sits with you.

Claude Code without explicit constraints generates Lucia code that compiles, runs in development, and quietly fails in production. The most common failure modes: cookies set without httpOnly or secure flags, session validation that does not check expiry, adapter setup that does not match the schema you actually have in the database, and OAuth callback handlers that store provider tokens in plaintext. None of these surface as TypeScript errors. They surface as security incidents three months later.

This guide covers the CLAUDE.md template that locks Claude Code into Lucia's correct model: the validateSession tuple handling pattern, the adapter selection logic for Drizzle, Prisma, and the major Postgres clients, the cookie attribute defaults that pass a security audit, and the OAuth provider wiring that does not leak access tokens. For the broader auth landscape, Claude Code with NextAuth and Claude Code with Better Auth cover the alternatives you may want to compare against before committing.

Lucia is in maintenance, why are we writing this

Lucia v3 is in maintenance mode as of late 2024. The author has redirected new development to the oslo primitives and to Better Auth as the recommended higher-level library. The Lucia v3 codebase is stable, the documentation is still online, and there are tens of thousands of production deployments running it today.

If you have a Lucia codebase already, you do not need to migrate immediately. Lucia v3 will continue to receive security patches. If you are starting a new project in 2026, the right call is usually Better Auth or NextAuth, unless you specifically want the session-only, no-frills primitive model that Lucia provides. This guide is for the first group: developers running Lucia in production who want Claude Code to generate correct integration code against the existing codebase.

The Lucia CLAUDE.md template

The CLAUDE.md at your project root needs to declare: the Lucia version, the adapter package and configuration, the cookie attribute policy, the session validation pattern, the OAuth provider setup if applicable, and the hard rules that block the mistakes Claude generates most often.

# Lucia auth rules

## Stack
- lucia ^3.x, @lucia-auth/adapter-drizzle ^1.x
- Drizzle ORM with Postgres (or your adapter of choice)
- TypeScript 5.x strict
- Node.js 20.x (or Next.js 14.x route handlers)

## Project structure
- src/lib/auth.ts          , Lucia instance + adapter
- src/lib/session.ts       , validateRequest() wrapper for route handlers
- src/db/schema.ts         , user + session tables
- src/app/api/auth/*       , route handlers for login, logout, callback
- src/middleware.ts        , optional, for global session attachment

## Session validation (MANDATORY pattern)
Every protected route MUST call validateRequest() at the top, NOT lucia.validateSession() directly.
validateRequest() reads the cookie, calls validateSession, returns { user, session } | { user: null, session: null }.

const { user, session } = await validateRequest();
if (!user) return new Response('Unauthorized', { status: 401 });

## Cookie attributes (NON-NEGOTIABLE)
Lucia generates the Set-Cookie string via lucia.createSessionCookie(). The flags MUST be:
- httpOnly: true
- secure: true (in production)
- sameSite: 'lax' (or 'strict' for higher security flows)
- path: '/'
- name: 'auth_session' (or your chosen name, kept consistent)

NEVER manually set the cookie string. ALWAYS use lucia.createSessionCookie(session.id).serialize()
or the Next.js cookies() API with the attributes Lucia provides.

## Hard rules
- NEVER store passwords as plaintext, ALWAYS hash with Argon2id (via oslo/password)
- NEVER skip the session.fresh check when extending session expiry
- NEVER trust the user object from the cookie alone, always re-validate the session
- NEVER set httpOnly: false on the session cookie
- NEVER log session IDs in any environment
- NEVER expose lucia.validateSession() directly in client-callable code
- ALWAYS invalidate sessions on logout via lucia.invalidateSession(session.id)
- ALWAYS use the blank session cookie pattern on logout: lucia.createBlankSessionCookie()

The session validation pattern is the rule that blocks the largest class of bugs. Claude tends to call lucia.validateSession(sessionId) directly from route handlers, which means every route reads the cookie, parses the ID, and calls the validator. The wrapper function validateRequest() centralises this and gives you a single place to add caching, logging, or audit hooks later.

The cookie attribute rule is the one that gates a security audit. Without httpOnly, any JavaScript on the page can read the session cookie via document.cookie, including any malicious script injected via XSS. Without secure, the cookie travels over HTTP in addition to HTTPS. Without sameSite, the cookie is sent on cross-origin POST requests, opening CSRF. Lucia's createSessionCookie() sets these by default, but Claude often regenerates the cookie string manually and drops the flags.

Install and adapter setup

Install Lucia and the adapter for your database. The Drizzle adapter is the most common choice in 2026 for new TypeScript projects.

npm i lucia @lucia-auth/adapter-drizzle
npm i oslo  # password hashing + OAuth

The Drizzle schema for user and session tables:

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

export const userTable = pgTable('user', {
  id: text('id').primaryKey(),
  email: text('email').notNull().unique(),
  passwordHash: text('password_hash').notNull(),
});

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

The Lucia instance and adapter wiring:

// src/lib/auth.ts
import { Lucia } from 'lucia';
import { DrizzlePostgreSQLAdapter } from '@lucia-auth/adapter-drizzle';
import { db } from '@/db';
import { sessionTable, userTable } from '@/db/schema';

const adapter = new DrizzlePostgreSQLAdapter(db, sessionTable, userTable);

export const lucia = new Lucia(adapter, {
  sessionCookie: {
    attributes: {
      secure: process.env.NODE_ENV === 'production',
    },
  },
  getUserAttributes: (attributes) => ({
    email: attributes.email,
  }),
});

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

The declare module block tells the Lucia type system what shape the user attributes have in your database. Without it, user.email in your code will be typed as unknown and you will reach for unsafe casts. Claude omits this block by default because it lives outside the standard adapter setup snippet. Add the pattern to CLAUDE.md as a concrete example.

For Postgres directly without Drizzle, the adapter package is @lucia-auth/adapter-postgres and accepts a pg client. For Prisma, it is @lucia-auth/adapter-prisma and accepts a Prisma client. For SQLite via better-sqlite3, it is @lucia-auth/adapter-sqlite. The schema fields are the same in all cases: id, user_id, expires_at on the session table; id plus your chosen attributes on the user table.

The validateRequest pattern

The session validation wrapper is the single most important file in your auth setup. Every route handler imports from this file. Every middleware reads from this file. Centralising the session read makes the cookie handling, session refresh, and audit logging consistent across the application.

// src/lib/session.ts
import { cookies } from 'next/headers';
import { cache } from 'react';
import type { Session, User } from 'lucia';
import { lucia } from '@/lib/auth';

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

    const result = await lucia.validateSession(sessionId);

    try {
      if (result.session && result.session.fresh) {
        const sessionCookie = lucia.createSessionCookie(result.session.id);
        cookies().set(
          sessionCookie.name,
          sessionCookie.value,
          sessionCookie.attributes,
        );
      }
      if (!result.session) {
        const sessionCookie = lucia.createBlankSessionCookie();
        cookies().set(
          sessionCookie.name,
          sessionCookie.value,
          sessionCookie.attributes,
        );
      }
    } catch {
      // cookies().set throws in some Next.js contexts (server components)
      // The blank/fresh cookie write is best-effort, the validation result is what matters
    }

    return result;
  },
);

Three details in this file matter and Claude misses them without explicit instruction.

The cache() wrapper from React means that within a single request, multiple calls to validateRequest() from different server components or route handlers hit the database once. Without it, every component that needs the user object triggers a fresh session lookup, which adds 5 to 20ms per call and multiplies on pages with several auth-gated components.

The result.session.fresh check is how Lucia signals that the session was extended. Lucia automatically extends sessions when they cross the halfway point of their expiry window. When the session is fresh, you need to write the new cookie back to the response so the browser stores the extended expiry. Skipping this means the user gets logged out at the original expiry even though Lucia internally extended the session.

The try/catch around cookies().set is necessary because Next.js server components cannot write cookies. validateRequest() is called from both route handlers (where cookies can be written) and server components (where they cannot). The try/catch swallows the error from the server component case while preserving the validation result.

Login route handler

The login route reads the email and password, validates them against the database, hashes the password with Argon2id (via oslo/password), and creates a session.

// src/app/api/auth/login/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { Argon2id } from 'oslo/password';
import { cookies } from 'next/headers';
import { lucia } from '@/lib/auth';
import { db } from '@/db';
import { userTable } from '@/db/schema';
import { eq } from 'drizzle-orm';

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

  if (typeof email !== 'string' || typeof password !== 'string') {
    return NextResponse.json({ error: 'Invalid input' }, { status: 400 });
  }

  const [user] = await db
    .select()
    .from(userTable)
    .where(eq(userTable.email, email))
    .limit(1);

  // Constant-time check: always hash even if user does not exist
  const validPassword = user
    ? await new Argon2id().verify(user.passwordHash, password)
    : await new Argon2id().verify(
        '$argon2id$v=19$m=19456,t=2,p=1$placeholder$placeholder',
        password,
      );

  if (!user || !validPassword) {
    return NextResponse.json({ error: 'Invalid credentials' }, { status: 401 });
  }

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

  cookies().set(
    sessionCookie.name,
    sessionCookie.value,
    sessionCookie.attributes,
  );

  return NextResponse.json({ ok: true });
}

The constant-time check matters more than Claude's default suggests. A naive implementation returns early when the user does not exist, which makes the response time for "user does not exist" measurably faster than "user exists, password wrong". Attackers can use this timing difference to enumerate valid email addresses. The pattern above always runs the Argon2id verify, even against a placeholder hash, so both branches take roughly the same time.

Claude generates the naive version by default because the constant-time consideration is not part of the basic Lucia tutorial. Add it to CLAUDE.md:

## Login flow constant-time check
- ALWAYS run Argon2id verify even when the user lookup returns nothing
- Use a placeholder hash for the missing-user branch
- This prevents email enumeration via timing analysis

Logout route handler

The logout route invalidates the session in the database and writes a blank cookie to clear it from the browser.

// src/app/api/auth/logout/route.ts
import { NextResponse } from 'next/server';
import { cookies } from 'next/headers';
import { lucia } from '@/lib/auth';
import { validateRequest } from '@/lib/session';

export async function POST() {
  const { session } = await validateRequest();
  if (!session) {
    return NextResponse.json({ error: 'No session' }, { status: 401 });
  }

  await lucia.invalidateSession(session.id);

  const sessionCookie = lucia.createBlankSessionCookie();
  cookies().set(
    sessionCookie.name,
    sessionCookie.value,
    sessionCookie.attributes,
  );

  return NextResponse.json({ ok: true });
}

The blank cookie pattern is the only correct way to clear a Lucia session cookie. Manually deleting the cookie via cookies().delete() works in most browsers but does not propagate the Set-Cookie header that older clients and CDN edge caches expect. The blank cookie sets the same name with an empty value and Max-Age=0, which all clients honour.

OAuth provider wiring

Lucia does not ship OAuth provider clients itself. The arctic package (from the same maintainer) provides OAuth 2.0 and OIDC clients for the major providers: GitHub, Google, Discord, Apple, Microsoft, Twitch, and dozens more. The pattern is the same for all of them: redirect to the provider, handle the callback, exchange the code for tokens, fetch the user profile, create or look up a local user record, create a Lucia session.

npm i arctic

The GitHub flow as a worked example:

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

export const github = new GitHub(
  process.env.GITHUB_CLIENT_ID!,
  process.env.GITHUB_CLIENT_SECRET!,
);
// src/app/api/auth/github/route.ts
import { generateState } from 'arctic';
import { cookies } from 'next/headers';
import { NextResponse } from 'next/server';
import { github } from '@/lib/oauth';

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

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

  return NextResponse.redirect(url);
}
// src/app/api/auth/github/callback/route.ts
import { OAuth2RequestError } from 'arctic';
import { cookies } from 'next/headers';
import { NextRequest, NextResponse } from 'next/server';
import { github } from '@/lib/oauth';
import { lucia } from '@/lib/auth';
import { db } from '@/db';
import { userTable } from '@/db/schema';
import { eq } from 'drizzle-orm';
import { generateIdFromEntropySize } from 'lucia';

export async function GET(req: NextRequest) {
  const code = req.nextUrl.searchParams.get('code');
  const state = req.nextUrl.searchParams.get('state');
  const storedState = cookies().get('github_oauth_state')?.value ?? null;

  if (!code || !state || !storedState || state !== storedState) {
    return new NextResponse('Invalid state', { 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();

    const [existingUser] = await db
      .select()
      .from(userTable)
      .where(eq(userTable.email, githubUser.email))
      .limit(1);

    let userId: string;
    if (existingUser) {
      userId = existingUser.id;
    } else {
      userId = generateIdFromEntropySize(10);
      await db.insert(userTable).values({
        id: userId,
        email: githubUser.email,
        passwordHash: '',  // OAuth users have no password
      });
    }

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

    return NextResponse.redirect(new URL('/dashboard', req.url));
  } catch (e) {
    if (e instanceof OAuth2RequestError) {
      return new NextResponse('Invalid code', { status: 400 });
    }
    return new NextResponse('Server error', { status: 500 });
  }
}

The state check is the OAuth 2.0 CSRF protection. The state cookie set in the redirect handler is compared against the state query parameter in the callback. If they do not match, the request is rejected. Claude often skips the state check when generating OAuth callbacks because the basic OAuth tutorial focuses on the happy path. Add the state check requirement to CLAUDE.md:

## OAuth callback security
- ALWAYS check state cookie matches state query parameter
- Return 400 immediately if state is missing or does not match
- State cookie: httpOnly, secure, sameSite: 'lax', maxAge: 10 minutes
- NEVER store the OAuth access token in localStorage or sessionStorage
- NEVER expose the access token to client-side JavaScript

The other security note is the access token storage. The GitHub access token is used in the callback to fetch the user profile and then discarded. Storing it in the user record or session makes future API calls easier but creates a credential leakage surface. If you need persistent access (for ongoing GitHub API calls on behalf of the user), encrypt the token before storing it, and treat the encryption key with the same protection as password hashes.

For deployments that need to manage Stripe customer data alongside auth, Claude Code with Stripe covers the webhook handlers and customer object patterns that work alongside Lucia sessions.

Cookie attribute reference

The full set of cookie attributes Lucia sets via createSessionCookie(), with the security rationale for each:

Attribute Default Why it matters
httpOnly true Blocks document.cookie access from JavaScript, prevents XSS-driven session theft
secure true in production Cookie only sent over HTTPS, prevents man-in-the-middle on local networks
sameSite 'lax' Cookie not sent on cross-site POST requests, blocks CSRF
path '/' Cookie sent on all paths under the domain
maxAge session expiry Set from expiresAt on the session record
name 'auth_session' Configurable, kept consistent across all handlers

The sameSite: 'lax' default is the right choice for most applications. 'strict' is stricter (the cookie is not sent on any cross-origin request, including top-level navigations from external links), which breaks user flows where someone clicks an email link to your authenticated app. 'none' is required only for cross-site embedded scenarios, and even then requires secure: true. Stick with 'lax' unless you have a specific reason.

Add the cookie attribute table to CLAUDE.md verbatim. Claude generates correct defaults when the table is present and incorrect defaults when only abstract rules are given.

Permission hooks for auth scripts

A Lucia project accumulates scripts: seed scripts that create test users, migration scripts that adjust the session schema, suppression scripts that revoke sessions for compromised accounts. Permission hooks gate the destructive ones.

In .claude/settings.local.json:

{
  "permissions": {
    "allow": [
      "Bash(npx tsx scripts/list-users.ts*)",
      "Bash(npx tsx scripts/check-session-count.ts*)",
      "Bash(npx drizzle-kit generate*)"
    ],
    "deny": [
      "Bash(npx tsx scripts/revoke-all-sessions.ts*)",
      "Bash(npx tsx scripts/delete-users.ts*)",
      "Bash(npx drizzle-kit drop*)"
    ]
  }
}

Reading session counts and listing users are safe operations. Revoking all sessions globally or dropping migrations require explicit confirmation. The deny list forces Claude to surface those operations as prompts rather than executing them as part of an automated workflow. For more on permission hooks, Claude Code permissions covers the full configuration model.

Common Claude Code mistakes with Lucia

Six patterns Claude generates incorrectly without CLAUDE.md constraints, with the correct replacement for each.

1. Direct validateSession calls in route handlers

Claude generates: const result = await lucia.validateSession(sessionId); inside every route handler.

Correct pattern: const { user, session } = await validateRequest(); from the centralised wrapper.

2. Missing session.fresh handling

Claude generates: validation logic that ignores the fresh flag and never writes the extended cookie back.

Correct pattern: check result.session.fresh, call createSessionCookie(), write the new cookie to the response.

3. Manual cookie string construction

Claude generates: setHeader('Set-Cookie', 'auth_session=' + sessionId + '; HttpOnly; Secure').

Correct pattern: lucia.createSessionCookie(session.id) and use the returned name, value, attributes.

4. Skipped OAuth state check

Claude generates: callback handler that exchanges the code without checking the state cookie matches the state query parameter.

Correct pattern: read state cookie, compare to query state, return 400 if mismatched, only then exchange the code.

5. Plaintext password storage

Claude generates: await db.insert(userTable).values({ password }) storing the raw password string.

Correct pattern: const passwordHash = await new Argon2id().hash(password) and store passwordHash.

6. No constant-time check on login

Claude generates: if (!user) return 401 before the password verify, leaking timing information.

Correct pattern: always run the Argon2id verify, against a placeholder hash if no user exists.

Add these six pairs to CLAUDE.md as before/after examples. Claude reproduces patterns faster from concrete examples than from abstract rules.

When to consider migrating off Lucia

Lucia v3 will keep working, but the ecosystem momentum is moving elsewhere. If you find yourself doing any of the following, consider whether the migration cost to Better Auth, NextAuth, or Clerk would pay back.

You are spending more than two hours a month maintaining Lucia code. You need a feature Lucia does not provide (passkeys, magic links, organisation management, role-based access control) and would have to build it manually. Your team has rotated and the engineers familiar with Lucia have left. You want vendor-provided abuse protection (CAPTCHA, rate limiting, suspicious login detection).

The migration path from Lucia to Better Auth is the most straightforward because both libraries use a similar session-based model. Claude Code with Better Auth covers the Better Auth setup directly. If your needs include hosted UI and abuse protection, Claude Code with Clerk covers Clerk as the higher-level alternative.

Until then, the CLAUDE.md template in this guide is what keeps Claude Code generating correct Lucia integration code against the existing codebase. The combination of the validateRequest wrapper, the constant-time login check, the OAuth state validation, and the cookie attribute defaults produces auth code that passes a security review and survives the production traffic that finds the edges of every shortcut.

Get Claudify. The bundle includes a Lucia-specific CLAUDE.md with the session wrapper, adapter setup, OAuth state handling, and all six common-mistake rules pre-configured.

More like this

Ready to upgrade your Claude Code setup?

Get Claudify
Featured on Dofollow.Tools AI Toolz Dir Claudify - Featured on Startup Fame