Modern Authentication and Authorization in Next.js Applications
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
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! 🔐