Authentication in Next.js with NextAuth.js (Auth.js v5)

Step 13 of 31Next.js Tutorial Series | Source code for this step

Live Demo →


Commands in This Step

CommandPurpose
pnpm add next-auth@betaInstall NextAuth.js
openssl rand -base64 32Generate auth secret
npx auth secretGenerate auth secret (alternative)

What You Will Build

↑ Index

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

  1. What is NextAuth.js?
  2. Install NextAuth.js
  3. Generate the Auth Secret
  4. Core Concept: Providers
  5. Core Concept: How the Credentials Provider Works
  6. Create the Auth Configuration
  7. Create the API Route
  8. Create the Session Provider
  9. Wrap the App in the Session Provider
  10. Understanding the Code
  11. Create an API Route to Register Users
  12. Verify the Setup
  13. Summary & Key Takeaways

What is NextAuth.js?

↑ Index

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

↑ Index

pnpm add next-auth@beta

We use next-auth@beta because 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

↑ Index

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_SECRET because the auth npm package is now owned by a different library called "Better Auth". Ignore the variable name. NextAuth.js expects the variable to be called AUTH_SECRET, not BETTER_AUTH_SECRET. Copy only the value and add it to your .env file as AUTH_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

↑ Index

A provider tells NextAuth how to authenticate users. There are three types:

Provider typeExampleHow it works
OAuthGoogle, GitHubUser clicks "Sign in with Google" and is redirected to Google
EmailMagic linkUser enters their email, receives a login link
CredentialsEmail + passwordUser 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

↑ Index

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

↑ Index

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:

ExportPurpose
handlersThe GET and POST handlers for the /api/auth/[...nextauth] route
signInA function to trigger sign-in (used in Server Actions or API routes)
signOutA function to trigger sign-out
authA function to get the current session (used in Server Components and middleware)

Create the API Route

↑ Index

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:

URLMatched by [...nextauth]
/api/auth/signinYes — nextauth = ["signin"]
/api/auth/signoutYes — nextauth = ["signout"]
/api/auth/sessionYes — nextauth = ["session"]
/api/auth/callback/credentialsYes — nextauth = ["callback", "credentials"]

This is different from [id] (single segment) — [...nextauth] captures all segments after /api/auth/.


Create the Session Provider

↑ Index

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

↑ Index

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

↑ Index

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:

  1. Extract credentialsemail and password from the form submission.
  2. Validate input — if either is missing, return null (reject).
  3. Find the user — query the database by email. If not found, return null.
  4. Verify passwordbcrypt.compare() checks the plain-text password against the stored hash. It returns true or false.
  5. Return user or null — if everything is valid, return the user object (which becomes the session). If anything fails, return null to 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:

  1. The plain-text password the user just typed
  2. 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:

StrategyHow it worksStorage
'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 tableRequires 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

↑ Index

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. The 10 controls 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 includes id, email, and name.

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

↑ Index

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 /login page 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

↑ Index

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

↑ Index

ConceptWhat it means
NextAuth.js v5Authentication library for the Next.js App Router
Credentials providerAuthenticates with email + password against your database
authorize functionYour custom logic to validate credentials — return user or null
bcrypt.compareCompares a plain-text password against a stored hash
bcrypt.hashCreates a one-way hash of a password for storage
auth.tsThe central config file — exports handlers, signIn, signOut, auth
[...nextauth]A catch-all route that handles all /api/auth/* requests
SessionProviderMakes the session available to Client Components via useSession()
JWT strategySession 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.