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
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
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.
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
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;
}
}
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 };
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*',
],
};
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 });
}
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>
);
}
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>
);
}
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
- Test unauthenticated access: Visit /dashboard without signing in - should redirect to sign-in
- Test API protection: Call /api/protected without a session - should return 401
- Test authorization: Try to access another user's data - should return 403
- Inspect cookies: Check that session cookie has httpOnly and secure flags
- Test sign out: Sign out and verify old session is invalidated
- 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