Skip to content

Fix: Lucia Auth Not Working — Session Not Created, Middleware Rejecting Valid Sessions, or OAuth Callback Failing

FixDevs ·

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 redirect

Or middleware rejects valid sessions:

const { session, user } = await lucia.validateRequest(request);
// session is null — even though the cookie exists

Or an OAuth callback fails with an invalid state error:

Error: OAuth state mismatch
# State stored in cookie doesn't match state returned by provider

Or after upgrading to Lucia v3, the old API throws errors:

import { auth } from './lucia';  // v2
auth.createSession(userId, {});
// TypeError: auth.createSession is not a function

Why 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 Authorization header OR cookies — the validateRequest method (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-redis

Fix 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 augmentation

Still 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.

F

FixDevs

Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.

Was this article helpful?

Related Articles