Authentication in Next.js with NextAuth.js (Auth.js v5)
Step 13 of 31 — Next.js Tutorial Series | Source code for this step
Commands in This Step
| Command | Purpose |
|---|---|
pnpm add next-auth@beta | Install NextAuth.js |
openssl rand -base64 32 | Generate auth secret |
npx auth secret | Generate auth secret (alternative) |
What You Will Build
By the end of this step, your app will have a working authentication backend. It will not have login forms yet (that is Step 14) — but the entire infrastructure will be in place:
- NextAuth.js installed and configured
- A Credentials provider that validates email + password
- Password hashing with bcrypt
- A session system that tracks who is logged in
- An API route at
/api/auth/[...nextauth]that handles sign-in, sign-out, and session management
Goal: Set up the auth layer so that Step 14 can immediately build forms on top of it.
Table of Contents
- What is NextAuth.js?
- Install NextAuth.js
- Generate the Auth Secret
- Core Concept: Providers
- Core Concept: How the Credentials Provider Works
- Create the Auth Configuration
- Create the API Route
- Create the Session Provider
- Wrap the App in the Session Provider
- Understanding the Code
- Create an API Route to Register Users
- Verify the Setup
- Summary & Key Takeaways
What is NextAuth.js?
NextAuth.js (now also called Auth.js) is the most popular authentication library for Next.js. It handles:
- Sign-in / sign-out — creating and destroying sessions
- Session management — tracking who is logged in across requests
- Providers — different ways to authenticate (email/password, Google, GitHub, etc.)
- Security — CSRF protection, JWT signing, secure cookies
You do not need to build any of this from scratch. NextAuth handles the low-level details — you configure it and build your UI on top.
We are using NextAuth.js v5 (also known as Auth.js), which is designed for the Next.js App Router.
Install NextAuth.js
pnpm add next-auth@beta
We use
next-auth@betabecause v5 is the version designed for the App Router. The stable v4 is for the Pages Router and does not work well with Server Components.
Generate the Auth Secret
NextAuth uses a secret key to sign session tokens (JWTs) and cookies. You need to generate a random string and add it to your environment variables.
Option 1 — Using openssl (recommended):
openssl rand -base64 32
This prints a random string to the terminal. Copy it and add it to your .env file:
AUTH_SECRET="paste-the-generated-string-here"
Option 2 — Using npx auth secret:
npx auth secret
When you run this, npm will ask to install the auth package:
Need to install the following packages:
[email protected]
Ok to proceed? (y) y
Press y to continue. The output will look like:
Add the following to your .env file:
# Auth Secret
BETTER_AUTH_SECRET=49d7eec79888d3a...
Important: The command outputs
BETTER_AUTH_SECRETbecause theauthnpm package is now owned by a different library called "Better Auth". Ignore the variable name. NextAuth.js expects the variable to be calledAUTH_SECRET, notBETTER_AUTH_SECRET. Copy only the value and add it to your.envfile asAUTH_SECRET:AUTH_SECRET="49d7eec79888d3a..."
This is why Option 1 (openssl) is simpler — it gives you the raw string without any naming confusion.
Never commit this value to Git. Make sure .env or .env.local is in your .gitignore.
Core Concept: Providers
A provider tells NextAuth how to authenticate users. There are three types:
| Provider type | Example | How it works |
|---|---|---|
| OAuth | Google, GitHub | User clicks "Sign in with Google" and is redirected to Google |
| Magic link | User enters their email, receives a login link | |
| Credentials | Email + password | User enters email and password, validated against your database |
We will use the Credentials provider because our User model already has email and password fields. This is the most common setup for apps that manage their own user accounts.
Core Concept: How the Credentials Provider Works
Here is the flow when a user signs in:
1. User submits email + password
↓
2. NextAuth calls your `authorize` function
↓
3. Your function queries the database for the user
↓
4. Your function compares the password hash (bcrypt)
↓
5. If valid → return the user object → NextAuth creates a session
If invalid → return null → NextAuth rejects the login
The authorize function is the only part you write. Everything else (session creation, cookie management, CSRF protection) is handled by NextAuth.
Create the Auth Configuration
Create a new file auth.ts in the project root:
import NextAuth from 'next-auth'
import Credentials from 'next-auth/providers/credentials'
import bcrypt from 'bcryptjs'
import prisma from '@/lib/prisma'
export const { handlers, signIn, signOut, auth } = NextAuth({
providers: [
Credentials({
credentials: {
email: { label: 'Email', type: 'email' },
password: { label: 'Password', type: 'password' },
},
async authorize(credentials) {
const email = credentials?.email as string
const password = credentials?.password as string
if (!email || !password) return null
const user = await prisma.user.findUnique({
where: { email },
})
if (!user) return null
const isValid = await bcrypt.compare(password, user.password)
if (!isValid) return null
return {
id: user.id,
name: user.name,
email: user.email,
}
},
}),
],
pages: {
signIn: '/login',
},
session: {
strategy: 'jwt',
},
})
This file exports four things:
| Export | Purpose |
|---|---|
handlers | The GET and POST handlers for the /api/auth/[...nextauth] route |
signIn | A function to trigger sign-in (used in Server Actions or API routes) |
signOut | A function to trigger sign-out |
auth | A function to get the current session (used in Server Components and middleware) |
Create the API Route
NextAuth needs an API route to handle authentication requests (sign-in, sign-out, session checks). Create the catch-all route:
mkdir -p app/api/auth/\[...nextauth\]
Create app/api/auth/[...nextauth]/route.ts:
import { handlers } from '@/auth'
export const { GET, POST } = handlers
That is the entire file. This two-line file connects NextAuth's handlers to the Next.js API route. All authentication requests (/api/auth/signin, /api/auth/signout, /api/auth/session, etc.) are handled automatically.
What is [...nextauth]?
The [...nextauth] folder name is a catch-all dynamic route. The ... means it matches any number of URL segments:
| URL | Matched by [...nextauth] |
|---|---|
/api/auth/signin | Yes — nextauth = ["signin"] |
/api/auth/signout | Yes — nextauth = ["signout"] |
/api/auth/session | Yes — nextauth = ["session"] |
/api/auth/callback/credentials | Yes — nextauth = ["callback", "credentials"] |
This is different from [id] (single segment) — [...nextauth] captures all segments after /api/auth/.
Create the Session Provider
NextAuth provides a SessionProvider component that makes the current session available to all Client Components via the useSession() hook.
Create app/providers.tsx:
'use client'
import { SessionProvider } from 'next-auth/react'
export default function Providers({ children }: { children: React.ReactNode }) {
return <SessionProvider>{children}</SessionProvider>
}
This is a Client Component because SessionProvider uses React context internally (which requires client-side JavaScript).
Wrap the App in the Session Provider
Update app/layout.tsx to wrap the entire app in the Providers component:
import type { Metadata } from 'next'
import { Geist, Geist_Mono } from 'next/font/google'
import './globals.css'
import Header from './Header'
import Providers from './providers'
const geistSans = Geist({ variable: '--font-geist-sans', subsets: ['latin'] })
const geistMono = Geist_Mono({
variable: '--font-geist-mono',
subsets: ['latin'],
})
export const metadata: Metadata = {
title: 'Superblog',
description: 'A full-stack blog built with Next.js, Prisma, and NextAuth.js',
}
export default function RootLayout({
children,
}: Readonly<{ children: React.ReactNode }>) {
return (
<html lang="en">
<body
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
>
<Providers>
<div className="flex flex-col min-h-screen bg-gray-50">
<Header />
<main className="flex-1 w-full max-w-4xl px-6 py-12 mx-auto">
{children}
</main>
<footer className="py-6 text-sm text-center text-gray-400">
Built with Next.js 16, React 19, Prisma & NextAuth.js
</footer>
</div>
</Providers>
</body>
</html>
)
}
The only change: everything inside <body> is now wrapped in <Providers>. This makes useSession() available in any Client Component in the app.
Why a separate Providers file?
layout.tsx is a Server Component (no 'use client'). SessionProvider requires client-side JavaScript. Instead of making the entire layout a Client Component (which would lose all Server Component benefits), we extract the provider into its own Client Component file and use it as a wrapper.
This is a common Next.js pattern: Server Component layout wraps a Client Component provider that wraps server-rendered children.
Understanding the Code
The authorize function
async authorize(credentials) {
const email = credentials?.email as string
const password = credentials?.password as string
if (!email || !password) return null
const user = await prisma.user.findUnique({
where: { email },
})
if (!user) return null
const isValid = await bcrypt.compare(password, user.password)
if (!isValid) return null
return {
id: user.id,
name: user.name,
email: user.email,
}
}
This function is the core of authentication. It runs on the server when a user tries to sign in:
- Extract credentials —
emailandpasswordfrom the form submission. - Validate input — if either is missing, return
null(reject). - Find the user — query the database by email. If not found, return
null. - Verify password —
bcrypt.compare()checks the plain-text password against the stored hash. It returnstrueorfalse. - Return user or null — if everything is valid, return the user object (which becomes the session). If anything fails, return
nullto reject the login.
Important: Never return the password field in the user object. Only return safe fields like id, name, and email.
bcrypt.compare()
const isValid = await bcrypt.compare(password, user.password)
bcrypt.compare() takes two arguments:
- The plain-text password the user just typed
- The hashed password stored in the database
It returns true if they match, false if they don't. You never decrypt the hash — bcrypt hashes the input and compares the results. This is a one-way operation.
The pages option
pages: {
signIn: '/login',
}
By default, NextAuth provides its own built-in sign-in page at /api/auth/signin. The pages option overrides this to use our custom /login page instead. When NextAuth needs to redirect an unauthenticated user to sign in, it will send them to /login.
The session option
session: {
strategy: 'jwt',
}
There are two ways NextAuth manages sessions:
| Strategy | How it works | Storage |
|---|---|---|
'jwt' | Session data is stored in an encrypted cookie (JSON Web Token) | No database needed for sessions |
'database' | Session data is stored in a database table | Requires additional Prisma models |
We use 'jwt' because it is simpler — no extra database tables needed. The session lives in a signed cookie that NextAuth reads on every request.
Create an API Route to Register Users
NextAuth handles sign-in, but it does not handle registration (creating new user accounts). We need a simple API route for that.
Create app/api/auth/register/route.ts:
import { NextResponse } from 'next/server'
import bcrypt from 'bcryptjs'
import prisma from '@/lib/prisma'
export async function POST(request: Request) {
try {
const body = await request.json()
if (!body.email || !body.password) {
return NextResponse.json(
{ error: 'Email and password are required' },
{ status: 400 }
)
}
// Check if user already exists
const existingUser = await prisma.user.findUnique({
where: { email: body.email },
})
if (existingUser) {
return NextResponse.json(
{ error: 'A user with this email already exists' },
{ status: 409 }
)
}
// Hash the password
const hashedPassword = await bcrypt.hash(body.password, 10)
// Create the user
const user = await prisma.user.create({
data: {
email: body.email,
name: body.name || null,
password: hashedPassword,
},
})
return NextResponse.json(
{ id: user.id, email: user.email, name: user.name },
{ status: 201 }
)
} catch (error) {
return NextResponse.json(
{ error: 'Failed to create user' },
{ status: 500 }
)
}
}
Key points
bcrypt.hash(password, 10)— hashes the password with a salt round of 10. The10controls how many times the hashing algorithm runs — higher numbers are slower but more secure. 10 is the standard default.- Status
409 Conflict— returned when the email is already taken. - Never return
password— the response only includesid,email, andname.
Test it
curl -X POST http://localhost:3000/api/auth/register \
-H "Content-Type: application/json" \
-d '{"email": "[email protected]", "password": "mypassword123", "name": "Test User"}'
Verify the Setup
At this point, no forms exist yet, but you can verify the setup is correct:
1. Check the session endpoint:
Open http://localhost:3000/api/auth/session in your browser. You should see:
{}
An empty object means no one is signed in — which is correct. If you see an error, something is wrong with the config.
2. Check the sign-in redirect:
Open http://localhost:3000/api/auth/signin. Because we set pages: { signIn: '/login' } in auth.ts, NextAuth redirects you to /login with a callbackUrl query parameter:
http://localhost:3000/login?callbackUrl=http%3A%2F%2Flocalhost%3A3000
This redirect confirms that NextAuth is running correctly and knows to send users to our custom login page. The callbackUrl tells NextAuth where to redirect the user after a successful sign-in.
Note: NextAuth v5 does not have a built-in sign-in page when you set a custom
pages.signIn. In v4, the built-in page was still accessible — in v5, it always redirects to your custom page. Our/loginpage is still a placeholder at this point. We will build the real form in Step 14.
3. Test signing in with curl:
Since the login form is not built yet, you can test the authorize function with curl. NextAuth v5 requires a CSRF token for the credentials callback — you cannot POST directly without one. This is a two-step process:
Step 1 — Get the CSRF token:
curl -c cookies.txt http://localhost:3000/api/auth/csrf
This returns a JSON object like {"csrfToken":"abc123..."} and saves the session cookie to cookies.txt. Copy the csrfToken value.
Step 2 — Sign in with the token:
curl -X POST http://localhost:3000/api/auth/callback/credentials \
-H "Content-Type: application/x-www-form-urlencoded" \
-b cookies.txt \
-c cookies.txt \
-d "[email protected]&password=password123&csrfToken=PASTE_TOKEN_HERE" \
-v
Replace PASTE_TOKEN_HERE with the token from step 1. Look for a set-cookie header in the response containing authjs.session-token. If you see it, the Credentials provider is working — NextAuth validated the password and created a session.
Why two steps? NextAuth v5 uses CSRF (Cross-Site Request Forgery) protection on all mutation endpoints. The CSRF token proves the request came from your app, not from a malicious third-party site. When you build the login form in Step 14, NextAuth handles the CSRF token automatically — you only need this manual process for curl testing.
Clean up the cookie file when you are done:
rm cookies.txt
4. Verify the register endpoint:
curl -X POST http://localhost:3000/api/auth/register \
-H "Content-Type: application/json" \
-d '{"email": "[email protected]", "password": "mypassword123", "name": "Test User"}'
You should see {"id":"...","email":"[email protected]","name":"Test User"} with status 201.
File Structure After This Step
app/
├── api/
│ ├── auth/
│ │ ├── [...nextauth]/
│ │ │ └── route.ts ← NextAuth API route (2 lines)
│ │ └── register/
│ │ └── route.ts ← User registration endpoint
│ └── posts/
│ └── route.ts
├── providers.tsx ← SessionProvider wrapper
├── layout.tsx ← Updated to wrap in Providers
└── ...
auth.ts ← NextAuth configuration (project root)
Summary & Key Takeaways
| Concept | What it means |
|---|---|
| NextAuth.js v5 | Authentication library for the Next.js App Router |
| Credentials provider | Authenticates with email + password against your database |
authorize function | Your custom logic to validate credentials — return user or null |
bcrypt.compare | Compares a plain-text password against a stored hash |
bcrypt.hash | Creates a one-way hash of a password for storage |
auth.ts | The central config file — exports handlers, signIn, signOut, auth |
[...nextauth] | A catch-all route that handles all /api/auth/* requests |
SessionProvider | Makes the session available to Client Components via useSession() |
| JWT strategy | Session data stored in an encrypted cookie — no extra database tables |
What is Next
In Step 14, we will build the login and register forms — real interactive Client Components that call NextAuth's signIn() function and our registration API.