Skip to content

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

FixDevs · (Updated: )

Part of:  React & Frontend Errors

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.

There is a second layer to this problem: the library has gone through three major API revolutions in five years, and tutorials, AI-generated snippets, and even your own previous code all coexist on the same npm dependency tree. NextAuth.js v3 (2020) used a [...nextauth].js API route in the Pages Router and a single _app.js <Provider> wrapper. NextAuth.js v4 (2021) introduced TypeScript-first typing, the getServerSession() helper, and next-auth/middleware. The 2022 rebrand to Auth.js signaled a framework-agnostic future and the start of the v5 redesign. next-auth@5 (beta throughout 2023, stable in 2024) replaced getServerSession(authOptions) with a unified auth() helper, dropped NEXTAUTH_URL requirements on Vercel, and switched providers to function-call form (GitHub({...}) instead of GitHubProvider({...})). If you copy a v4 snippet into a v5 project, almost every import path and call signature is wrong.

The App Router landing in Next.js 13.4 made things worse before they got better. Server Components, Server Actions, and Route Handlers each have a different session-access pattern, and the official examples lagged the stable Next.js release by months. Many of the “NextAuth not working” issues you see on GitHub Issues from 2023 are not bugs — they are someone running v4 code inside an App Router project and getting bitten by the missing authOptions argument, the cookies API change, or the fact that useSession() only works in Client Components.

The third layer is the OAuth provider side. Google, GitHub, Apple, and Microsoft each have their own redirect URI validation rules, secret rotation schedules, and dev/staging/prod separation requirements. The most common production failure — OAuthCallbackError — almost never comes from NextAuth itself. It comes from a redirect URI mismatch between what NextAuth generated (based on NEXTAUTH_URL or the auto-detected host) and what is whitelisted in the provider dashboard. Add a single trailing slash, forget to include https://, or use a preview URL that wasn’t registered, and the entire flow breaks at the very last redirect.

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;
  }
}

Version History: NextAuth.js to Auth.js

The package has been renamed, restructured, and rewritten enough times that “which version are you on?” is the first question to ask whenever a session-related error appears.

NextAuth.js v3 (2020) — Pages Router era. Configuration lived in pages/api/auth/[...nextauth].js with a default-export array of providers and a single _app.js wrapper using <Provider session={...}>. There was no getServerSession — server-side access went through getSession({ req }) in getServerSideProps. If you find a tutorial that imports from next-auth/client, you are reading v3 documentation and almost nothing you copy will compile against modern versions.

NextAuth.js v4 (Nov 2021) — TypeScript-first redesign. This is still the most widely deployed version in production today. It introduced NextAuthOptions, switched the React client export to next-auth/react, replaced <Provider> with <SessionProvider>, and added getServerSession(authOptions). The provider imports moved from next-auth/providers (default exports) to named providers in subpaths: next-auth/providers/github. Most “session is null in server components” Stack Overflow questions are v4 questions.

Auth.js rebrand (2022) — framework-agnostic future. The project rebranded to Auth.js to signal SvelteKit, SolidStart, and Express adapters were coming. The npm package stayed next-auth for backward compatibility, but the docs site moved to authjs.dev. This is when the v5 design started.

next-auth@5 beta (2023) — App Router-native API. The big break. Configuration moved from a route file to a top-level auth.ts that exports { auth, handlers, signIn, signOut }. The new auth() helper replaces getServerSession() in Server Components, Route Handlers, Server Actions, and middleware — same function, four contexts. Providers became factory calls: GitHub({ clientId, clientSecret }). Middleware switched from withAuth to the same auth() export. Edge runtime support was added for cookie-only sessions.

Auth.js v5 stable (early 2024). The v5 beta cycle ran for most of 2023 because each App Router minor release surfaced new edge cases (Server Actions in 13.4, cookies() becoming async in 15.0, params becoming async in 15.0). The stable v5 release locked the API for Next.js 14+ and added first-class TypeScript inference for session shape.

v5.x post-stable (late 2024 - 2025). Iterative additions: WebAuthn provider, account linking strategies, the useSession() hook gaining a required: true option that triggers a redirect when unauthenticated. Adapters were split into separate packages (@auth/prisma-adapter, @auth/drizzle-adapter) so the core stays lean.

Migration cheat-sheet. In v4: import { getServerSession } from 'next-auth'; const session = await getServerSession(authOptions);. In v5: import { auth } from '@/auth'; const session = await auth();. In v4: import { withAuth } from 'next-auth/middleware';. In v5: import { auth } from '@/auth'; export default auth;. In v4: NEXTAUTH_URL and NEXTAUTH_SECRET env vars. In v5: AUTH_URL and AUTH_SECRET (the old names still work as aliases). If your codebase still uses NEXTAUTH_SECRET, you are on v4 or running v5 in compatibility mode.

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.

cookies() must be awaited after upgrading to Next.js 15 — in Next.js 15, the cookies(), headers(), and params APIs became asynchronous. NextAuth v4 still calls these synchronously in some code paths. Either upgrade to Auth.js v5 (which handles the async APIs natively) or pin Next.js to 14.x until you migrate. Trying to patch around it inside v4 leads to flaky session reads on the server.

Preview deployments (Vercel, Netlify) fail OAuth callback — each preview gets a unique URL like myapp-pr-42-myteam.vercel.app. That URL is not in your OAuth provider’s whitelist, so OAuthCallbackError fires. Solutions: add *.vercel.app to the provider (Google and GitHub don’t allow wildcards, but some others do), use a separate dev OAuth app per preview environment, or proxy auth through a stable subdomain like auth.yourdomain.com.

CredentialsSignin error with no detail — by default, Auth.js v5 hides the underlying reason credentials sign-in failed (to prevent user enumeration). To see the real error during development, log it inside the authorize() callback before returning null, or set debug: true in your auth config. Never expose the detailed reason to the client in production.

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