Skip to content

Fix: Auth.js (NextAuth) Not Working — Session Null, OAuth Callback Error, or CSRF Token Mismatch

FixDevs ·

Quick Answer

How to fix Auth.js and NextAuth.js issues — OAuth provider setup, session handling in App Router and Pages Router, JWT vs database sessions, middleware protection, and credential provider configuration.

The Problem

useSession() always returns null even after login:

const { data: session } = useSession();
console.log(session);  // null — even after successful OAuth login

Or the OAuth callback fails:

[next-auth][error][OAUTH_CALLBACK_ERROR]
Error: oauth_callback_error — state mismatch

Or you get a CSRF token mismatch:

[next-auth][error][CSRF_TOKEN_MISMATCH]

Or the session exists in API routes but not in Server Components:

// app/page.tsx (Server Component)
const session = await auth();
console.log(session);  // null

Why This Happens

Auth.js (v5, formerly NextAuth.js v4) handles authentication through OAuth providers, credentials, and session management. Its configuration changed significantly between v4 and v5:

  • SessionProvider is required for client componentsuseSession() reads from a React context. Without SessionProvider wrapping your app, the hook always returns null. In App Router, the provider goes in a client component wrapper.
  • OAuth state/CSRF errors come from cookie misconfiguration — Auth.js stores state and CSRF tokens in cookies. If cookies are blocked (wrong domain, SameSite policy, missing HTTPS in production), the callback can’t verify the state parameter.
  • NEXTAUTH_URL must match the actual URL — in production, Auth.js uses NEXTAUTH_URL (v4) or AUTH_URL (v5) to construct callback URLs. A mismatch between this variable and the actual domain causes OAuth providers to reject the redirect.
  • v5 uses auth() instead of getServerSession() — the API changed. getServerSession(authOptions) is v4. In v5, you export auth from your config and call it directly.

Fix 1: Auth.js v5 Setup (App Router)

npm install next-auth@beta @auth/core
// auth.ts — root of your project
import NextAuth from 'next-auth';
import GitHub from 'next-auth/providers/github';
import Google from 'next-auth/providers/google';
import Credentials from 'next-auth/providers/credentials';
import { DrizzleAdapter } from '@auth/drizzle-adapter';
import { db } from './src/db';
import bcrypt from 'bcryptjs';

export const { handlers, auth, signIn, signOut } = NextAuth({
  adapter: DrizzleAdapter(db),  // Optional: database sessions
  providers: [
    GitHub({
      clientId: process.env.AUTH_GITHUB_ID!,
      clientSecret: process.env.AUTH_GITHUB_SECRET!,
    }),
    Google({
      clientId: process.env.AUTH_GOOGLE_ID!,
      clientSecret: process.env.AUTH_GOOGLE_SECRET!,
    }),
    Credentials({
      credentials: {
        email: { label: 'Email', type: 'email' },
        password: { label: 'Password', type: 'password' },
      },
      authorize: async (credentials) => {
        const user = await db.query.users.findFirst({
          where: eq(users.email, credentials.email as string),
        });

        if (!user || !user.passwordHash) return null;

        const valid = await bcrypt.compare(
          credentials.password as string,
          user.passwordHash,
        );

        if (!valid) return null;

        return { id: user.id, name: user.name, email: user.email };
      },
    }),
  ],
  session: {
    strategy: 'jwt',  // Use 'database' with an adapter for DB sessions
  },
  callbacks: {
    async jwt({ token, user }) {
      if (user) {
        token.id = user.id;
        token.role = user.role;
      }
      return token;
    },
    async session({ session, token }) {
      if (token) {
        session.user.id = token.id as string;
        session.user.role = token.role as string;
      }
      return session;
    },
    async authorized({ auth, request }) {
      // Used by middleware — return true to allow, false to redirect
      const isLoggedIn = !!auth?.user;
      const isProtected = request.nextUrl.pathname.startsWith('/dashboard');
      if (isProtected && !isLoggedIn) return false;
      return true;
    },
  },
  pages: {
    signIn: '/login',       // Custom sign-in page
    error: '/auth/error',   // Custom error page
  },
});
// app/api/auth/[...nextauth]/route.ts — API route handler
import { handlers } from '@/auth';

export const { GET, POST } = handlers;
// middleware.ts — protect routes
export { auth as middleware } from '@/auth';

export const config = {
  matcher: ['/dashboard/:path*', '/settings/:path*', '/api/protected/:path*'],
};

Fix 2: Session Access in Server and Client Components

// app/layout.tsx — wrap with SessionProvider for client components
import { SessionProvider } from 'next-auth/react';

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html>
      <body>
        <SessionProvider>
          {children}
        </SessionProvider>
      </body>
    </html>
  );
}

// Server Component — use auth() directly
// app/dashboard/page.tsx
import { auth } from '@/auth';
import { redirect } from 'next/navigation';

export default async function DashboardPage() {
  const session = await auth();

  if (!session) redirect('/login');

  return <h1>Welcome, {session.user.name}</h1>;
}

// Client Component — use useSession()
// components/UserMenu.tsx
'use client';

import { useSession, signIn, signOut } from 'next-auth/react';

export function UserMenu() {
  const { data: session, status } = useSession();

  if (status === 'loading') return <div>Loading...</div>;

  if (!session) {
    return <button onClick={() => signIn()}>Sign In</button>;
  }

  return (
    <div>
      <span>{session.user.name}</span>
      <button onClick={() => signOut()}>Sign Out</button>
    </div>
  );
}

// Server Action — use auth() in actions
// app/actions.ts
'use server';

import { auth } from '@/auth';

export async function createPost(formData: FormData) {
  const session = await auth();
  if (!session) throw new Error('Not authenticated');

  const title = formData.get('title') as string;
  // ... create post with session.user.id as author
}

// API Route — use auth() in route handlers
// app/api/posts/route.ts
import { auth } from '@/auth';

export async function GET() {
  const session = await auth();
  if (!session) return Response.json({ error: 'Unauthorized' }, { status: 401 });

  // ... return posts
}

Fix 3: Fix OAuth Callback Errors

# .env.local — required environment variables
AUTH_SECRET=your-random-secret-at-least-32-characters  # v5
# NEXTAUTH_SECRET=...  # v4

AUTH_URL=http://localhost:3000  # Development
# AUTH_URL=https://myapp.com  # Production

AUTH_GITHUB_ID=your-github-oauth-app-client-id
AUTH_GITHUB_SECRET=your-github-oauth-app-client-secret

AUTH_GOOGLE_ID=your-google-client-id
AUTH_GOOGLE_SECRET=your-google-client-secret
# Generate a secret
npx auth secret
# Or: openssl rand -base64 32

OAuth provider callback URL configuration:

ProviderCallback URL
GitHubhttps://yourapp.com/api/auth/callback/github
Googlehttps://yourapp.com/api/auth/callback/google
Discordhttps://yourapp.com/api/auth/callback/discord

Common callback fixes:

  • State mismatch — caused by cookie issues. Check that AUTH_URL matches your actual domain. In development, use http://localhost:3000, not http://127.0.0.1:3000.
  • CSRF mismatch — same cookie issue. Clear browser cookies and retry. If behind a reverse proxy, set trustHost: true in your Auth.js config.
  • Redirect URI mismatch — the callback URL registered with the OAuth provider must exactly match what Auth.js sends. Check for trailing slashes and protocol (http vs https).
// auth.ts — fix for reverse proxy / load balancer
export const { handlers, auth } = NextAuth({
  trustHost: true,  // Trust the X-Forwarded-Host header
  // ...
});

Fix 4: TypeScript — Extend Session Types

// types/next-auth.d.ts
import { DefaultSession, DefaultUser } from 'next-auth';
import { DefaultJWT } from 'next-auth/jwt';

declare module 'next-auth' {
  interface Session {
    user: {
      id: string;
      role: string;
    } & DefaultSession['user'];
  }

  interface User extends DefaultUser {
    role: string;
  }
}

declare module 'next-auth/jwt' {
  interface JWT {
    id: string;
    role: string;
  }
}

Fix 5: NextAuth.js v4 (Pages Router)

If you’re still on v4:

// pages/api/auth/[...nextauth].ts
import NextAuth, { type NextAuthOptions } from 'next-auth';
import GitHubProvider from 'next-auth/providers/github';

export const authOptions: NextAuthOptions = {
  providers: [
    GitHubProvider({
      clientId: process.env.GITHUB_ID!,
      clientSecret: process.env.GITHUB_SECRET!,
    }),
  ],
  secret: process.env.NEXTAUTH_SECRET,
  callbacks: {
    async session({ session, token }) {
      if (token.sub) session.user.id = token.sub;
      return session;
    },
  },
};

export default NextAuth(authOptions);
// Server-side (Pages Router)
import { getServerSession } from 'next-auth';
import { authOptions } from '@/pages/api/auth/[...nextauth]';

// In getServerSideProps
export async function getServerSideProps(context) {
  const session = await getServerSession(context.req, context.res, authOptions);
  if (!session) return { redirect: { destination: '/login', permanent: false } };
  return { props: { session } };
}

// In API routes
export default async function handler(req, res) {
  const session = await getServerSession(req, res, authOptions);
  if (!session) return res.status(401).json({ error: 'Unauthorized' });
  // ...
}

Fix 6: Database Sessions with Drizzle or Prisma

npm install @auth/drizzle-adapter
# Or: npm install @auth/prisma-adapter
// Drizzle adapter setup
import { DrizzleAdapter } from '@auth/drizzle-adapter';
import { db } from './db';

export const { handlers, auth } = NextAuth({
  adapter: DrizzleAdapter(db),
  session: { strategy: 'database' },  // Store sessions in DB
  providers: [GitHub({ /* ... */ })],
});

// Required Drizzle schema tables
// See: https://authjs.dev/getting-started/adapters/drizzle
import { pgTable, text, timestamp, primaryKey, integer } from 'drizzle-orm/pg-core';

export const users = pgTable('user', {
  id: text('id').primaryKey().$defaultFn(() => crypto.randomUUID()),
  name: text('name'),
  email: text('email').notNull(),
  emailVerified: timestamp('emailVerified', { mode: 'date' }),
  image: text('image'),
});

export const accounts = pgTable('account', {
  userId: text('userId').notNull().references(() => users.id, { onDelete: 'cascade' }),
  type: text('type').notNull(),
  provider: text('provider').notNull(),
  providerAccountId: text('providerAccountId').notNull(),
  refresh_token: text('refresh_token'),
  access_token: text('access_token'),
  expires_at: integer('expires_at'),
  token_type: text('token_type'),
  scope: text('scope'),
  id_token: text('id_token'),
  session_state: text('session_state'),
}, (account) => ({
  compoundKey: primaryKey({ columns: [account.provider, account.providerAccountId] }),
}));

export const sessions = pgTable('session', {
  sessionToken: text('sessionToken').primaryKey(),
  userId: text('userId').notNull().references(() => users.id, { onDelete: 'cascade' }),
  expires: timestamp('expires', { mode: 'date' }).notNull(),
});

Still Not Working?

useSession() returns undefined instead of nullSessionProvider is missing. In App Router, you need a client component wrapper. Create components/Providers.tsx with 'use client' and SessionProvider, then use it in your root layout.

Session works in development but not in production — check AUTH_SECRET (v5) or NEXTAUTH_SECRET (v4) is set in your production environment. Without a secret, session tokens can’t be signed or verified. Also check AUTH_URL matches your production domain exactly.

Credentials provider doesn’t persist sessions — the Credentials provider doesn’t work with database sessions by default. Use session: { strategy: 'jwt' } with Credentials. If you need database sessions with Credentials, you must manually create the session in the authorize callback.

OAuth works but user data isn’t in the database — you need an adapter. Without an adapter, Auth.js uses JWT-only sessions and doesn’t store users in a database. Install and configure @auth/drizzle-adapter, @auth/prisma-adapter, or another supported adapter.

For related auth issues, see Fix: Lucia Auth Not Working and Fix: Next.js App Router 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