Back to blog

Modern Authentication and Authorization in Next.js Applications

10 min readBy Mustafa Akkaya
#Authentication#NextAuth.js#Security#Next.js#OAuth#JWT

Authentication and authorization are critical for modern web applications. Let's implement a complete, production-ready auth system using NextAuth.js v5 (Auth.js) with Next.js 15, covering everything from basic login to advanced role-based access control.

Why NextAuth.js?

NextAuth.js (now Auth.js) is the industry standard for Next.js authentication:

- Built for Next.js - Seamless integration with App Router

- Multiple Providers - Google, GitHub, credentials, and 50+ OAuth providers

- Type-Safe - Full TypeScript support

- Flexible - Database or JWT sessions

- Secure - CSRF protection, secure cookies, encrypted tokens

- Easy to Use - Minimal configuration required

Project Setup

Install NextAuth.js v5 and dependencies.

bash

npm install next-auth@beta

npm install @auth/prisma-adapter

npm install bcryptjs zod

npm install -D @types/bcryptjs

Database Schema

Set up user and session tables with Prisma.

prisma

// prisma/schema.prisma

generator client {

provider = "prisma-client-js"

}

datasource db {

provider = "postgresql"

url = env("DATABASE_URL")

}

enum UserRole {

USER

ADMIN

MODERATOR

}

model User {

id String @id @default(cuid())

name String?

email String @unique

emailVerified DateTime?

image String?

password String?

role UserRole @default(USER)

accounts Account[]

sessions Session[]

posts Post[]

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?

access_token String?

expires_at Int?

token_type String?

scope String?

id_token String?

session_state String?

user User @relation(fields: [userId], references: [id], onDelete: Cascade)

@@unique([provider, providerAccountId])

@@index([userId])

}

model Session {

id String @id @default(cuid())

sessionToken String @unique

userId String

expires DateTime

user User @relation(fields: [userId], references: [id], onDelete: Cascade)

@@index([userId])

}

model VerificationToken {

identifier String

token String @unique

expires DateTime

@@unique([identifier, token])

}

model Post {

id String @id @default(cuid())

title String

content String

published Boolean @default(false)

authorId String

author User @relation(fields: [authorId], references: [id], onDelete: Cascade)

createdAt DateTime @default(now())

updatedAt DateTime @updatedAt

@@index([authorId])

}

Auth Configuration

Configure NextAuth.js with multiple providers.

typescript

// auth.config.ts

import type { NextAuthConfig } from "next-auth";

import Credentials from "next-auth/providers/credentials";

import Google from "next-auth/providers/google";

import GitHub from "next-auth/providers/github";

import { LoginSchema } from "./lib/schemas";

import { getUserByEmail } from "./lib/user";

import bcrypt from "bcryptjs";

export default {

providers: [

Google({

clientId: process.env.GOOGLE_CLIENT_ID,

clientSecret: process.env.GOOGLE_CLIENT_SECRET,

}),

GitHub({

clientId: process.env.GITHUB_CLIENT_ID,

clientSecret: process.env.GITHUB_CLIENT_SECRET,

}),

Credentials({

async authorize(credentials) {

const validatedFields = LoginSchema.safeParse(credentials);

if (!validatedFields.success) {

return null;

}

const { email, password } = validatedFields.data;

const user = await getUserByEmail(email);

if (!user || !user.password) {

return null;

}

const passwordsMatch = await bcrypt.compare(password, user.password);

if (!passwordsMatch) {

return null;

}

return user;

},

}),

],

} satisfies NextAuthConfig;

typescript

// auth.ts

import NextAuth from "next-auth";

import { PrismaAdapter } from "@auth/prisma-adapter";

import { prisma } from "@/lib/prisma";

import authConfig from "./auth.config";

import { getUserById } from "./lib/user";

import { UserRole } from "@prisma/client";

export const {

handlers: { GET, POST },

auth,

signIn,

signOut,

} = NextAuth({

adapter: PrismaAdapter(prisma),

session: { strategy: "jwt" },

pages: {

signIn: "/auth/login",

error: "/auth/error",

},

events: {

async linkAccount({ user }) {

await prisma.user.update({

where: { id: user.id },

data: { emailVerified: new Date() },

});

},

},

callbacks: {

async signIn({ user, account }) {

// Allow OAuth without email verification

if (account?.provider !== "credentials") {

return true;

}

const existingUser = await getUserById(user.id!);

// Prevent sign in without email verification

if (!existingUser?.emailVerified) {

return false;

}

// TODO: Add 2FA check

return true;

},

async session({ token, session }) {

if (token.sub && session.user) {

session.user.id = token.sub;

}

if (token.role && session.user) {

session.user.role = token.role as UserRole;

}

if (session.user) {

session.user.name = token.name;

session.user.email = token.email as string;

}

return session;

},

async jwt({ token }) {

if (!token.sub) return token;

const existingUser = await getUserById(token.sub);

if (!existingUser) return token;

token.name = existingUser.name;

token.email = existingUser.email;

token.role = existingUser.role;

return token;

},

},

...authConfig,

});

Route Handler

Create API routes for NextAuth.

typescript

// app/api/auth/[...nextauth]/route.ts

export { GET, POST } from "@/auth";

Type Definitions

Extend NextAuth types for TypeScript support.

typescript

// types/next-auth.d.ts

import { UserRole } from "@prisma/client";

import { DefaultSession } from "next-auth";

export type ExtendedUser = DefaultSession["user"] & {

role: UserRole;

};

declare module "next-auth" {

interface Session {

user: ExtendedUser;

}

}

declare module "@auth/core/jwt" {

interface JWT {

role?: UserRole;

}

}

Validation Schemas

Create Zod schemas for form validation.

typescript

// lib/schemas.ts

import { z } from "zod";

export const LoginSchema = z.object({

email: z.string().email({

message: "Email is required",

}),

password: z.string().min(1, {

message: "Password is required",

}),

});

export const RegisterSchema = z.object({

email: z.string().email({

message: "Email is required",

}),

password: z.string().min(6, {

message: "Minimum 6 characters required",

}),

name: z.string().min(1, {

message: "Name is required",

}),

});

Server Actions

Implement authentication actions.

typescript

// actions/auth.ts

"use server";

import { z } from "zod";

import { AuthError } from "next-auth";

import { signIn, signOut } from "@/auth";

import { LoginSchema, RegisterSchema } from "@/lib/schemas";

import { DEFAULT_LOGIN_REDIRECT } from "@/routes";

import { getUserByEmail } from "@/lib/user";

import bcrypt from "bcryptjs";

import { prisma } from "@/lib/prisma";

export async function login(values: z.infer) {

const validatedFields = LoginSchema.safeParse(values);

if (!validatedFields.success) {

return { error: "Invalid fields!" };

}

const { email, password } = validatedFields.data;

try {

await signIn("credentials", {

email,

password,

redirectTo: DEFAULT_LOGIN_REDIRECT,

});

return { success: "Logged in!" };

} catch (error) {

if (error instanceof AuthError) {

switch (error.type) {

case "CredentialsSignin":

return { error: "Invalid credentials!" };

default:

return { error: "Something went wrong!" };

}

}

throw error;

}

}

export async function register(values: z.infer) {

const validatedFields = RegisterSchema.safeParse(values);

if (!validatedFields.success) {

return { error: "Invalid fields!" };

}

const { email, password, name } = validatedFields.data;

const hashedPassword = await bcrypt.hash(password, 10);

const existingUser = await getUserByEmail(email);

if (existingUser) {

return { error: "Email already in use!" };

}

await prisma.user.create({

data: {

name,

email,

password: hashedPassword,

},

});

// TODO: Send verification token email

return { success: "User created!" };

}

export async function logout() {

await signOut();

}

Login Form Component

Create a login form with OAuth options.

typescript

// components/auth/login-form.tsx

"use client";

import { useState, useTransition } from "react";

import { useForm } from "react-hook-form";

import { zodResolver } from "@hookform/resolvers/zod";

import { z } from "zod";

import { LoginSchema } from "@/lib/schemas";

import { login } from "@/actions/auth";

import { signIn } from "next-auth/react";

export function LoginForm() {

const [error, setError] = useState("");

const [success, setSuccess] = useState("");

const [isPending, startTransition] = useTransition();

const form = useForm>({

resolver: zodResolver(LoginSchema),

defaultValues: {

email: "",

password: "",

},

});

const onSubmit = (values: z.infer) => {

setError("");

setSuccess("");

startTransition(() => {

login(values).then((data) => {

setError(data?.error);

setSuccess(data?.success);

});

});

};

const onClick = (provider: "google" | "github") => {

signIn(provider, {

callbackUrl: DEFAULT_LOGIN_REDIRECT,

});

};

return (

Sign In

{...form.register("email")}

type="email"

disabled={isPending}

className="w-full px-3 py-2 border rounded-lg"

placeholder="john@example.com"

/>

{form.formState.errors.email && (

{form.formState.errors.email.message}

)}

{...form.register("password")}

type="password"

disabled={isPending}

className="w-full px-3 py-2 border rounded-lg"

placeholder="••••••••"

/>

{form.formState.errors.password && (

{form.formState.errors.password.message}

)}

{error && (

{error}

)}

{success && (

{success}

)}

Or continue with

);

}

Protected Routes

Implement middleware for route protection.

typescript

// middleware.ts

import authConfig from "./auth.config";

import NextAuth from "next-auth";

const { auth } = NextAuth(authConfig);

export default auth((req) => {

const isLoggedIn = !!req.auth;

const { nextUrl } = req;

const isApiAuthRoute = nextUrl.pathname.startsWith("/api/auth");

const isPublicRoute =

nextUrl.pathname === "/" || nextUrl.pathname === "/blog";

const isAuthRoute = nextUrl.pathname.startsWith("/auth");

if (isApiAuthRoute) {

return;

}

if (isAuthRoute) {

if (isLoggedIn) {

return Response.redirect(new URL("/dashboard", nextUrl));

}

return;

}

if (!isLoggedIn && !isPublicRoute) {

return Response.redirect(new URL("/auth/login", nextUrl));

}

return;

});

export const config = {

matcher: ["/((?!.*\\..*|_next).*)", "/", "/(api|trpc)(.*)"],

};

Role-Based Access Control

Implement RBAC for admin features.

typescript

// lib/rbac.ts

import { UserRole } from "@prisma/client";

import { auth } from "@/auth";

export async function checkRole(allowedRoles: UserRole[]) {

const session = await auth();

if (!session || !session.user) {

return false;

}

return allowedRoles.includes(session.user.role);

}

export async function requireAdmin() {

const isAdmin = await checkRole([UserRole.ADMIN]);

if (!isAdmin) {

throw new Error("Unauthorized: Admin access required");

}

}

// Usage in Server Component

export default async function AdminPage() {

await requireAdmin();

return

Admin Dashboard
;

}

Session Hook

Custom hook for client-side auth state.

typescript

// hooks/use-current-user.ts

import { useSession } from "next-auth/react";

export function useCurrentUser() {

const session = useSession();

return session.data?.user;

}

export function useCurrentRole() {

const session = useSession();

return session.data?.user?.role;

}

// Usage in Client Component

("use client");

import { useCurrentUser } from "@/hooks/use-current-user";

export function UserProfile() {

const user = useCurrentUser();

if (!user) {

return

Not authenticated
;

}

return (

Welcome, {user.name}

{user.email}

Role: {user.role}

);

}

Environment Variables

Set up required environment variables.

bash

# .env.local

DATABASE_URL="postgresql://user:password@localhost:5432/mydb"

NEXTAUTH_SECRET="your-secret-key-here"

NEXTAUTH_URL="http://localhost:3000"

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"

Best Practices

1. Use Server Actions for form submissions

2. JWT for stateless apps - Better scalability

3. Database sessions for sensitive apps - More control

4. Email verification - Prevent spam accounts

5. Password requirements - Enforce strong passwords

6. Rate limiting - Prevent brute force attacks

7. CSRF protection - Built-in with NextAuth

8. Secure cookies - httpOnly, secure, sameSite

9. OAuth over credentials - More secure

10. 2FA - Additional security layer

Security Checklist

✅ Hash passwords with bcrypt

✅ Use HTTPS in production

✅ Set secure cookie flags

✅ Implement CSRF protection

✅ Validate all inputs with Zod

✅ Rate limit authentication endpoints

✅ Log authentication events

✅ Expire sessions appropriately

✅ Use environment variables for secrets

✅ Implement email verification

Conclusion

NextAuth.js v5 provides a robust, production-ready authentication solution for Next.js applications. With built-in support for OAuth providers, JWT sessions, and TypeScript, you can implement secure authentication in minutes while maintaining flexibility for custom requirements.

Start with OAuth providers for the best user experience, add credentials auth when needed, and always enforce proper authorization checks in your application! 🔐