Skip to content

Fix: NextAuth.js Not Working — Session Null, Callback Errors, or OAuth Redirect Issues

FixDevs ·

Quick Answer

How to fix NextAuth.js (Auth.js) issues — session undefined in server components, OAuth callback URL mismatch, JWT vs database sessions, middleware protection, and credentials provider.

The Problem

useSession() returns null or undefined even after signing in:

// app/dashboard/page.tsx
import { useSession } from 'next-auth/react';

export default function Dashboard() {
  const { data: session } = useSession();
  console.log(session);  // null — even though the user is logged in
}

Or a server component can’t access the session:

// app/profile/page.tsx (Server Component)
import { getServerSession } from 'next-auth';

export default async function Profile() {
  const session = await getServerSession();
  // session is null — getServerSession() without options always returns null
}

Or OAuth login redirects to an error page:

https://your-app.com/api/auth/error?error=OAuthCallbackError
https://your-app.com/api/auth/error?error=Configuration

Or the session works in development but not in production.

Why This Happens

NextAuth.js (rebranded as Auth.js for v5) has several configuration requirements that are easy to miss:

  • SessionProvider missinguseSession() requires the SessionProvider context wrapper. Without it, session is always null.
  • getServerSession() needs authOptions — calling getServerSession() without passing your authOptions config always returns null in App Router.
  • NEXTAUTH_URL not set in production — NextAuth uses NEXTAUTH_URL to construct callback URLs. Without it, OAuth providers redirect to the wrong URL.
  • OAuth callback URL mismatch — the redirect URI registered with your OAuth provider (Google, GitHub, etc.) must exactly match what NextAuth generates. Any mismatch causes OAuthCallbackError.
  • NEXTAUTH_SECRET missing — required in production for signing JWT tokens. Without it, sessions can’t be created.

Fix 1: Add SessionProvider to the App

useSession() is a React hook that requires the SessionProvider context:

// app/providers.tsx
'use client';

import { SessionProvider } from 'next-auth/react';

export function Providers({ children }: { children: React.ReactNode }) {
  return <SessionProvider>{children}</SessionProvider>;
}
// app/layout.tsx
import { Providers } from './providers';

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="en">
      <body>
        <Providers>
          {children}
        </Providers>
      </body>
    </html>
  );
}
// Now useSession() works in any Client Component
'use client';
import { useSession, signIn, signOut } from 'next-auth/react';

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

  if (status === 'loading') return <p>Loading...</p>;
  if (status === 'unauthenticated') {
    return <button onClick={() => signIn()}>Sign in</button>;
  }

  return (
    <div>
      <p>Signed in as {session.user?.email}</p>
      <button onClick={() => signOut()}>Sign out</button>
    </div>
  );
}

Fix 2: Get Session in Server Components

getServerSession() requires your authOptions to work correctly in the App Router:

// lib/auth.ts — export authOptions to reuse everywhere
import NextAuth, { NextAuthOptions } from 'next-auth';
import GithubProvider from 'next-auth/providers/github';
import GoogleProvider from 'next-auth/providers/google';

export const authOptions: NextAuthOptions = {
  providers: [
    GithubProvider({
      clientId: process.env.GITHUB_ID!,
      clientSecret: process.env.GITHUB_SECRET!,
    }),
    GoogleProvider({
      clientId: process.env.GOOGLE_CLIENT_ID!,
      clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
    }),
  ],
  secret: process.env.NEXTAUTH_SECRET,
};

export default NextAuth(authOptions);
// app/api/auth/[...nextauth]/route.ts
import NextAuth from 'next-auth';
import { authOptions } from '@/lib/auth';

const handler = NextAuth(authOptions);
export { handler as GET, handler as POST };
// app/profile/page.tsx (Server Component)
import { getServerSession } from 'next-auth';
import { authOptions } from '@/lib/auth';
import { redirect } from 'next/navigation';

export default async function Profile() {
  // WRONG — no authOptions, always returns null
  // const session = await getServerSession();

  // CORRECT — pass authOptions
  const session = await getServerSession(authOptions);

  if (!session) {
    redirect('/api/auth/signin');
  }

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

Auth.js v5 (next-auth@5) — new API:

// auth.ts (Auth.js v5)
import NextAuth from 'next-auth';
import GitHub from 'next-auth/providers/github';

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

// app/profile/page.tsx
import { auth } from '@/auth';
import { redirect } from 'next/navigation';

export default async function Profile() {
  const session = await auth();  // No authOptions needed in v5
  if (!session) redirect('/api/auth/signin');
  return <h1>Welcome, {session.user?.name}</h1>;
}

Fix 3: Fix OAuth Callback URL Mismatch

The redirect URI in your OAuth provider settings must match what NextAuth generates:

NextAuth callback URL format:
{NEXTAUTH_URL}/api/auth/callback/{provider}

Examples:
https://your-app.com/api/auth/callback/github
https://your-app.com/api/auth/callback/google
https://your-app.com/api/auth/callback/twitter

Set these in your OAuth provider’s dashboard:

GitHub → Settings → Developer Settings → OAuth Apps → your app
  Authorization callback URL: https://your-app.com/api/auth/callback/github

Google → Google Cloud Console → APIs & Services → Credentials → OAuth 2.0 Client
  Authorized redirect URIs: https://your-app.com/api/auth/callback/google

For local development — add both:
  http://localhost:3000/api/auth/callback/github
  https://your-app.com/api/auth/callback/github

Set environment variables:

# .env.local (development)
NEXTAUTH_URL=http://localhost:3000
NEXTAUTH_SECRET=your-random-secret-here

# Production (.env or hosting platform env vars)
NEXTAUTH_URL=https://your-app.com
NEXTAUTH_SECRET=your-production-secret-here

# Generate a secure secret:
openssl rand -base64 32

Note: On Vercel, NEXTAUTH_URL is often not needed because Auth.js detects the deployment URL automatically. But it’s required on most other platforms.

Fix 4: Protect Routes with Middleware

Use NextAuth middleware to protect routes without checking the session on every page:

// middleware.ts (at the root, not in app/)
export { default } from 'next-auth/middleware';

// Apply middleware to specific paths
export const config = {
  matcher: [
    '/dashboard/:path*',
    '/profile/:path*',
    '/api/protected/:path*',
    // Exclude public paths and NextAuth's own routes
  ],
};

Custom middleware with redirect:

// middleware.ts
import { withAuth } from 'next-auth/middleware';
import { NextResponse } from 'next/server';

export default withAuth(
  function middleware(req) {
    // Custom logic — e.g., role-based access
    const token = req.nextauth.token;

    if (req.nextUrl.pathname.startsWith('/admin') && token?.role !== 'admin') {
      return NextResponse.redirect(new URL('/unauthorized', req.url));
    }
  },
  {
    callbacks: {
      authorized: ({ token }) => !!token,  // Require a token to access
    },
  }
);

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

Auth.js v5 middleware:

// middleware.ts
import { auth } from '@/auth';
import { NextResponse } from 'next/server';

export default auth((req) => {
  if (!req.auth) {
    return NextResponse.redirect(new URL('/login', req.url));
  }
});

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

Fix 5: Use Credentials Provider Correctly

The Credentials provider lets you authenticate with username/password:

// lib/auth.ts
import CredentialsProvider from 'next-auth/providers/credentials';
import bcrypt from 'bcryptjs';
import { db } from '@/lib/db';

export const authOptions: NextAuthOptions = {
  providers: [
    CredentialsProvider({
      name: 'credentials',
      credentials: {
        email: { label: 'Email', type: 'email' },
        password: { label: 'Password', type: 'password' },
      },
      async authorize(credentials) {
        if (!credentials?.email || !credentials?.password) {
          return null;  // Return null to indicate failure
        }

        const user = await db.user.findUnique({
          where: { email: credentials.email },
        });

        if (!user || !user.hashedPassword) {
          return null;
        }

        const isValid = await bcrypt.compare(
          credentials.password,
          user.hashedPassword
        );

        if (!isValid) {
          return null;
        }

        // Return user object — will be stored in JWT/session
        return {
          id: user.id,
          email: user.email,
          name: user.name,
        };
      },
    }),
  ],
  // Credentials provider requires JWT strategy
  session: { strategy: 'jwt' },
  pages: {
    signIn: '/login',  // Custom sign-in page
  },
};

Warning: The Credentials provider doesn’t support account linking, email verification, or password reset out of the box. For full auth flows, consider using the Email or OAuth providers alongside it.

Fix 6: Extend the Session with Custom Data

Add custom fields (like user role) to the session:

// lib/auth.ts
import { NextAuthOptions, Session } from 'next-auth';
import { JWT } from 'next-auth/jwt';

export const authOptions: NextAuthOptions = {
  providers: [...],
  callbacks: {
    async jwt({ token, user }) {
      // 'user' is only available on initial sign-in
      if (user) {
        token.id = user.id;
        token.role = user.role;  // Add role from database
      }
      return token;
    },
    async session({ session, token }) {
      // Transfer JWT data to the session object
      if (session.user) {
        session.user.id = token.id as string;
        session.user.role = token.role as string;
      }
      return session;
    },
  },
};
// types/next-auth.d.ts — extend type definitions
import 'next-auth';
import 'next-auth/jwt';

declare module 'next-auth' {
  interface Session {
    user: {
      id: string;
      role: string;
      email: string;
      name?: string;
    };
  }

  interface User {
    role: string;
  }
}

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

Still Not Working?

Session works in dev but not production — the two most common causes: NEXTAUTH_URL is not set for the production domain, or NEXTAUTH_SECRET is missing. Both are required in production.

useSession() returns { status: 'loading' } forever — the SessionProvider is present but the API route at /api/auth/[...nextauth] is missing or returning an error. Open DevTools Network tab and check the response from /api/auth/session.

Database session vs JWT session — by default, NextAuth uses database sessions (requires a database adapter). If you haven’t set up an adapter, switch to JWT strategy: session: { strategy: 'jwt' }. JWT sessions work without a database.

Cookies not working across subdomains — if your app is on app.example.com but the auth is on auth.example.com, cookies won’t share. Set cookies.sessionToken.options.domain = '.example.com' in authOptions to make the cookie available to all subdomains.

For related Next.js issues, see Fix: Next.js Middleware Not Running and Fix: Next.js Environment Variables 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