Testing NextAuth-Protected Routes

Auth.js — the library formerly known as NextAuth — puts one function at the centre of every protected surface in a Next.js App Router application: auth(). Route handlers call it, server components call it, server actions call it, and middleware wraps it. That single seam is the right place to control identity in tests, and it is also where most attempts go wrong, either by mocking so broadly that the session callbacks never run or by trying to drive a real OAuth provider from a unit test. This guide covers injecting sessions at that seam with Vitest, keeping your own callbacks under test, covering the signed-out and wrong-role paths, and testing the middleware separately. It targets Next.js 14 or 15 with Auth.js v5, and sits under authentication and session mocking.

Root Cause Analysis

Next.js applications make authentication awkward to test because identity arrives through request context rather than as a parameter. A server component does not receive the user; it calls auth(), which reads cookies from the request that the framework has in scope. Outside a real request — which is where a Vitest test runs — there is no cookie store, and the call fails or returns nothing.

Teams respond in one of two unhelpful ways. The first is to mock the entire next-auth module, which makes auth() return a fixed object but also bypasses the session and jwt callbacks — the application code that decides which fields a session carries, and which is exactly where role mapping and tenant resolution usually live. The second is to test protected pages only through Playwright, which works but pays browser cost for what are mostly permission decisions.

The better seam is one layer in. Your application exports its own auth from a local configuration module; mocking that export, with a session produced by running your real callbacks against a test token, gives you fast tests that still exercise the code you wrote.

Where to control identity in an Auth.js application Mocking the whole next-auth package skips your session callbacks, while controlling the app's own auth export with a session built by those callbacks keeps your role mapping under test and still avoids a real provider. next-auth package providers, cookies your callbacks jwt, session, roles your auth() export src/auth.ts mock the package callbacks never run role mapping untested control your export session built by your callbacks no provider, no cookies
The seam one layer in keeps your callbacks under test while removing the parts that need a real request.

Reproducible Setup

Keep the Auth.js configuration in one local module and export the callbacks separately, so tests can run them without the framework.

// src/auth.config.ts — callbacks are plain functions, exported for tests
import type { NextAuthConfig } from 'next-auth';

export const callbacks: NextAuthConfig['callbacks'] = {
  async jwt({ token, profile }) {
    if (profile) token.roles = mapGroupsToRoles((profile as any).groups ?? []);
    return token;
  },
  async session({ session, token }) {
    session.user.id = token.sub!;
    session.user.roles = (token.roles as string[]) ?? ['member'];
    return session;
  },
};

export function mapGroupsToRoles(groups: string[]): string[] {
  const roles = new Set<string>(['member']);
  if (groups.includes('support-staff')) roles.add('support');
  if (groups.includes('platform-admins')) roles.add('admin');
  return [...roles];
}
// src/auth.ts
import NextAuth from 'next-auth';
import GitHub from 'next-auth/providers/github';
import { callbacks } from './auth.config';

export const { auth, handlers, signIn, signOut } = NextAuth({ providers: [GitHub], callbacks });

Implementation

Step 1 — Test the callbacks directly. They are the part of the auth setup you wrote, and they are pure enough to test without any framework at all.

// src/auth.config.test.ts
import { test, expect } from 'vitest';
import { callbacks, mapGroupsToRoles } from './auth.config';

test('maps directory groups to application roles', () => {
  expect(mapGroupsToRoles(['support-staff'])).toEqual(['member', 'support']);
  expect(mapGroupsToRoles([])).toEqual(['member']);
});

test('the session callback exposes id and roles from the token', async () => {
  const session = await callbacks!.session!({
    session: { user: { name: 'Ada' }, expires: '2099-01-01' } as any,
    token: { sub: 'u1', roles: ['admin'] } as any,
  } as any);
  expect(session.user).toMatchObject({ id: 'u1', roles: ['admin'] });
});

Step 2 — Build test sessions by running the real callbacks. A helper that pushes a token through your session callback produces exactly the shape production code sees — no hand-written session objects drifting out of date.

// test/auth/session.ts
import { callbacks } from '../../src/auth.config';

export async function aSession({ sub = 'user_1', roles = ['member'] } = {}) {
  return callbacks!.session!({
    session: { user: { name: 'Test User', email: 'test@example.test' }, expires: '2099-01-01T00:00:00.000Z' } as any,
    token: { sub, roles } as any,
  } as any);
}

Step 3 — Control the app’s auth export per test. Mock the local module rather than the package, and let each test decide who is signed in.

// src/app/api/refunds/route.test.ts
import { vi, test, expect, beforeEach } from 'vitest';
import { aSession } from '../../../../test/auth/session';

const authMock = vi.hoisted(() => vi.fn());
vi.mock('@/auth', () => ({ auth: authMock }));

import { POST } from './route';

beforeEach(() => authMock.mockReset());

test('rejects a signed-out request', async () => {
  authMock.mockResolvedValue(null);
  const res = await POST(new Request('http://test/api/refunds', { method: 'POST', body: '{}' }));
  expect(res.status).toBe(401);
});

test('forbids a member from refunding', async () => {
  authMock.mockResolvedValue(await aSession({ roles: ['member'] }));
  const res = await POST(new Request('http://test/api/refunds', { method: 'POST', body: JSON.stringify({ orderId: 'o1', amountPence: 500 }) }));
  expect(res.status).toBe(403);
});

Step 4 — Test server components by awaiting them. An async server component is a function returning JSX; call it with the session controlled, then render the result.

// src/app/account/page.test.tsx
import { render, screen } from '@testing-library/react';
import { vi, test, expect } from 'vitest';
import { aSession } from '../../../test/auth/session';

const authMock = vi.hoisted(() => vi.fn());
vi.mock('@/auth', () => ({ auth: authMock }));
vi.mock('next/navigation', () => ({ redirect: vi.fn(() => { throw new Error('NEXT_REDIRECT'); }) }));

import AccountPage from './page';

test('shows the admin panel link only to admins', async () => {
  authMock.mockResolvedValue(await aSession({ roles: ['member', 'admin'] }));
  render(await AccountPage());
  expect(screen.getByRole('link', { name: 'Admin panel' })).toBeInTheDocument();
});

test('redirects a signed-out visitor to sign in', async () => {
  authMock.mockResolvedValue(null);
  await expect(AccountPage()).rejects.toThrow('NEXT_REDIRECT');
});
Four protected surfaces and how to test each Route handlers are called with a Request, server components are awaited and rendered, server actions are invoked as functions, and middleware is tested separately with a constructed NextRequest — all with the session controlled at the app's auth export. route handler call POST() with a new Request assert on status server component await Page() then render it assert on the DOM server action call it as an async function assert on the result middleware a NextRequest per path assert on redirect
One controlled seam, four surfaces — none of which needs a browser or a provider.

Step 5 — Test server actions as the functions they are. A server action that checks the session is callable directly once auth is controlled.

// src/app/orders/actions.test.ts
authMock.mockResolvedValue(await aSession({ sub: 'user_7' }));
const result = await cancelOrder({ orderId: 'o_owned_by_someone_else' });
expect(result).toEqual({ ok: false, error: 'not_your_order' });

Step 6 — Test middleware on its own terms. Middleware decides redirects based on path and session presence; construct a NextRequest per path and assert on the response.

// src/middleware.test.ts
import { NextRequest } from 'next/server';
import { vi, test, expect } from 'vitest';

vi.mock('@/auth', () => ({ auth: (handler: any) => (req: any) => handler(Object.assign(req, { auth: null })) }));
import middleware from './middleware';

test('redirects an anonymous visitor away from /account', async () => {
  const res = await middleware(new NextRequest('http://test/account'), {} as any);
  expect(res?.headers.get('location')).toContain('/api/auth/signin');
});

Verification

Run the suite and confirm the signed-out and wrong-role paths are present for every protected surface, not only the happy path.

npx vitest run src/app --reporter=verbose | grep -E "signed-out|forbids|redirects"
# ✓ rejects a signed-out request
# ✓ forbids a member from refunding
# ✓ redirects a signed-out visitor to sign in
# ✓ redirects an anonymous visitor away from /account

Then prove the callbacks are genuinely in the loop. Change the role mapping — make support-staff map to nothing — and confirm that both the callback test and at least one route test fail. If only the callback test fails, the route tests are using hand-built sessions that bypass the callbacks.

Proving the callbacks are part of the test path Breaking the role mapping should fail the callback test and a route test together; if only the callback test fails, the route tests are building sessions by hand and the callbacks are not really covered. both fail callback test and route test sessions come from callbacks the setup is right only the callback fails route tests use hand-built sessions role mapping untested in context switch them to aSession()
A deliberate break tells you in one run whether the session helper is doing its job.

Troubleshooting

Symptom: auth() throws about headers being called outside a request scope. Diagnosis: the real auth is running because the mock targets next-auth while the code imports from @/auth, or the alias is not configured in Vitest. Fix: mock the exact specifier the code imports, and make sure the @ alias resolves in the Vitest configuration the same way it does in Next.js.

Symptom: the mock is ignored and the real module loads. Diagnosis: the mocked function was defined in module scope and referenced inside vi.mock’s factory, which is hoisted above it. Fix: create it with vi.hoisted, as in Step 3 — the hoisting rules are explained in avoiding vi.mock hoisting pitfalls.

Symptom: redirect() does nothing in tests. Diagnosis: in Next.js, redirect throws a special error that the framework catches; in a test there is no framework to catch it. Fix: mock next/navigation’s redirect to throw a recognisable error and assert that the component rejects with it.

Symptom: types complain that session.user.roles does not exist. Diagnosis: the module augmentation for the session type is not included in the test compilation. Fix: include the declaration file in the test tsconfig, so tests and production share one definition of what a session contains.

FAQ

Should I ever test the real OAuth sign-in flow?

Once, at the end-to-end tier, against a stubbed provider — which is what stubbing OAuth redirect flows in Playwright covers. Everything downstream of a successful sign-in is a question of what the session contains, which the approach here answers far more cheaply.

Why not mock next-auth directly?

Because it removes your callbacks from the test path. The callbacks are the most application-specific part of an Auth.js setup — role mapping, tenant resolution, field shaping — and a mock of the package replaces them with whatever the mock returns. Controlling your own export keeps them in play.

Does this work with the database session strategy?

Yes. With database sessions, auth() reads a session row rather than decoding a token, but your code still calls the same function and receives the same shape. Controlling the export works identically; for integration tests that need a real row, see faking session cookies in integration tests.

How do I test a page that behaves differently for several roles?

Parameterise the test over sessions built with aSession, one per role, and assert on what each should see. Because the session comes from your callbacks, adding a role to the mapping automatically flows into these tests, which is exactly the coupling you want.