How to Set Up NextAuth.js Securely

Share
How-To Guide

How to Set Up NextAuth.js Securely

Production-ready authentication for Next.js App Router

TL;DR

TL;DR (25 minutes): Install next-auth with a database adapter (Prisma recommended). Generate a strong NEXTAUTH_SECRET with openssl. Configure OAuth providers with exact redirect URIs. Use middleware.ts to protect routes. Always verify sessions server-side on API routes. Check authorization (not just authentication) before accessing resources.

Prerequisites:

  • Next.js 13+ with App Router
  • Node.js 18 or later
  • Database (PostgreSQL, MySQL, or MongoDB)
  • OAuth provider credentials (Google, GitHub, etc.)

Why This Matters

NextAuth.js (now Auth.js) is the most popular authentication library for Next.js, but misconfiguration leads to serious vulnerabilities. Missing NEXTAUTH_SECRET, improper session handling, and forgetting to protect API routes are common mistakes that expose user data.

Step-by-Step Guide

1

Install NextAuth.js and dependencies

# Core package
npm install next-auth

# Database adapter (choose one)
npm install @next-auth/prisma-adapter @prisma/client prisma
# or
npm install @auth/drizzle-adapter drizzle-orm
2

Generate and configure environment variables

Create a strong secret and configure your environment:

# Generate a secure secret (run in terminal)
openssl rand -base64 32

Add to your .env.local:

# REQUIRED: Authentication secret
# Generate with: openssl rand -base64 32
NEXTAUTH_SECRET=your-generated-secret-here

# REQUIRED in production: Your app URL
NEXTAUTH_URL=https://yourapp.com

# Database
DATABASE_URL="postgresql://user:password@localhost:5432/myapp"

# OAuth Providers
GOOGLE_CLIENT_ID=your-google-client-id
GOOGLE_CLIENT_SECRET=your-google-client-secret
GITHUB_CLIENT_ID=your-github-client-id
GITHUB_CLIENT_SECRET=your-github-client-secret

Critical: NEXTAUTH_SECRET must be set in production. Without it, sessions are signed with a default key and are not secure. Never commit secrets to git.

3

Set up the database schema

Add NextAuth tables to your Prisma schema (prisma/schema.prisma):

datasource db {
  provider = "postgresql"
  url      = env("DATABASE_URL")
}

generator client {
  provider = "prisma-client-js"
}

model Account {
  id                String  @id @default(cuid())
  userId            String
  type              String
  provider          String
  providerAccountId String
  refresh_token     String? @db.Text
  access_token      String? @db.Text
  expires_at        Int?
  token_type        String?
  scope             String?
  id_token          String? @db.Text
  session_state     String?

  user User @relation(fields: [userId], references: [id], onDelete: Cascade)

  @@unique([provider, providerAccountId])
}

model Session {
  id           String   @id @default(cuid())
  sessionToken String   @unique
  userId       String
  expires      DateTime
  user         User     @relation(fields: [userId], references: [id], onDelete: Cascade)
}

model User {
  id            String    @id @default(cuid())
  name          String?
  email         String?   @unique
  emailVerified DateTime?
  image         String?
  role          String    @default("user")
  accounts      Account[]
  sessions      Session[]
}

model VerificationToken {
  identifier String
  token      String   @unique
  expires    DateTime

  @@unique([identifier, token])
}

Run the migration:

npx prisma migrate dev --name add-auth-tables
npx prisma generate
4

Create the NextAuth configuration

Create lib/auth.ts for your auth configuration:

import { NextAuthOptions } from 'next-auth';
import { PrismaAdapter } from '@next-auth/prisma-adapter';
import GoogleProvider from 'next-auth/providers/google';
import GitHubProvider from 'next-auth/providers/github';
import { prisma } from '@/lib/prisma';

export const authOptions: NextAuthOptions = {
  adapter: PrismaAdapter(prisma),

  providers: [
    GoogleProvider({
      clientId: process.env.GOOGLE_CLIENT_ID!,
      clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
      // Request offline access for refresh tokens
      authorization: {
        params: {
          prompt: 'consent',
          access_type: 'offline',
          response_type: 'code'
        }
      }
    }),
    GitHubProvider({
      clientId: process.env.GITHUB_CLIENT_ID!,
      clientSecret: process.env.GITHUB_CLIENT_SECRET!,
    }),
  ],

  session: {
    strategy: 'database', // Use database sessions (more secure)
    maxAge: 30 * 24 * 60 * 60, // 30 days
    updateAge: 24 * 60 * 60, // Update session every 24 hours
  },

  callbacks: {
    async session({ session, user }) {
      // Add user ID and role to the session
      if (session.user) {
        session.user.id = user.id;
        session.user.role = user.role;
      }
      return session;
    },
    async signIn({ user, account, profile }) {
      // Optional: Add custom sign-in logic
      // Return false to deny access
      return true;
    },
  },

  pages: {
    signIn: '/auth/signin',
    error: '/auth/error',
  },

  // Security options
  cookies: {
    sessionToken: {
      name: process.env.NODE_ENV === 'production'
        ? '__Secure-next-auth.session-token'
        : 'next-auth.session-token',
      options: {
        httpOnly: true,
        sameSite: 'lax',
        path: '/',
        secure: process.env.NODE_ENV === 'production',
      },
    },
  },

  debug: process.env.NODE_ENV === 'development',
};

// Type augmentation for session
declare module 'next-auth' {
  interface Session {
    user: {
      id: string;
      role: string;
      name?: string | null;
      email?: string | null;
      image?: string | null;
    };
  }
  interface User {
    role: string;
  }
}
5

Create the API route handler

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

Protect routes with middleware

Create middleware.ts in your project root:

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

export default withAuth(
  function middleware(req) {
    const token = req.nextauth.token;
    const path = req.nextUrl.pathname;

    // Admin routes require admin role
    if (path.startsWith('/admin') && token?.role !== 'admin') {
      return NextResponse.redirect(new URL('/unauthorized', req.url));
    }

    return NextResponse.next();
  },
  {
    callbacks: {
      authorized: ({ token }) => !!token,
    },
  }
);

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

Protect API routes with session checks

Always verify sessions in API routes:

import { getServerSession } from 'next-auth';
import { authOptions } from '@/lib/auth';
import { NextResponse } from 'next/server';
import { prisma } from '@/lib/prisma';

// GET /api/posts - Get user's posts
export async function GET(request: Request) {
  const session = await getServerSession(authOptions);

  if (!session) {
    return NextResponse.json(
      { error: 'Unauthorized' },
      { status: 401 }
    );
  }

  // IMPORTANT: Scope data to the authenticated user
  const posts = await prisma.post.findMany({
    where: { authorId: session.user.id },
    orderBy: { createdAt: 'desc' },
  });

  return NextResponse.json(posts);
}

// DELETE /api/posts/[id] - Delete a post
export async function DELETE(
  request: Request,
  { params }: { params: { id: string } }
) {
  const session = await getServerSession(authOptions);

  if (!session) {
    return NextResponse.json(
      { error: 'Unauthorized' },
      { status: 401 }
    );
  }

  // Check authorization: Does this user own this resource?
  const post = await prisma.post.findUnique({
    where: { id: params.id },
  });

  if (!post) {
    return NextResponse.json(
      { error: 'Post not found' },
      { status: 404 }
    );
  }

  if (post.authorId !== session.user.id && session.user.role !== 'admin') {
    return NextResponse.json(
      { error: 'Forbidden' },
      { status: 403 }
    );
  }

  await prisma.post.delete({
    where: { id: params.id },
  });

  return NextResponse.json({ success: true });
}
8

Get session in Server Components

import { getServerSession } from 'next-auth';
import { authOptions } from '@/lib/auth';
import { redirect } from 'next/navigation';

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

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

  return (
    <div>
      <h1>Welcome, {session.user.name}</h1>
      <p>Role: {session.user.role}</p>
    </div>
  );
}
9

Set up SessionProvider for client components

Create app/providers.tsx:

'use client';

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

export function Providers({ children }: { children: React.ReactNode }) {
  return <SessionProvider>{children}</SessionProvider>;
}

Wrap your app in 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>
  );
}
10

Use session in client components

'use client';

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

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

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

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

  return <button onClick={() => signIn()}>Sign in</button>;
}

NextAuth.js Security Checklist:

  • NEXTAUTH_SECRET set in production - Use openssl rand -base64 32
  • Database adapter configured - JWT-only sessions can't be revoked
  • Exact redirect URIs - No wildcards in OAuth provider settings
  • Session checks on API routes - Don't rely only on middleware
  • Authorization checks - Verify users can access specific resources
  • Secure cookies enabled - httpOnly, secure, sameSite set properly
  • Environment variables protected - Never commit secrets to git
  • CSRF protection enabled - NextAuth handles this by default

How to Verify It Worked

  1. Test unauthenticated access: Visit /dashboard without signing in - should redirect to sign-in
  2. Test API protection: Call /api/protected without a session - should return 401
  3. Test authorization: Try to access another user's data - should return 403
  4. Inspect cookies: Check that session cookie has httpOnly and secure flags
  5. Test sign out: Sign out and verify old session is invalidated
  6. Check database: Verify sessions are stored in your database

Common Errors & Troubleshooting

Error: "NEXTAUTH_SECRET missing"

Set NEXTAUTH_SECRET in your environment. In production, this is required. Generate with: openssl rand -base64 32

Error: "redirect_uri_mismatch"

The callback URL doesn't match your OAuth provider settings. Add the exact URL including protocol and path: https://yourapp.com/api/auth/callback/google

Session is null in API route

Make sure you're passing authOptions to getServerSession: getServerSession(authOptions), not just getServerSession()

User ID not in session

Add the session callback to include user.id. With database sessions, use session.user.id = user.id

Prisma adapter errors

Run npx prisma generate after schema changes. Ensure DATABASE_URL is set correctly.

Cookies not being set

In production, cookies require HTTPS. Set secure: true only when NODE_ENV is production.

Should I use JWT or database sessions?

Database sessions are more secure because you can revoke them immediately. JWTs are stateless and faster but can't be revoked until they expire. Use database sessions unless you have a specific need for JWTs (like a mobile app or cross-domain auth).

How do I add credentials/password login?

Use CredentialsProvider, but be aware it doesn't work with database sessions by default. You'll need JWT sessions or implement your own session management. OAuth is generally more secure and easier to implement correctly.

How do I protect specific pages based on role?

Add role to your User model and session callback. Then check session.user.role in middleware or your page components. Reject access with a redirect or 403 response.

Why is getServerSession() returning null?

Common causes: 1) Not passing authOptions, 2) Calling from a client component (use useSession instead), 3) Session cookie not being sent (check NEXTAUTH_URL matches your domain).

Related guides:OAuth Setup · Session Management · Add Authentication to Next.js

How-To Guides

How to Set Up NextAuth.js Securely