Lucia Auth Testing: Session Management, Adapters, and Integration Tests

Lucia Auth Testing: Session Management, Adapters, and Integration Tests

Lucia Auth is a lean, framework-agnostic authentication library for TypeScript that gives you full control over session management. Unlike Clerk or Auth0, Lucia is not a SaaS — it runs on your infrastructure and stores sessions in your database. This ownership makes testing more thorough because you can actually inspect the database state. This guide covers testing Lucia v3 integrations.

What Makes Lucia Auth Testing Different

Lucia's architecture is explicit: you create sessions, validate them, and invalidate them manually. There's no magic middleware. This means:

  1. Sessions are testable — you can create and inspect session records directly
  2. Adapters are swappable — tests can use an in-memory adapter instead of a real database
  3. No external service dependencies — unlike Clerk or Auth0, everything is local

Setting Up Lucia for Testing

Lucia v3 uses adapters for database storage. Use the in-memory adapter for unit tests:

// test-utils/lucia-setup.ts
import { Lucia } from 'lucia';

export function createTestLucia() {
  // In-memory store for tests
  const sessions = new Map<string, { id: string; userId: string; expiresAt: Date; attributes: Record<string, unknown> }>();
  const users = new Map<string, { id: string; username: string; passwordHash: string }>();

  const adapter = {
    getSessionAndUser: async (sessionId: string) => {
      const session = sessions.get(sessionId);
      if (!session) return [null, null];
      const user = users.get(session.userId);
      if (!user) return [null, null];
      return [session, user] as const;
    },
    getUserSessions: async (userId: string) => {
      return Array.from(sessions.values()).filter(s => s.userId === userId);
    },
    setSession: async (session: { id: string; userId: string; expiresAt: Date; attributes: Record<string, unknown> }) => {
      sessions.set(session.id, session);
    },
    updateSessionExpiration: async (sessionId: string, expiresAt: Date) => {
      const session = sessions.get(sessionId);
      if (session) session.expiresAt = expiresAt;
    },
    deleteSession: async (sessionId: string) => {
      sessions.delete(sessionId);
    },
    deleteUserSessions: async (userId: string) => {
      for (const [id, session] of sessions.entries()) {
        if (session.userId === userId) sessions.delete(id);
      }
    },
    deleteExpiredSessions: async () => {
      const now = new Date();
      for (const [id, session] of sessions.entries()) {
        if (session.expiresAt < now) sessions.delete(id);
      }
    },
  };

  const lucia = new Lucia(adapter, {
    sessionCookie: {
      name: 'auth_session',
      attributes: { secure: false }, // HTTP in tests
    },
  });

  return { lucia, sessions, users };
}

Testing Session Creation

import { createTestLucia } from '../test-utils/lucia-setup';

describe('Session management', () => {
  test('creates a new session for a user', async () => {
    const { lucia, sessions } = createTestLucia();
    
    const session = await lucia.createSession('user-1', {});
    
    expect(session.id).toBeDefined();
    expect(session.userId).toBe('user-1');
    expect(session.expiresAt).toBeInstanceOf(Date);
    expect(session.expiresAt.getTime()).toBeGreaterThan(Date.now());
    
    // Verify it's stored in the adapter
    expect(sessions.has(session.id)).toBe(true);
  });

  test('creates session with custom expiry', async () => {
    const { lucia } = createTestLucia();
    
    // Default session expiry is 30 days
    const session = await lucia.createSession('user-1', {});
    const thirtyDaysFromNow = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000);
    
    // Allow 5 seconds tolerance
    expect(Math.abs(session.expiresAt.getTime() - thirtyDaysFromNow.getTime())).toBeLessThan(5000);
  });

  test('creates unique session IDs', async () => {
    const { lucia } = createTestLucia();
    
    const session1 = await lucia.createSession('user-1', {});
    const session2 = await lucia.createSession('user-1', {});
    
    expect(session1.id).not.toBe(session2.id);
  });
});

Testing Session Validation

Session validation is the core of every protected route:

describe('Session validation', () => {
  test('validates a valid session token', async () => {
    const { lucia } = createTestLucia();
    
    const session = await lucia.createSession('user-1', {});
    const { session: validatedSession, user } = await lucia.validateSession(session.id);
    
    expect(validatedSession).not.toBeNull();
    expect(validatedSession!.id).toBe(session.id);
    expect(validatedSession!.userId).toBe('user-1');
  });

  test('returns null for an invalid session token', async () => {
    const { lucia } = createTestLucia();
    
    const { session, user } = await lucia.validateSession('invalid-session-id');
    
    expect(session).toBeNull();
    expect(user).toBeNull();
  });

  test('returns null for an expired session', async () => {
    const { lucia, sessions } = createTestLucia();
    
    const session = await lucia.createSession('user-1', {});
    
    // Manually expire the session
    sessions.set(session.id, {
      ...sessions.get(session.id)!,
      expiresAt: new Date(Date.now() - 1000), // 1 second in the past
    });
    
    const { session: validatedSession } = await lucia.validateSession(session.id);
    
    expect(validatedSession).toBeNull();
  });

  test('extends session expiry when session is near expiration', async () => {
    const { lucia, sessions } = createTestLucia();
    
    const session = await lucia.createSession('user-1', {});
    const originalExpiry = sessions.get(session.id)!.expiresAt;
    
    // Set session to expire in 14 days (Lucia refreshes when <15 days remain)
    sessions.set(session.id, {
      ...sessions.get(session.id)!,
      expiresAt: new Date(Date.now() + 14 * 24 * 60 * 60 * 1000),
    });
    
    await lucia.validateSession(session.id);
    
    // Session should be extended back to 30 days
    const newExpiry = sessions.get(session.id)!.expiresAt;
    expect(newExpiry.getTime()).toBeGreaterThan(originalExpiry.getTime() - 1000);
  });
});

Testing Session Invalidation

describe('Session invalidation', () => {
  test('invalidates a single session on sign-out', async () => {
    const { lucia, sessions } = createTestLucia();
    
    const session = await lucia.createSession('user-1', {});
    expect(sessions.has(session.id)).toBe(true);
    
    await lucia.invalidateSession(session.id);
    
    expect(sessions.has(session.id)).toBe(false);
  });

  test('invalidates all user sessions on account deletion', async () => {
    const { lucia, sessions } = createTestLucia();
    
    const session1 = await lucia.createSession('user-1', {});
    const session2 = await lucia.createSession('user-1', {});
    const otherSession = await lucia.createSession('user-2', {});
    
    await lucia.invalidateUserSessions('user-1');
    
    expect(sessions.has(session1.id)).toBe(false);
    expect(sessions.has(session2.id)).toBe(false);
    expect(sessions.has(otherSession.id)).toBe(true); // Other user unaffected
  });
});

Testing Authentication Handlers (Hono/Express)

Testing your Lucia-powered auth endpoints:

import { Hono } from 'hono';
import { serveStatic } from '@hono/node-server/serve-static';
import { createTestLucia } from '../test-utils/lucia-setup';
import { hashPassword, verifyPassword } from '../lib/password';

describe('Auth routes', () => {
  let app: Hono;
  let lucia: ReturnType<typeof createTestLucia>['lucia'];
  let users: ReturnType<typeof createTestLucia>['users'];

  beforeEach(() => {
    const setup = createTestLucia();
    lucia = setup.lucia;
    users = setup.users;

    app = new Hono();
    
    app.post('/sign-in', async (c) => {
      const { username, password } = await c.req.json();
      
      // Find user
      const user = Array.from(users.values()).find(u => u.username === username);
      if (!user || !await verifyPassword(password, user.passwordHash)) {
        return c.json({ error: 'Invalid credentials' }, 401);
      }
      
      const session = await lucia.createSession(user.id, {});
      const sessionCookie = lucia.createSessionCookie(session.id);
      c.header('Set-Cookie', sessionCookie.serialize());
      
      return c.json({ success: true });
    });

    app.post('/sign-out', async (c) => {
      const sessionId = getCookie(c, lucia.sessionCookieName);
      if (sessionId) await lucia.invalidateSession(sessionId);
      
      const blankCookie = lucia.createBlankSessionCookie();
      c.header('Set-Cookie', blankCookie.serialize());
      
      return c.json({ success: true });
    });
  });

  test('sign-in creates session and sets cookie for valid credentials', async () => {
    // Set up test user
    const passwordHash = await hashPassword('test-password');
    users.set('user-1', { id: 'user-1', username: 'jane', passwordHash });

    const response = await app.request('/sign-in', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ username: 'jane', password: 'test-password' }),
    });

    expect(response.status).toBe(200);
    expect(response.headers.get('Set-Cookie')).toContain('auth_session=');
    
    const body = await response.json();
    expect(body.success).toBe(true);
  });

  test('sign-in returns 401 for wrong password', async () => {
    const passwordHash = await hashPassword('correct-password');
    users.set('user-1', { id: 'user-1', username: 'jane', passwordHash });

    const response = await app.request('/sign-in', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ username: 'jane', password: 'wrong-password' }),
    });

    expect(response.status).toBe(401);
    expect(response.headers.get('Set-Cookie')).toBeNull();
  });

  test('sign-out invalidates the session', async () => {
    const { sessions } = createTestLucia();
    const session = await lucia.createSession('user-1', {});
    
    const response = await app.request('/sign-out', {
      method: 'POST',
      headers: { Cookie: `auth_session=${session.id}` },
    });

    expect(response.status).toBe(200);
    expect(sessions.has(session.id)).toBe(false);
  });
});

Integration Tests with Testcontainers

For production-like adapter testing, use a real database:

import { PostgreSqlContainer } from '@testcontainers/postgresql';
import postgres from 'postgres';
import { NodePostgresAdapter } from '@lucia-auth/adapter-postgresql';
import { Lucia } from 'lucia';

describe('Lucia with PostgreSQL adapter', () => {
  let container: any;
  let sql: any;
  let lucia: Lucia;

  beforeAll(async () => {
    container = await new PostgreSqlContainer().start();
    sql = postgres(container.getConnectionUri());
    
    // Create Lucia tables
    await sql`
      CREATE TABLE user_sessions (
        id TEXT NOT NULL PRIMARY KEY,
        user_id TEXT NOT NULL,
        expires_at TIMESTAMPTZ NOT NULL
      )
    `;
    await sql`
      CREATE TABLE user_table (
        id TEXT NOT NULL PRIMARY KEY,
        username TEXT NOT NULL UNIQUE
      )
    `;
    
    const adapter = new NodePostgresAdapter(sql, {
      user: 'user_table',
      session: 'user_sessions',
    });
    
    lucia = new Lucia(adapter, {
      sessionCookie: { name: 'auth_session', attributes: { secure: false } },
    });
    
    // Insert test user
    await sql`INSERT INTO user_table VALUES ('user-1', 'testuser')`;
  }, 60_000);

  afterAll(async () => {
    await sql?.end();
    await container?.stop();
  });

  test('creates and validates session in real database', async () => {
    const session = await lucia.createSession('user-1', {});
    
    const { session: validated } = await lucia.validateSession(session.id);
    expect(validated?.userId).toBe('user-1');
  });

  test('persists session across lucia instances', async () => {
    const session = await lucia.createSession('user-1', {});
    
    // Create a new Lucia instance pointing at the same DB
    const lucia2 = new Lucia(new NodePostgresAdapter(sql, {
      user: 'user_table',
      session: 'user_sessions',
    }), { sessionCookie: { name: 'auth_session', attributes: { secure: false } } });
    
    const { session: validated } = await lucia2.validateSession(session.id);
    expect(validated).not.toBeNull();
  });
});

E2E Testing with HelpMeTest

Authentication flows need E2E testing in real browsers to catch cookie issues, redirect loops, and session persistence problems:

Navigate to https://your-app.com/sign-in
Enter username "testuser" in the username field
Enter password in the password field
Click Sign In button
Verify redirect to /dashboard
Verify welcome message includes username
Verify session persists after page refresh
Navigate to /sign-out
Verify redirect to /sign-in
Verify cannot access /dashboard without re-authenticating

HelpMeTest saves session state so you can reuse authenticated sessions across test runs without re-authenticating each time.

Summary

Testing Lucia Auth effectively requires:

  1. In-memory adapter for fast, isolated unit tests
  2. Session creation tests — ID uniqueness, expiry calculation, attribute storage
  3. Session validation tests — valid, invalid, expired, and near-expiry sessions
  4. Session invalidation tests — single session and bulk user session invalidation
  5. Auth route tests — sign-in credential validation, sign-out session cleanup, cookie management
  6. Testcontainers integration for adapter-level database validation
  7. E2E monitoring with HelpMeTest for cookie, redirect, and session persistence testing

Read more