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

AI Bot
By AI Bot ·

Loading the Text to Speech Audio Player...

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:

  1. A sign-in page supporting Google OAuth and email/password
  2. A protected dashboard only accessible to authenticated users
  3. An admin panel restricted to users with the admin role
  4. 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-app

Install the required dependencies:

npm install next-auth@beta @auth/prisma-adapter @prisma/client prisma bcryptjs zod
npm install -D @types/bcryptjs

Here's what each package does:

PackagePurpose
next-auth@betaAuth.js v5 for Next.js
@auth/prisma-adapterStores users/sessions in your database
prismaDatabase ORM
bcryptjsPassword hashing
zodSchema validation for credentials

Step 2: Configure Prisma

Initialize Prisma with your preferred database (we'll use PostgreSQL):

npx prisma init --datasource-provider postgresql

Update 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 generate

Create 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 = prisma

Step 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:

  1. Google provider handles OAuth with automatic profile mapping
  2. Credentials provider validates email/password with Zod and bcrypt
  3. JWT callbacks persist the user's role in the token
  4. Session callback exposes the role to client components
  5. Authorized callback protects /dashboard and /admin routes

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 } = handlers

That'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&apos;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

  1. Go to Google Cloud Console
  2. Create a new project or select an existing one
  3. Navigate to APIs & Services > Credentials
  4. Click Create Credentials > OAuth client ID
  5. Select Web application
  6. Add authorized redirect URI: http://localhost:3000/api/auth/callback/google
  7. Copy the Client ID and Client Secret to your .env.local

Generate an auth secret:

npx auth secret

Testing Your Implementation

Start the development server:

npm run dev

Test the following flows:

  1. Google OAuth: Click "Continue with Google" on the sign-in page
  2. Registration: Create a new account via /auth/register
  3. Credentials login: Sign in with the registered email/password
  4. Protected routes: Try accessing /dashboard without signing in — you should be redirected
  5. Admin access: Manually set a user's role to admin in the database and verify /admin access

To promote a user to admin:

npx prisma studio

Find 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.


Want to read more tutorials? Check out our latest tutorial on Introduction to Vibe Coding: AI-Assisted Development for Modern Teams.

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