Fix: Lucia Auth Not Working — Session Not Created, Middleware Rejecting Valid Sessions, or OAuth Callback Failing
Quick Answer
How to fix Lucia auth issues — adapter setup, session validation in middleware, cookie configuration, OAuth provider integration, Next.js App Router setup, and Lucia v3 migration.
The Problem
After logging in, the session isn’t persisted and the user is logged out immediately:
// Login handler
const session = await lucia.createSession(userId, {});
const sessionCookie = lucia.createSessionCookie(session.id);
// Cookie set — but user is still logged out after redirectOr middleware rejects valid sessions:
const { session, user } = await lucia.validateRequest(request);
// session is null — even though the cookie existsOr an OAuth callback fails with an invalid state error:
Error: OAuth state mismatch
# State stored in cookie doesn't match state returned by providerOr after upgrading to Lucia v3, the old API throws errors:
import { auth } from './lucia'; // v2
auth.createSession(userId, {});
// TypeError: auth.createSession is not a functionWhy This Happens
Lucia is a minimal session management library — it handles sessions but relies on you to wire up the adapter, cookie handling, and middleware:
- Adapter not matching your database — Lucia requires a database adapter. Using the wrong adapter or misconfiguring it causes session creation to fail silently or throw cryptic errors.
- Cookie not being set or read correctly — Lucia generates cookie configuration, but you must set and read cookies manually in your framework’s request/response cycle. Missing this step means sessions are created in the database but never sent to the client.
- Session validation reads from the
Authorizationheader OR cookies — thevalidateRequestmethod (Lucia v3) needs you to pass the session ID explicitly, extracted from either cookies or headers. It doesn’t automatically read from the request object. - Lucia v3 API is completely different from v2 — Lucia v3 is a near-total rewrite.
lucia.createSession,lucia.validateSessionToken, and the initialization API all changed.
Fix 1: Set Up Lucia v3 Correctly
// lib/auth.ts — Lucia v3 setup
import { Lucia } from 'lucia';
import { DrizzleSQLiteAdapter } from '@lucia-auth/adapter-drizzle';
import { db } from './db';
import { sessions, users } from './schema';
const adapter = new DrizzleSQLiteAdapter(db, sessions, users);
export const lucia = new Lucia(adapter, {
sessionCookie: {
// Set cookie attributes based on environment
attributes: {
secure: process.env.NODE_ENV === 'production',
},
},
getUserAttributes: (attributes) => {
return {
username: attributes.username,
email: attributes.email,
};
},
});
// IMPORTANT: Type augmentation for TypeScript
declare module 'lucia' {
interface Register {
Lucia: typeof lucia;
DatabaseUserAttributes: {
username: string;
email: string;
};
}
}Database schema (Drizzle example):
// lib/schema.ts
import { sqliteTable, text, integer } from 'drizzle-orm/sqlite-core';
export const users = sqliteTable('users', {
id: text('id').primaryKey(),
username: text('username').notNull().unique(),
email: text('email').notNull().unique(),
hashedPassword: text('hashed_password'),
});
export const sessions = sqliteTable('sessions', {
id: text('id').primaryKey(),
userId: text('user_id')
.notNull()
.references(() => users.id),
expiresAt: integer('expires_at', { mode: 'timestamp' }).notNull(),
});Available adapters:
# Prisma
npm install @lucia-auth/adapter-prisma
# Drizzle
npm install @lucia-auth/adapter-drizzle
# MongoDB (with mongoose)
npm install @lucia-auth/adapter-mongoose
# Redis (for session storage only)
npm install @lucia-auth/adapter-redisFix 2: Handle Login and Session Creation
// app/api/login/route.ts (Next.js App Router)
import { lucia } from '@/lib/auth';
import { cookies } from 'next/headers';
import { verifyPassword } from '@/lib/password';
import { db } from '@/lib/db';
export async function POST(request: Request) {
const body = await request.json();
const { username, password } = body;
// 1. Find user
const user = await db.query.users.findFirst({
where: eq(users.username, username),
});
if (!user || !user.hashedPassword) {
return Response.json({ error: 'Invalid credentials' }, { status: 401 });
}
// 2. Verify password
const validPassword = await verifyPassword(password, user.hashedPassword);
if (!validPassword) {
return Response.json({ error: 'Invalid credentials' }, { status: 401 });
}
// 3. Create session
const session = await lucia.createSession(user.id, {});
const sessionCookie = lucia.createSessionCookie(session.id);
// 4. Set the cookie — THIS IS REQUIRED
const cookieStore = await cookies();
cookieStore.set(
sessionCookie.name,
sessionCookie.value,
sessionCookie.attributes
);
return Response.json({ success: true });
}
// Logout
export async function DELETE(request: Request) {
const cookieStore = await cookies();
const sessionId = cookieStore.get(lucia.sessionCookieName)?.value;
if (!sessionId) {
return Response.json({ error: 'Not logged in' }, { status: 401 });
}
// Invalidate session in database
await lucia.invalidateSession(sessionId);
// Delete the cookie
const blankCookie = lucia.createBlankSessionCookie();
cookieStore.set(blankCookie.name, blankCookie.value, blankCookie.attributes);
return Response.json({ success: true });
}Fix 3: Validate Sessions in Middleware
Session validation in Lucia v3 is manual — you extract the session ID from the cookie and validate it:
// lib/auth-helpers.ts — reusable validation
import { lucia } from './auth';
import { cookies } from 'next/headers';
import { cache } from 'react';
// Cache per request — avoids duplicate DB calls
export const validateSession = cache(async () => {
const cookieStore = await cookies();
const sessionId = cookieStore.get(lucia.sessionCookieName)?.value ?? null;
if (!sessionId) {
return { user: null, session: null };
}
const result = await lucia.validateSession(sessionId);
// Session rolling — extend expiry on active sessions
if (result.session && result.session.fresh) {
const sessionCookie = lucia.createSessionCookie(result.session.id);
cookieStore.set(
sessionCookie.name,
sessionCookie.value,
sessionCookie.attributes
);
}
// Delete expired session cookie
if (!result.session) {
const blankCookie = lucia.createBlankSessionCookie();
cookieStore.set(blankCookie.name, blankCookie.value, blankCookie.attributes);
}
return result;
});// middleware.ts — Next.js middleware
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
import { verifyRequestOrigin } from 'lucia';
export async function middleware(request: NextRequest) {
// CSRF protection for non-GET requests
if (request.method !== 'GET') {
const originHeader = request.headers.get('Origin');
const hostHeader = request.headers.get('Host');
if (
!originHeader ||
!hostHeader ||
!verifyRequestOrigin(originHeader, [hostHeader])
) {
return new NextResponse(null, { status: 403 });
}
}
// Protect routes
const sessionId = request.cookies.get(lucia.sessionCookieName)?.value;
const protectedPaths = ['/dashboard', '/settings', '/api/user'];
const isProtected = protectedPaths.some(p =>
request.nextUrl.pathname.startsWith(p)
);
if (isProtected && !sessionId) {
return NextResponse.redirect(new URL('/login', request.url));
}
return NextResponse.next();
}
export const config = {
matcher: ['/((?!_next|.*\\..*).*)'],
};Use in Server Components:
// app/dashboard/page.tsx
import { validateSession } from '@/lib/auth-helpers';
import { redirect } from 'next/navigation';
export default async function DashboardPage() {
const { user, session } = await validateSession();
if (!user) {
redirect('/login');
}
return <h1>Welcome, {user.username}!</h1>;
}Fix 4: Add OAuth with GitHub (or Google)
// lib/oauth.ts
import { GitHub, Google } from 'arctic'; // arctic is Lucia's OAuth library
export const github = new GitHub(
process.env.GITHUB_CLIENT_ID!,
process.env.GITHUB_CLIENT_SECRET!,
);
export const google = new Google(
process.env.GOOGLE_CLIENT_ID!,
process.env.GOOGLE_CLIENT_SECRET!,
process.env.GOOGLE_REDIRECT_URI!, // http://localhost:3000/api/auth/google/callback
);// app/api/auth/github/route.ts — initiate OAuth
import { github } from '@/lib/oauth';
import { generateState } from 'arctic';
import { cookies } from 'next/headers';
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, // 10 minutes
sameSite: 'lax',
});
return Response.redirect(url.toString());
}// app/api/auth/github/callback/route.ts
import { github } from '@/lib/oauth';
import { lucia } from '@/lib/auth';
import { cookies } from 'next/headers';
import { db } from '@/lib/db';
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 cookieStore = await cookies();
const storedState = cookieStore.get('github_oauth_state')?.value;
// Validate state — prevents CSRF
if (!code || !state || state !== storedState) {
return new Response('Invalid state', { status: 400 });
}
try {
// Exchange code for tokens
const tokens = await github.validateAuthorizationCode(code);
// Fetch user info from GitHub
const githubUserRes = await fetch('https://api.github.com/user', {
headers: { Authorization: `Bearer ${tokens.accessToken()}` },
});
const githubUser = await githubUserRes.json();
// Find or create user in your database
let user = await db.query.users.findFirst({
where: eq(users.githubId, String(githubUser.id)),
});
if (!user) {
user = await db.insert(users).values({
id: generateId(15),
githubId: String(githubUser.id),
username: githubUser.login,
email: githubUser.email,
}).returning().get();
}
// Create session
const session = await lucia.createSession(user.id, {});
const sessionCookie = lucia.createSessionCookie(session.id);
cookieStore.set(sessionCookie.name, sessionCookie.value, sessionCookie.attributes);
} catch (error) {
if (error instanceof OAuth2RequestError) {
return new Response('Invalid code', { status: 400 });
}
throw error;
}
return Response.redirect(new URL('/dashboard', request.url));
}Fix 5: Express/Hono Integration
For non-Next.js backends:
// Express
import express from 'express';
import { lucia } from './auth';
const app = express();
// Middleware — validate session on every request
app.use(async (req, res, next) => {
const sessionId = lucia.readSessionCookie(req.headers.cookie ?? '');
if (!sessionId) {
res.locals.user = null;
res.locals.session = null;
return next();
}
const { session, user } = await lucia.validateSession(sessionId);
if (session && session.fresh) {
res.appendHeader('Set-Cookie', lucia.createSessionCookie(session.id).serialize());
}
if (!session) {
res.appendHeader('Set-Cookie', lucia.createBlankSessionCookie().serialize());
}
res.locals.session = session;
res.locals.user = user;
next();
});
// Protect route
app.get('/dashboard', (req, res) => {
if (!res.locals.user) {
return res.redirect('/login');
}
res.send(`Welcome, ${res.locals.user.username}`);
});
// Login endpoint
app.post('/login', async (req, res) => {
const { username, password } = req.body;
// ... validate credentials ...
const session = await lucia.createSession(userId, {});
res.appendHeader('Set-Cookie', lucia.createSessionCookie(session.id).serialize());
res.redirect('/dashboard');
});Fix 6: Migrate from Lucia v2 to v3
// KEY DIFFERENCES: Lucia v2 → v3
// 1. Initialization
// v2:
import lucia from 'lucia';
export const auth = lucia({
adapter: ...,
env: process.env.NODE_ENV === 'production' ? 'PROD' : 'DEV',
middleware: nextjs(),
});
// v3:
import { Lucia } from 'lucia';
export const lucia = new Lucia(adapter, {
sessionCookie: { attributes: { secure: process.env.NODE_ENV === 'production' } },
});
// 2. Session validation
// v2:
const authRequest = auth.handleRequest(request, context);
const session = await authRequest.validate();
// v3:
const sessionId = lucia.readSessionCookie(request.headers.get('Cookie') ?? '');
const { session, user } = await lucia.validateSession(sessionId ?? '');
// 3. Session creation
// v2:
const session = await auth.createSession({ userId, attributes: {} });
const sessionCookie = auth.createSessionCookie(session);
// v3:
const session = await lucia.createSession(userId, {});
const sessionCookie = lucia.createSessionCookie(session.id);
// 4. Framework middleware removed
// v2 had built-in middleware: nextjs(), express(), h3()
// v3: you handle cookie reading/setting manually (more control, more code)
// 5. User attributes
// v2: defined in lucia() config
// v3: defined in getUserAttributes and declared via module augmentationStill Not Working?
Session is created but cookie isn’t sent — check that you’re actually calling cookieStore.set(sessionCookie.name, sessionCookie.value, sessionCookie.attributes). A common mistake is creating the cookie object but not setting it. In Next.js, cookies() from next/headers must be awaited in Next.js 15+.
validateSession always returns { user: null, session: null } — the session ID isn’t being extracted correctly. Log lucia.sessionCookieName to see the expected cookie name (default: auth_session), then verify the cookie exists in the request with that name. Also check that the expiresAt column in your database is stored and read correctly (timestamp format issues cause sessions to appear expired).
OAuth state mismatch — the state cookie is set with httpOnly: true — you can’t read it from JavaScript. The issue is usually that the state cookie expires before the OAuth redirect completes, or the cookie isn’t being read back correctly. Check sameSite: 'lax' is set (not strict) — strict blocks the cookie on the OAuth callback redirect from the provider’s domain.
For related auth issues, see Fix: Next.js API Route Not Working and Fix: Express Middleware Not Working.
Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.
Was this article helpful?
Related Articles
Fix: Fastify Not Working — 404, Plugin Encapsulation, and Schema Validation Errors
How to fix Fastify issues — route 404 from plugin encapsulation, reply already sent, FST_ERR_VALIDATION, request.body undefined, @fastify/cors, hooks not running, and TypeScript type inference.
Fix: pdf-lib Not Working — PDF Not Generating, Fonts Not Embedding, or Pages Blank
How to fix pdf-lib issues — creating PDFs from scratch, modifying existing PDFs, embedding fonts and images, form filling, merging documents, and browser and Node.js usage.
Fix: Pusher Not Working — Events Not Received, Channel Auth Failing, or Connection Dropping
How to fix Pusher real-time issues — client and server setup, channel types, presence channels, authentication endpoints, event binding, connection management, and React integration.
Fix: Sharp Not Working — Installation Failing, Image Not Processing, or Build Errors on Deploy
How to fix Sharp image processing issues — native binary installation, resize and convert operations, Next.js image optimization, Docker setup, serverless deployment, and common platform errors.