Skip to content

Testing

How to test auth flows in your application.

Updated View as Markdown

Testing authentication code requires mocking the boundary between your application and the framework — primarily the cookie layer. ideal-auth’s CookieBridge interface makes this straightforward: replace the three cookie functions with in-memory implementations and you have a fully functional auth system in your tests.

The cookie bridge is three functions: get, set, and delete. For tests, back them with a Map:

import type { CookieBridge, CookieOptions } from 'ideal-auth';

function createMockCookieBridge(): CookieBridge {
  const store = new Map<string, string>();
  return {
    get: (name: string) => store.get(name),
    set: (name: string, value: string, _options: CookieOptions) => {
      store.set(name, value);
    },
    delete: (name: string) => {
      store.delete(name);
    },
  };
}

This gives you a cookie bridge that behaves like a real one but runs entirely in memory. No HTTP, no framework, no browser.

Testing login/logout flows

Create a real auth instance with the mock cookie bridge and a mock user store. Use createHash with low rounds (4 is the minimum for bcrypt) so tests run fast:

import { createAuth, createHash } from 'ideal-auth';
import type { AuthInstance } from 'ideal-auth';

interface User {
  id: string;
  email: string;
  name: string;
  password: string;
}

const hash = createHash({ rounds: 4 });

async function setupAuth() {
  const hashedPassword = await hash.make('correct-password');
  const users: User[] = [
    { id: '1', email: 'alice@example.com', name: 'Alice', password: hashedPassword },
  ];

  const bridge = createMockCookieBridge();
  const auth = createAuth<User>({
    secret: 'a'.repeat(32), // 32+ character test secret
    cookie: bridge,
    hash,
    resolveUser: async (id) => users.find((u) => u.id === id) ?? null,
    resolveUserByCredentials: async (creds) =>
      users.find((u) => u.email === creds.email) ?? null,
  });

  return { auth, bridge, users };
}

Full login/logout test

import { describe, it, expect } from 'vitest'; // or bun:test, jest, etc.

describe('auth flow', () => {
  it('attempt → check → user → logout → check', async () => {
    const { auth } = await setupAuth();
    const session = auth();

    // Attempt login with correct credentials
    const success = await session.attempt({
      email: 'alice@example.com',
      password: 'correct-password',
    });
    expect(success).toBe(true);

    // Session is now active
    expect(await session.check()).toBe(true);

    // User is resolved
    const user = await session.user();
    expect(user).not.toBeNull();
    expect(user!.email).toBe('alice@example.com');
    expect(user!.name).toBe('Alice');

    // User ID is available
    expect(await session.id()).toBe('1');

    // Logout clears the session
    await session.logout();
    expect(await session.check()).toBe(false);
    expect(await session.user()).toBeNull();
    expect(await session.id()).toBeNull();
  });

  it('attempt fails with wrong password', async () => {
    const { auth } = await setupAuth();
    const session = auth();

    const success = await session.attempt({
      email: 'alice@example.com',
      password: 'wrong-password',
    });
    expect(success).toBe(false);
    expect(await session.check()).toBe(false);
  });

  it('attempt fails with unknown email', async () => {
    const { auth } = await setupAuth();
    const session = auth();

    const success = await session.attempt({
      email: 'unknown@example.com',
      password: 'correct-password',
    });
    expect(success).toBe(false);
  });

  it('login directly with a user object', async () => {
    const { auth, users } = await setupAuth();
    const session = auth();

    await session.login(users[0]);
    expect(await session.check()).toBe(true);
    expect(await session.user()).toEqual(users[0]);
  });

  it('loginById resolves and logs in', async () => {
    const { auth } = await setupAuth();
    const session = auth();

    await session.loginById('1');
    expect(await session.check()).toBe(true);
    expect(await session.id()).toBe('1');
  });
});

Testing with a mock auth instance

When unit testing components or server actions that use auth (rather than testing the auth system itself), you do not need a real auth instance. Mock the AuthInstance interface directly:

import type { AuthInstance } from 'ideal-auth';

interface User {
  id: string;
  email: string;
  name: string;
}

function createMockAuthInstance(user: User | null): AuthInstance<User> {
  return {
    check: async () => user !== null,
    user: async () => user,
    id: async () => user?.id ?? null,
    login: async () => {},
    loginById: async () => {},
    logout: async () => {},
    attempt: async () => user !== null,
  };
}

Use it in your tests:

// Test an authenticated scenario
const authedSession = createMockAuthInstance({
  id: '1',
  email: 'test@example.com',
  name: 'Test User',
});

expect(await authedSession.check()).toBe(true);
expect((await authedSession.user())!.email).toBe('test@example.com');

// Test an unauthenticated scenario
const guestSession = createMockAuthInstance(null);

expect(await guestSession.check()).toBe(false);
expect(await guestSession.user()).toBeNull();

This is useful for testing:

  • Server components that call session.user() and render conditionally
  • Server actions that call session.check() as a guard
  • API route handlers that read session.id() to scope database queries
  • Middleware logic that branches on authentication state

Testing token verifier

The token verifier is stateless and deterministic (given the same secret and purpose), so it is straightforward to test. Use a test secret that is at least 32 characters long. The purpose field is required — creation throws without it:

import { createTokenVerifier } from 'ideal-auth';

const SECRET = 'test-secret-that-is-at-least-32-chars';

describe('token verifier', () => {
  const verifier = createTokenVerifier({ secret: SECRET, purpose: 'test-tokens' });

  it('creates and verifies a token', () => {
    const token = verifier.createToken('user-1');
    const result = verifier.verifyToken(token);

    expect(result).not.toBeNull();
    expect(result!.userId).toBe('user-1');
    expect(result!.iatMs).toBeLessThanOrEqual(Date.now());
  });

  it('returns null for an expired token', () => {
    const expiredVerifier = createTokenVerifier({
      secret: SECRET,
      purpose: 'test-tokens',
      expiryMs: 0, // expires immediately
    });
    const token = expiredVerifier.createToken('user-1');

    expect(expiredVerifier.verifyToken(token)).toBeNull();
  });

  it('returns null with a different secret', () => {
    const token = verifier.createToken('user-1');
    const otherVerifier = createTokenVerifier({
      secret: 'different-secret-at-least-32-characters',
      purpose: 'test-tokens',
    });

    expect(otherVerifier.verifyToken(token)).toBeNull();
  });

  it('returns null with a different purpose', () => {
    // The purpose is mixed into the token signature, so a token from one
    // flow never verifies in another — even with the same secret.
    const token = verifier.createToken('user-1');
    const otherPurpose = createTokenVerifier({
      secret: SECRET,
      purpose: 'password-reset',
    });

    expect(otherPurpose.verifyToken(token)).toBeNull();
  });

  it('returns null for tampered tokens', () => {
    const token = verifier.createToken('user-1');
    const tampered = token.slice(0, -1) + 'x';

    expect(verifier.verifyToken(tampered)).toBeNull();
  });

  it('returns null for malformed strings', () => {
    expect(verifier.verifyToken('')).toBeNull();
    expect(verifier.verifyToken('not-a-token')).toBeNull();
    expect(verifier.verifyToken('a.b')).toBeNull();
  });
});

Testing rate limiting

Use the built-in MemoryRateLimitStore for tests. Each test gets a fresh store, so there is no cross-test contamination:

import { createRateLimiter } from 'ideal-auth';

describe('rate limiting', () => {
  it('allows up to maxAttempts, then blocks', async () => {
    const limiter = createRateLimiter({
      maxAttempts: 3,
      windowMs: 60_000,
    });

    const first = await limiter.attempt('test-key');
    expect(first.allowed).toBe(true);
    expect(first.remaining).toBe(2);

    const second = await limiter.attempt('test-key');
    expect(second.allowed).toBe(true);
    expect(second.remaining).toBe(1);

    const third = await limiter.attempt('test-key');
    expect(third.allowed).toBe(true);
    expect(third.remaining).toBe(0);

    // Fourth attempt is blocked
    const fourth = await limiter.attempt('test-key');
    expect(fourth.allowed).toBe(false);
    expect(fourth.remaining).toBe(0);
    expect(fourth.resetAt).toBeInstanceOf(Date);
  });

  it('reset clears the limit', async () => {
    const limiter = createRateLimiter({
      maxAttempts: 1,
      windowMs: 60_000,
    });

    await limiter.attempt('test-key');
    expect((await limiter.attempt('test-key')).allowed).toBe(false);

    await limiter.reset('test-key');
    expect((await limiter.attempt('test-key')).allowed).toBe(true);
  });

  it('different keys are independent', async () => {
    const limiter = createRateLimiter({
      maxAttempts: 1,
      windowMs: 60_000,
    });

    await limiter.attempt('key-a');
    expect((await limiter.attempt('key-a')).allowed).toBe(false);
    expect((await limiter.attempt('key-b')).allowed).toBe(true);
  });
});

Testing TOTP

Testing TOTP requires generating a valid code for a known secret. The library’s own test suite uses a helper function to compute TOTP codes from first principles. Here is a simplified version you can use in your tests:

import { createHmac } from 'node:crypto';
import { createTOTP } from 'ideal-auth';

// Helper: compute a TOTP code for a given secret at the current time + offset
function generateTestCode(
  secret: string,
  digits: number = 6,
  period: number = 30,
  offset: number = 0,
): string {
  const counter = Math.floor(Date.now() / 1000 / period) + offset;
  const buf = Buffer.alloc(8);
  let tmp = counter;
  for (let i = 7; i >= 0; i--) {
    buf[i] = tmp & 0xff;
    tmp = Math.floor(tmp / 256);
  }
  // Decode base32 secret
  const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
  let bits = '';
  for (const ch of secret) {
    bits += alphabet.indexOf(ch).toString(2).padStart(5, '0');
  }
  const bytes = new Uint8Array(Math.floor(bits.length / 8));
  for (let i = 0; i < bytes.length; i++) {
    bytes[i] = parseInt(bits.slice(i * 8, i * 8 + 8), 2);
  }

  const hmac = createHmac('sha1', bytes).update(buf).digest();
  const off = hmac[hmac.length - 1] & 0x0f;
  const code =
    ((hmac[off] & 0x7f) << 24) |
    ((hmac[off + 1] & 0xff) << 16) |
    ((hmac[off + 2] & 0xff) << 8) |
    (hmac[off + 3] & 0xff);
  return String(code % 10 ** digits).padStart(digits, '0');
}

TOTP verification tests

describe('TOTP', () => {
  const totp = createTOTP();

  it('generates a secret', () => {
    const secret = totp.generateSecret();
    expect(secret).toMatch(/^[A-Z2-7]+$/);
    expect(secret).toHaveLength(32);
  });

  it('verifies a valid code', () => {
    const secret = totp.generateSecret();
    const code = generateTestCode(secret);
    expect(totp.verify(code, secret, null).valid).toBe(true);
  });

  it('rejects an invalid code', () => {
    const secret = totp.generateSecret();
    expect(totp.verify('000000', secret, null).valid).toBe(false);
  });

  it('accepts codes within the window (default +-1 step)', () => {
    const secret = totp.generateSecret();
    const pastCode = generateTestCode(secret, 6, 30, -1);
    const futureCode = generateTestCode(secret, 6, 30, 1);
    expect(totp.verify(pastCode, secret, null).valid).toBe(true);
    expect(totp.verify(futureCode, secret, null).valid).toBe(true);
  });

  it('rejects a replayed code', () => {
    const secret = totp.generateSecret();
    const code = generateTestCode(secret);

    const first = totp.verify(code, secret, null);
    expect(first.valid).toBe(true);

    // Passing the persisted counter back rejects the same code
    const second = totp.verify(code, secret, first.counter);
    expect(second.valid).toBe(false);
  });

  it('generates a valid QR URI', () => {
    const secret = totp.generateSecret();
    const uri = totp.generateQrUri({
      secret,
      issuer: 'TestApp',
      account: 'user@test.com',
    });
    expect(uri).toMatch(/^otpauth:\/\/totp\//);
    expect(uri).toContain(`secret=${secret}`);
    expect(uri).toContain('issuer=TestApp');
  });
});

Testing recovery codes

import { createHash, generateRecoveryCodes, verifyRecoveryCode } from 'ideal-auth';

describe('recovery codes', () => {
  const hash = createHash({ rounds: 4 });

  it('generates codes and verifies them', async () => {
    const { codes, hashed } = await generateRecoveryCodes(hash);

    expect(codes).toHaveLength(8);
    expect(hashed).toHaveLength(8);

    // Verify the first code
    const result = await verifyRecoveryCode(codes[0], hashed, hash);
    expect(result.valid).toBe(true);
    expect(result.remaining).toHaveLength(7);
  });

  it('rejects an invalid code', async () => {
    const { hashed } = await generateRecoveryCodes(hash);

    const result = await verifyRecoveryCode('invalid-code', hashed, hash);
    expect(result.valid).toBe(false);
    expect(result.remaining).toHaveLength(8); // unchanged
  });

  it('removes used codes from remaining', async () => {
    const { codes, hashed } = await generateRecoveryCodes(hash, 3);

    // Use the first code
    const first = await verifyRecoveryCode(codes[0], hashed, hash);
    expect(first.valid).toBe(true);
    expect(first.remaining).toHaveLength(2);

    // Use the third code with the updated remaining hashes
    const second = await verifyRecoveryCode(codes[2], first.remaining, hash);
    expect(second.valid).toBe(true);
    expect(second.remaining).toHaveLength(1);
  });
});

Framework-specific testing tips

Next.js (App Router)

Next.js provides cookies() from next/headers. In tests, mock this function to return your test cookie bridge:

import { vi, describe, it, expect } from 'vitest';

// Mock next/headers before importing your auth module
vi.mock('next/headers', () => {
  const store = new Map<string, string>();
  return {
    cookies: () => ({
      get: (name: string) => {
        const value = store.get(name);
        return value ? { name, value } : undefined;
      },
      set: (name: string, value: string, _options?: any) => {
        store.set(name, value);
      },
      delete: (name: string) => {
        store.delete(name);
      },
    }),
  };
});

// Now import your auth module — it will use the mocked cookies()
import { auth } from '@/lib/auth';

describe('protected server action', () => {
  it('returns user data when authenticated', async () => {
    const session = auth();
    // Login with a test user to populate the mock cookie store
    await session.login({ id: '1', email: 'test@example.com', name: 'Test' });

    expect(await session.check()).toBe(true);
  });
});
// __mocks__/next/headers.ts
const store = new Map<string, string>();

export function cookies() {
  return {
    get: (name: string) => {
      const value = store.get(name);
      return value ? { name, value } : undefined;
    },
    set: (name: string, value: string, _options?: any) => {
      store.set(name, value);
    },
    delete: (name: string) => {
      store.delete(name);
    },
  };
}
// your-test.test.ts
jest.mock('next/headers');
import { auth } from '@/lib/auth';

describe('protected route', () => {
  it('works with mocked cookies', async () => {
    const session = auth();
    await session.login({ id: '1', email: 'test@example.com', name: 'Test' });
    expect(await session.check()).toBe(true);
  });
});

If your auth module accepts a cookie bridge as a parameter (or you can inject one through a factory), the simplest approach is to pass the mock bridge directly:

// lib/auth.ts
import { createAuth, createHash } from 'ideal-auth';
import type { CookieBridge } from 'ideal-auth';

const hash = createHash();

export function createAppAuth(cookie: CookieBridge) {
  return createAuth({
    secret: process.env.IDEAL_AUTH_SECRET!,
    cookie,
    hash,
    resolveUser: (id) => db.user.findUnique({ where: { id } }),
    resolveUserByCredentials: (creds) =>
      db.user.findUnique({ where: { email: creds.email } }),
  });
}
// auth.test.ts
import { createAppAuth } from '@/lib/auth';

const bridge = createMockCookieBridge(); // from the "Mocking the cookie bridge" section
const auth = createAppAuth(bridge);
const session = auth();

// ... test as normal

E2E with Playwright

For end-to-end tests, you do not mock anything. Test the complete flow through the browser:

import { test, expect } from '@playwright/test';

test('login flow', async ({ page }) => {
  // Navigate to the login page
  await page.goto('/login');

  // Fill in credentials
  await page.fill('input[name="email"]', 'alice@example.com');
  await page.fill('input[name="password"]', 'correct-password');
  await page.click('button[type="submit"]');

  // Verify redirect to dashboard
  await expect(page).toHaveURL('/dashboard');

  // Verify the session cookie was set
  const cookies = await page.context().cookies();
  const sessionCookie = cookies.find((c) => c.name === 'ideal_session');
  expect(sessionCookie).toBeDefined();
  expect(sessionCookie!.httpOnly).toBe(true);

  // Verify authenticated content is visible
  await expect(page.getByText('Welcome, Alice')).toBeVisible();
});

test('logout flow', async ({ page }) => {
  // Login first (reuse a helper or storage state)
  await page.goto('/login');
  await page.fill('input[name="email"]', 'alice@example.com');
  await page.fill('input[name="password"]', 'correct-password');
  await page.click('button[type="submit"]');
  await expect(page).toHaveURL('/dashboard');

  // Logout
  await page.click('button:has-text("Logout")');

  // Verify redirect to login
  await expect(page).toHaveURL('/login');

  // Verify the session cookie was removed
  const cookies = await page.context().cookies();
  const sessionCookie = cookies.find((c) => c.name === 'ideal_session');
  expect(sessionCookie).toBeUndefined();
});

test('protected route redirects when unauthenticated', async ({ page }) => {
  await page.goto('/dashboard');
  await expect(page).toHaveURL('/login');
});

Test helpers

Here is a reusable createTestAuth() factory that wires up a mock cookie bridge with a test user store. Copy it into a shared test utilities file:

// test/helpers/auth.ts
import { createAuth, createHash } from 'ideal-auth';
import type { CookieBridge, CookieOptions, AuthInstance } from 'ideal-auth';

interface TestUser {
  id: string;
  email: string;
  name: string;
  password: string;
}

interface MockCookieBridge extends CookieBridge {
  store: Map<string, string>;
  lastOptions: CookieOptions | undefined;
}

export function createMockCookieBridge(): MockCookieBridge {
  const store = new Map<string, string>();
  return {
    store,
    lastOptions: undefined,
    get(name: string) {
      return store.get(name);
    },
    set(name: string, value: string, options: CookieOptions) {
      store.set(name, value);
      this.lastOptions = options;
    },
    delete(name: string) {
      store.delete(name);
    },
  };
}

const hash = createHash({ rounds: 4 });

interface TestAuthResult {
  auth: () => AuthInstance<TestUser>;
  bridge: MockCookieBridge;
  users: TestUser[];
  hash: typeof hash;
}

export async function createTestAuth(
  overrides: Partial<TestUser>[] = [],
): Promise<TestAuthResult> {
  const hashedPassword = await hash.make('password123');

  const users: TestUser[] = [
    {
      id: '1',
      email: 'alice@example.com',
      name: 'Alice',
      password: hashedPassword,
      ...overrides[0],
    },
    ...(overrides.slice(1).map((o, i) => ({
      id: String(i + 2),
      email: `user${i + 2}@example.com`,
      name: `User ${i + 2}`,
      password: hashedPassword,
      ...o,
    }))),
  ];

  const bridge = createMockCookieBridge();

  const auth = createAuth<TestUser>({
    secret: 'a'.repeat(32),
    cookie: bridge,
    hash,
    resolveUser: async (id) => users.find((u) => u.id === id) ?? null,
    resolveUserByCredentials: async (creds) =>
      users.find((u) => u.email === creds.email) ?? null,
  });

  return { auth, bridge, users, hash };
}

Using the helper

import { describe, it, expect } from 'vitest';
import { createTestAuth } from '../helpers/auth';

describe('my feature', () => {
  it('requires authentication', async () => {
    const { auth } = await createTestAuth();
    const session = auth();

    // Not logged in
    expect(await session.check()).toBe(false);

    // Log in
    await session.attempt({ email: 'alice@example.com', password: 'password123' });
    expect(await session.check()).toBe(true);
  });

  it('works with custom users', async () => {
    const { auth } = await createTestAuth([
      { email: 'admin@example.com', name: 'Admin' },
    ]);
    const session = auth();

    await session.attempt({ email: 'admin@example.com', password: 'password123' });
    const user = await session.user();
    expect(user!.name).toBe('Admin');
  });

  it('can inspect cookie state', async () => {
    const { auth, bridge } = await createTestAuth();
    const session = auth();

    await session.attempt({ email: 'alice@example.com', password: 'password123' });

    // The session cookie was set
    expect(bridge.store.has('ideal_session')).toBe(true);

    // Cookie options were applied
    expect(bridge.lastOptions?.httpOnly).toBe(true);
  });
});
Navigation

Type to search…

↑↓ navigate↵ selectEsc close