Skip to content

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

FixDevs · (Updated: )

Part of:  React & Frontend Errors

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, and the surface area now spans App Router, Pages Router, Server Components, Server Actions, middleware, and the Edge runtime. Each of these contexts has subtly different rules about what is available, where cookies are read, and how state is propagated.

  • 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.

A second class of failure is runtime-specific. The Edge runtime used by Next.js middleware does not include the Node.js crypto module, the bcrypt package, or any database driver that depends on TCP sockets. That means the same auth() config that works in a Node.js Server Component can crash in middleware if it touches a Prisma client or a bcrypt.compare() call. The official split is to keep the heavy authorize logic out of the Edge config and only import the lightweight auth.config.ts from middleware.

A third class is host-specific. Vercel preview deployments get a fresh URL like myapp-git-feature-team.vercel.app, Netlify Deploy Previews use deploy-preview-N--myapp.netlify.app, and Cloudflare Pages previews are <hash>.myapp.pages.dev. OAuth providers reject any redirect that doesn’t exactly match the registered callback URL, so a preview build will fail OAuth unless you either register every preview URL with the provider or use the trustHost: true option together with a wildcard-friendly provider configuration.

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(),
});

Fix 7: Platform Differences — App Router, Pages Router, Edge, SvelteKit, Astro

The single biggest source of mystery Auth.js bugs is running config that targets one platform inside another. Each context has different rules.

Next.js App Router (Node runtime). This is the default target for auth() in auth.ts. You can use Prisma, Drizzle, bcrypt, and the Credentials provider here without restriction. Cookies are written by the route handler at /api/auth/[...nextauth].

Next.js Pages Router. Still supported in v5 via getServerSession for v4-style code, but v5’s auth() works too. The cookie store is read from req.headers.cookie instead of the App Router cookies API, which matters when calling auth from getServerSideProps.

Next.js Edge middleware. Middleware runs on the Edge runtime, which has no Node.js crypto and no TCP sockets. Importing the full auth.ts will crash because the Credentials provider’s authorize callback typically imports bcrypt or a database client. Split your config:

// auth.config.ts — Edge-safe, no Node APIs
import GitHub from 'next-auth/providers/github';
import type { NextAuthConfig } from 'next-auth';

export default {
  providers: [GitHub],
  trustHost: true,
} satisfies NextAuthConfig;

// auth.ts — full Node config
import NextAuth from 'next-auth';
import authConfig from './auth.config';
import Credentials from 'next-auth/providers/credentials';
import bcrypt from 'bcryptjs';

export const { handlers, auth } = NextAuth({
  ...authConfig,
  providers: [
    ...authConfig.providers,
    Credentials({ authorize: async (c) => { /* bcrypt + db */ } }),
  ],
  session: { strategy: 'jwt' },
});

// middleware.ts — Edge: imports auth.config only
import NextAuth from 'next-auth';
import authConfig from './auth.config';
export const { auth: middleware } = NextAuth(authConfig);

SvelteKit. Auth.js ships @auth/sveltekit. The export shape is different — you get a handle hook for hooks.server.ts instead of route handlers. Cookies are read through SvelteKit’s event.cookies API, and the redirect-after-signin uses SvelteKit’s redirect() from @sveltejs/kit rather than next/navigation.

Astro. Auth.js supports Astro via @auth/astro. Astro endpoints must be configured for SSR (output: 'server' or 'hybrid' with prerender = false on the auth route). On Cloudflare Pages or Vercel adapters the Edge restrictions apply just as in Next.js middleware.

Cookie SameSite per host. OAuth redirects bounce between the provider host and your app host, so the session cookie must not be set to SameSite=Strict. Auth.js defaults to Lax for the session cookie and None; Secure for the CSRF and state cookies. If a reverse proxy rewrites Set-Cookie or strips the Secure flag, the OAuth flow will fail on the callback with a CSRF or state mismatch even though nothing in your code is wrong. Always verify the raw Set-Cookie header from the auth route in production using curl -I.

Vercel preview deployments. Each preview URL is unique. The cleanest fix is to set trustHost: true and configure your OAuth app with a wildcard policy where supported (Auth0, Clerk, Cognito all allow this; GitHub does not). For GitHub, register a single OAuth app per environment instead.

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.

Middleware crashes with crypto is not defined or Cannot find module 'fs' — your auth.ts is being imported into middleware and it pulls in Node-only code. Split into auth.config.ts (Edge-safe) and auth.ts (Node) as shown in Fix 7. Middleware imports only auth.config.ts.

Sign-in works locally but redirects to the wrong URL on Vercel previewAUTH_URL is hard-coded. Either remove it (Auth.js will infer from x-forwarded-host when trustHost: true) or set it dynamically per environment using Vercel’s VERCEL_URL system variable.

Session cookie is set but auth() still returns null — the cookie name differs between secure and non-secure contexts. In production, Auth.js uses __Secure-authjs.session-token; in dev it uses authjs.session-token. A reverse proxy that strips the Secure flag or downgrades to HTTP will cause the names to disagree.

For related auth issues, see Fix: Lucia Auth Not Working, Fix: jose JWT Not Working, Fix: Next.js Server Action Not Working, and Fix: Hono 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