Authenticate Your Next.js 15 App with Auth.js v5: Email, OAuth, and Role-Based Access

Authentication is the gatekeeper of every serious web application. Whether you're building a SaaS dashboard, an e-commerce platform, or an internal tool, you need a secure, maintainable auth system. Auth.js v5 (formerly NextAuth.js) is the de facto standard for authentication in the Next.js ecosystem — and with its v5 rewrite, it's faster, simpler, and more powerful than ever.
In this tutorial, you'll build a complete authentication system from scratch, covering:
- Google OAuth login
- Email/password credentials
- Session management with JWT
- Protected routes via middleware
- Role-based access control (RBAC)
Why Auth.js v5? The v5 release brings first-class App Router support, Edge Runtime compatibility, a simplified API surface, and built-in CSRF protection. If you're starting a new Next.js project in 2026, Auth.js v5 is the recommended choice.
Prerequisites
Before you begin, make sure you have:
- Node.js 18+ installed
- Next.js 15 project (App Router)
- A Google Cloud Console account (for OAuth)
- Basic familiarity with TypeScript and React Server Components
What You'll Build
A Next.js 15 application with:
- A sign-in page supporting Google OAuth and email/password
- A protected dashboard only accessible to authenticated users
- An admin panel restricted to users with the
adminrole - Middleware that automatically redirects unauthenticated users
Step 1: Set Up Your Next.js Project
If you don't already have a project, create one:
npx create-next-app@latest my-auth-app --typescript --tailwind --app --src-dir
cd my-auth-appInstall the required dependencies:
npm install next-auth@beta @auth/prisma-adapter @prisma/client prisma bcryptjs zod
npm install -D @types/bcryptjsHere's what each package does:
| Package | Purpose |
|---|---|
next-auth@beta | Auth.js v5 for Next.js |
@auth/prisma-adapter | Stores users/sessions in your database |
prisma | Database ORM |
bcryptjs | Password hashing |
zod | Schema validation for credentials |
Step 2: Configure Prisma
Initialize Prisma with your preferred database (we'll use PostgreSQL):
npx prisma init --datasource-provider postgresqlUpdate your prisma/schema.prisma with the Auth.js models:
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
model User {
id String @id @default(cuid())
name String?
email String? @unique
emailVerified DateTime?
image String?
passwordHash String?
role String @default("user")
accounts Account[]
sessions Session[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
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 VerificationToken {
identifier String
token String @unique
expires DateTime
@@unique([identifier, token])
}Notice the passwordHash and role fields on User — these are custom additions for credentials login and RBAC.
Run the migration:
npx prisma migrate dev --name init
npx prisma generateCreate a Prisma client singleton at src/lib/prisma.ts:
import { PrismaClient } from "@prisma/client"
const globalForPrisma = globalThis as unknown as {
prisma: PrismaClient | undefined
}
export const prisma = globalForPrisma.prisma ?? new PrismaClient()
if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = prismaStep 3: Configure Auth.js
Create the main Auth.js configuration file at src/auth.ts:
import NextAuth from "next-auth"
import Google from "next-auth/providers/google"
import Credentials from "next-auth/providers/credentials"
import { PrismaAdapter } from "@auth/prisma-adapter"
import { prisma } from "@/lib/prisma"
import bcrypt from "bcryptjs"
import { z } from "zod"
const signInSchema = z.object({
email: z.string().email("Invalid email address"),
password: z.string().min(8, "Password must be at least 8 characters"),
})
export const { handlers, auth, signIn, signOut } = NextAuth({
adapter: PrismaAdapter(prisma),
providers: [
Google({
clientId: process.env.AUTH_GOOGLE_ID!,
clientSecret: process.env.AUTH_GOOGLE_SECRET!,
profile(profile) {
return {
id: profile.sub,
name: profile.name,
email: profile.email,
image: profile.picture,
role: "user",
}
},
}),
Credentials({
credentials: {
email: { label: "Email", type: "email" },
password: { label: "Password", type: "password" },
},
async authorize(credentials) {
const parsed = signInSchema.safeParse(credentials)
if (!parsed.success) return null
const { email, password } = parsed.data
const user = await prisma.user.findUnique({
where: { email },
})
if (!user || !user.passwordHash) return null
const isValid = await bcrypt.compare(password, user.passwordHash)
if (!isValid) return null
return {
id: user.id,
name: user.name,
email: user.email,
image: user.image,
role: user.role,
}
},
}),
],
session: {
strategy: "jwt",
maxAge: 30 * 24 * 60 * 60, // 30 days
},
pages: {
signIn: "/auth/signin",
error: "/auth/error",
},
callbacks: {
jwt({ token, user }) {
if (user) {
token.id = user.id
token.role = (user as any).role ?? "user"
}
return token
},
session({ session, token }) {
if (session.user) {
session.user.id = token.id as string
;(session.user as any).role = token.role as string
}
return session
},
authorized({ auth, request: { nextUrl } }) {
const isLoggedIn = !!auth?.user
const isOnDashboard = nextUrl.pathname.startsWith("/dashboard")
const isOnAdmin = nextUrl.pathname.startsWith("/admin")
if (isOnDashboard || isOnAdmin) {
if (isLoggedIn) return true
return false // Redirect to login
}
return true
},
},
})Let's break down what's happening:
- Google provider handles OAuth with automatic profile mapping
- Credentials provider validates email/password with Zod and bcrypt
- JWT callbacks persist the user's
rolein the token - Session callback exposes the role to client components
- Authorized callback protects
/dashboardand/adminroutes
Step 4: Set Up the API Route
Create the Auth.js API route at src/app/api/auth/[...nextauth]/route.ts:
import { handlers } from "@/auth"
export const { GET, POST } = handlersThat's it — Auth.js v5 makes this incredibly clean.
Step 5: Add Middleware for Route Protection
Create src/middleware.ts:
export { auth as default } from "@/auth"
export const config = {
matcher: [
"/dashboard/:path*",
"/admin/:path*",
"/api/protected/:path*",
],
}This middleware runs the authorized callback from your Auth.js config on every matching route. Unauthenticated users are automatically redirected to your sign-in page.
Step 6: Extend TypeScript Types
To get proper typing for session.user.role, create src/types/next-auth.d.ts:
import { DefaultSession } from "next-auth"
declare module "next-auth" {
interface Session {
user: {
id: string
role: string
} & DefaultSession["user"]
}
interface User {
role?: string
}
}
declare module "next-auth/jwt" {
interface JWT {
id: string
role: string
}
}Step 7: Build the Sign-In Page
Create a custom sign-in page at src/app/auth/signin/page.tsx:
import { signIn, auth } from "@/auth"
import { redirect } from "next/navigation"
export default async function SignInPage() {
const session = await auth()
if (session) redirect("/dashboard")
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50">
<div className="max-w-md w-full space-y-8 p-8 bg-white rounded-xl shadow-lg">
<div className="text-center">
<h1 className="text-3xl font-bold text-gray-900">Welcome back</h1>
<p className="mt-2 text-gray-600">Sign in to your account</p>
</div>
{/* Google OAuth */}
<form
action={async () => {
"use server"
await signIn("google", { redirectTo: "/dashboard" })
}}
>
<button
type="submit"
className="w-full flex items-center justify-center gap-3 px-4 py-3 border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors"
>
<svg className="w-5 h-5" viewBox="0 0 24 24">
<path
fill="#4285F4"
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92a5.06 5.06 0 0 1-2.2 3.32v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.1z"
/>
<path
fill="#34A853"
d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"
/>
<path
fill="#FBBC05"
d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"
/>
<path
fill="#EA4335"
d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"
/>
</svg>
Continue with Google
</button>
</form>
<div className="relative">
<div className="absolute inset-0 flex items-center">
<div className="w-full border-t border-gray-300" />
</div>
<div className="relative flex justify-center text-sm">
<span className="px-2 bg-white text-gray-500">Or continue with email</span>
</div>
</div>
{/* Email/Password */}
<form
action={async (formData) => {
"use server"
await signIn("credentials", {
email: formData.get("email"),
password: formData.get("password"),
redirectTo: "/dashboard",
})
}}
className="space-y-4"
>
<div>
<label htmlFor="email" className="block text-sm font-medium text-gray-700">
Email
</label>
<input
id="email"
name="email"
type="email"
required
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-lg shadow-sm focus:ring-2 focus:ring-blue-500 focus:border-transparent"
placeholder="you@example.com"
/>
</div>
<div>
<label htmlFor="password" className="block text-sm font-medium text-gray-700">
Password
</label>
<input
id="password"
name="password"
type="password"
required
minLength={8}
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-lg shadow-sm focus:ring-2 focus:ring-blue-500 focus:border-transparent"
placeholder="••••••••"
/>
</div>
<button
type="submit"
className="w-full py-3 px-4 bg-blue-600 text-white font-medium rounded-lg hover:bg-blue-700 transition-colors"
>
Sign in
</button>
</form>
<p className="text-center text-sm text-gray-600">
Don't have an account?{" "}
<a href="/auth/register" className="text-blue-600 hover:underline">
Create one
</a>
</p>
</div>
</div>
)
}Step 8: Create a Registration Endpoint
Create a server action for user registration at src/app/auth/register/actions.ts:
"use server"
import { prisma } from "@/lib/prisma"
import bcrypt from "bcryptjs"
import { z } from "zod"
const registerSchema = z.object({
name: z.string().min(2, "Name must be at least 2 characters"),
email: z.string().email("Invalid email address"),
password: z.string().min(8, "Password must be at least 8 characters"),
})
export async function register(formData: FormData) {
const parsed = registerSchema.safeParse({
name: formData.get("name"),
email: formData.get("email"),
password: formData.get("password"),
})
if (!parsed.success) {
return { error: parsed.error.errors[0].message }
}
const { name, email, password } = parsed.data
const existingUser = await prisma.user.findUnique({
where: { email },
})
if (existingUser) {
return { error: "An account with this email already exists" }
}
const passwordHash = await bcrypt.hash(password, 12)
await prisma.user.create({
data: {
name,
email,
passwordHash,
role: "user",
},
})
return { success: true }
}Then create the registration page at src/app/auth/register/page.tsx:
"use client"
import { useState } from "react"
import { useRouter } from "next/navigation"
import { register } from "./actions"
export default function RegisterPage() {
const router = useRouter()
const [error, setError] = useState<string | null>(null)
const [loading, setLoading] = useState(false)
async function handleSubmit(formData: FormData) {
setLoading(true)
setError(null)
const result = await register(formData)
if (result.error) {
setError(result.error)
setLoading(false)
return
}
router.push("/auth/signin?registered=true")
}
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50">
<div className="max-w-md w-full p-8 bg-white rounded-xl shadow-lg">
<h1 className="text-3xl font-bold text-center text-gray-900">
Create your account
</h1>
{error && (
<div className="mt-4 p-3 bg-red-50 border border-red-200 rounded-lg text-red-700 text-sm">
{error}
</div>
)}
<form action={handleSubmit} className="mt-6 space-y-4">
<div>
<label htmlFor="name" className="block text-sm font-medium text-gray-700">
Full name
</label>
<input
id="name"
name="name"
type="text"
required
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-lg shadow-sm focus:ring-2 focus:ring-blue-500 focus:border-transparent"
/>
</div>
<div>
<label htmlFor="email" className="block text-sm font-medium text-gray-700">
Email
</label>
<input
id="email"
name="email"
type="email"
required
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-lg shadow-sm focus:ring-2 focus:ring-blue-500 focus:border-transparent"
/>
</div>
<div>
<label htmlFor="password" className="block text-sm font-medium text-gray-700">
Password
</label>
<input
id="password"
name="password"
type="password"
required
minLength={8}
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-lg shadow-sm focus:ring-2 focus:ring-blue-500 focus:border-transparent"
/>
</div>
<button
type="submit"
disabled={loading}
className="w-full py-3 px-4 bg-blue-600 text-white font-medium rounded-lg hover:bg-blue-700 disabled:opacity-50 transition-colors"
>
{loading ? "Creating account..." : "Create account"}
</button>
</form>
<p className="mt-4 text-center text-sm text-gray-600">
Already have an account?{" "}
<a href="/auth/signin" className="text-blue-600 hover:underline">
Sign in
</a>
</p>
</div>
</div>
)
}Step 9: Build a Protected Dashboard
Create a simple dashboard at src/app/dashboard/page.tsx:
import { auth, signOut } from "@/auth"
import { redirect } from "next/navigation"
export default async function DashboardPage() {
const session = await auth()
if (!session?.user) redirect("/auth/signin")
return (
<div className="min-h-screen bg-gray-50 p-8">
<div className="max-w-4xl mx-auto">
<div className="flex items-center justify-between mb-8">
<h1 className="text-3xl font-bold text-gray-900">Dashboard</h1>
<form
action={async () => {
"use server"
await signOut({ redirectTo: "/" })
}}
>
<button
type="submit"
className="px-4 py-2 text-sm text-red-600 border border-red-300 rounded-lg hover:bg-red-50 transition-colors"
>
Sign out
</button>
</form>
</div>
<div className="bg-white rounded-xl shadow-sm p-6">
<div className="flex items-center gap-4">
{session.user.image && (
<img
src={session.user.image}
alt=""
className="w-16 h-16 rounded-full"
/>
)}
<div>
<h2 className="text-xl font-semibold">{session.user.name}</h2>
<p className="text-gray-600">{session.user.email}</p>
<span className="inline-block mt-1 px-2 py-0.5 text-xs font-medium bg-blue-100 text-blue-800 rounded-full">
{(session.user as any).role}
</span>
</div>
</div>
</div>
<div className="mt-6 grid grid-cols-1 md:grid-cols-3 gap-4">
{["Projects", "Tasks", "Reports"].map((item) => (
<div
key={item}
className="bg-white rounded-xl shadow-sm p-6 text-center"
>
<h3 className="text-lg font-medium text-gray-900">{item}</h3>
<p className="mt-1 text-3xl font-bold text-blue-600">0</p>
</div>
))}
</div>
</div>
</div>
)
}Step 10: Implement Role-Based Access Control
Create an admin-only page at src/app/admin/page.tsx:
import { auth } from "@/auth"
import { redirect } from "next/navigation"
export default async function AdminPage() {
const session = await auth()
if (!session?.user) redirect("/auth/signin")
if ((session.user as any).role !== "admin") redirect("/dashboard")
return (
<div className="min-h-screen bg-gray-50 p-8">
<div className="max-w-4xl mx-auto">
<div className="flex items-center gap-3 mb-8">
<span className="px-3 py-1 text-sm font-medium bg-red-100 text-red-800 rounded-full">
Admin Only
</span>
<h1 className="text-3xl font-bold text-gray-900">Admin Panel</h1>
</div>
<div className="bg-white rounded-xl shadow-sm p-6">
<h2 className="text-lg font-semibold mb-4">User Management</h2>
<p className="text-gray-600">
This page is only accessible to users with the admin role.
You can add user management functionality here.
</p>
</div>
</div>
</div>
)
}To enforce this at the middleware level as well, update your authorized callback in src/auth.ts:
authorized({ auth, request: { nextUrl } }) {
const isLoggedIn = !!auth?.user
const isOnAdmin = nextUrl.pathname.startsWith("/admin")
if (isOnAdmin) {
if (!isLoggedIn) return false
if ((auth?.user as any)?.role !== "admin") {
return Response.redirect(new URL("/dashboard", nextUrl))
}
return true
}
if (nextUrl.pathname.startsWith("/dashboard")) {
return isLoggedIn
}
return true
},Step 11: Set Up Environment Variables
Create your .env.local file:
# Database
DATABASE_URL="postgresql://user:password@localhost:5432/myauthdb"
# Auth.js
AUTH_SECRET="your-random-secret-here" # Generate with: npx auth secret
AUTH_URL="http://localhost:3000"
# Google OAuth
AUTH_GOOGLE_ID="your-google-client-id"
AUTH_GOOGLE_SECRET="your-google-client-secret"Getting Google OAuth Credentials
- Go to Google Cloud Console
- Create a new project or select an existing one
- Navigate to APIs & Services > Credentials
- Click Create Credentials > OAuth client ID
- Select Web application
- Add authorized redirect URI:
http://localhost:3000/api/auth/callback/google - Copy the Client ID and Client Secret to your
.env.local
Generate an auth secret:
npx auth secretTesting Your Implementation
Start the development server:
npm run devTest the following flows:
- Google OAuth: Click "Continue with Google" on the sign-in page
- Registration: Create a new account via
/auth/register - Credentials login: Sign in with the registered email/password
- Protected routes: Try accessing
/dashboardwithout signing in — you should be redirected - Admin access: Manually set a user's role to
adminin the database and verify/adminaccess
To promote a user to admin:
npx prisma studioFind the user and change their role field from user to admin.
Troubleshooting
"CSRF token mismatch" error
Make sure AUTH_URL in your .env.local matches the actual URL you're accessing (including port number).
Google OAuth redirects to wrong URL
Verify the redirect URI in Google Cloud Console exactly matches: http://localhost:3000/api/auth/callback/google
Session is null in client components
Use the SessionProvider wrapper in your layout if you need session access in client components:
// src/app/providers.tsx
"use client"
import { SessionProvider } from "next-auth/react"
export function Providers({ children }: { children: React.ReactNode }) {
return <SessionProvider>{children}</SessionProvider>
}Credentials login returns null
Ensure the user has a passwordHash field set. Users who signed up via Google OAuth won't have a password. Check that bcrypt comparison is working correctly.
Next Steps
Now that you have a solid authentication foundation, consider:
- Email verification: Use Auth.js email provider with Resend or Nodemailer
- Two-factor authentication: Add TOTP-based 2FA with a library like
otpauth - Social providers: Add GitHub, Discord, or Apple login alongside Google
- Rate limiting: Protect your login endpoint from brute-force attacks
- Audit logging: Track sign-in events for security monitoring
Conclusion
You've built a production-ready authentication system using Auth.js v5 and Next.js 15 that includes:
- Dual authentication: Google OAuth and email/password credentials
- Database-backed users: Prisma with PostgreSQL for persistent user storage
- JWT sessions: Fast, stateless session management
- Route protection: Middleware-based access control
- RBAC: Role-based authorization at both page and middleware levels
Auth.js v5's simplified API makes it remarkably easy to add enterprise-grade authentication to your Next.js applications. The combination of Server Components, Server Actions, and middleware gives you multiple layers of security without sacrificing developer experience.
The full source code for this tutorial is available as a reference — feel free to adapt it to your specific use case.
Discuss Your Project with Us
We're here to help with your web development needs. Schedule a call to discuss your project and how we can assist you.
Let's find the best solutions for your needs.
Related Articles

Build a SaaS Starter Kit with Next.js 15, Stripe Subscriptions, and Auth.js v5
Learn how to build a production-ready SaaS application with Next.js 15, Stripe for subscription billing, and Auth.js v5 for authentication. This step-by-step tutorial covers project setup, OAuth login, pricing plans, webhook handling, and protected routes.

Build a Real-Time App with Supabase and Next.js 15: Complete Guide
Learn how to build a full-stack real-time application using Supabase and Next.js 15 App Router. This guide covers authentication, database setup, Row Level Security, and real-time subscriptions.

Building an Autonomous AI Agent with Agentic RAG and Next.js
Learn how to build an AI agent that autonomously decides when and how to retrieve information from vector databases. A comprehensive hands-on guide using Vercel AI SDK and Next.js with executable examples.