Fix: Lucia Auth Not Working — Session Not Created, Middleware Rejecting Valid Sessions, or OAuth Callback Failing
Part of: JavaScript & TypeScript Errors
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 session creation and validation, but it intentionally stays out of the request/response cycle. Every other auth library hides cookie reading, CSRF protection, and adapter wiring behind one helper. Lucia exposes them. That design buys flexibility but pushes integration onto you, and the same bug shows up at three different layers depending on which step you skipped.
The most common failure path is silent: the session row writes to the database, the cookie object gets built, but the cookie never actually sets on the response. Lucia returns a sessionCookie.name, sessionCookie.value, and sessionCookie.attributes triple — you have to feed all three into your framework’s cookie API yourself. Next.js App Router, SvelteKit, Astro, and Express all expose different cookie APIs, and each one has a “must await” or “must serialize” quirk that swallows the cookie if you skip it.
The second failure mode is a session-adapter mismatch. Drizzle’s SQLite adapter expects expiresAt as a JavaScript Date. Drizzle’s Postgres adapter expects a timestamp. If you copy a schema from one example into a database with a different driver, the comparison in validateSession silently treats every session as expired. The third is OAuth state: the cookie storing the state has to survive a full external redirect, which means sameSite: 'lax' and httpOnly: true are mandatory, and the cookie path has to include the callback route.
- 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. secure: truecookies onhttp://localhost— production-style cookie attributes break dev. The browser drops aSecurecookie sent over plain HTTP without telling you. Logs show the cookie was set; DevTools shows it never landed.
Diagnostic Timeline
When a Lucia session refuses to persist, the temptation is to grep for “cookie” and stop there. The real cause is usually one layer deeper. Walk the request top-down before guessing.
Minute 0 — observe. Open DevTools, log in, and watch the Network tab. The login response should carry a Set-Cookie: auth_session=... header. If that header is missing, the cookie was never set on the response — the bug is in your route handler, not in Lucia.
Minute 2 — confirm the database write. Query the sessions table directly: SELECT * FROM sessions ORDER BY expires_at DESC LIMIT 1. If a row exists but the cookie header is missing, you forgot to call cookieStore.set(sessionCookie.name, sessionCookie.value, sessionCookie.attributes). If no row exists, the adapter is misconfigured or createSession threw silently inside a try/catch.
Minute 5 — check secure on localhost. In sessionCookie.attributes, look for secure: true. Over http://localhost, the browser silently discards Secure cookies. Lucia’s default config keys off process.env.NODE_ENV — if you set NODE_ENV=production in .env.local for any reason, dev will look exactly like this bug.
Minute 8 — inspect framework integration. In Next.js 15 App Router, cookies() from next/headers must be awaited. In SvelteKit, you set cookies via event.cookies.set() inside actions, not in +page.server.ts load. In Astro, cookies go on Astro.cookies.set inside server endpoints, and output: 'static' mode silently drops cookies. Mismatched framework integration accounts for most “session created but not persisted” bugs.
Minute 12 — verify session expiry comparison. If sessions create and the cookie sets but validateSession always returns null, log the raw expiresAt from the database alongside new Date(). Drizzle’s mode: 'timestamp' stores seconds, not milliseconds — every session looks expired. Switch to mode: 'timestamp_ms' or use the integer column matching your adapter’s docs.
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.
Cookies work in dev but not in production behind a proxy — when your app sits behind a load balancer, CDN, or reverse proxy that terminates TLS, the framework sees http:// and may skip setting Secure cookies. Set trust proxy in Express (app.set('trust proxy', 1)) or configure your platform to forward X-Forwarded-Proto: https. In Next.js on Vercel this is automatic; on a custom Node server it is not.
SvelteKit/Astro session not visible inside server load — the cookie was set on the response, but you are reading it on the same request before the redirect. Cookies set on a response are only visible on the next request. Always redirect after login so the browser replays the cookie on the next round-trip, then read the session inside the new request.
Lucia v3 sessions get truncated after rolling refresh — when result.session.fresh is true Lucia issues a new cookie with extended expiresAt. If you forget to call cookieStore.set in the rolling block, every active session expires at its original time instead of being extended. Hit the “user keeps getting logged out after 24 hours” complaint? Check that branch first.
For related auth issues, see Fix: Next.js API Route Not Working, Fix: Express Middleware Not Working, Fix: Auth.js Not Working, and Fix: Better Auth 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.