Auth.js Mastery: From Static UI to Hardened Security This project is a modular, step-by-step curriculum designed to master Authentication in the Next.js App Router ecosystem. Spanning eight distinct "Labs," this series takes a practical, hands-on approach to security. Starting with a static Tailwind UI, each self-contained Docker environment incrementally layers on complexity—moving through Credentials and OAuth providers, database persistence with Prisma & SQLite, Role-Based Access Control (RBAC), and passwordless "Magic Links." The journey culminates in Lab 8, where we lock down backend API routes and Server Actions, providing a complete blueprint for modern, secure web application architecture.
Learning Authentication by Layers Security is complex, but learning it shouldn't be overwhelming. This series breaks down the modern authentication stack (Next.js, Auth.js v5, Prisma, and SQLite) into isolated, digestible experiments. By using Docker to containerize each step, we ensure a clean slate for every concept. We won't just paste code; we will build the system layer by layer. You will witness the evolution of a secure app: from a vulnerable static page to a robust system capable of handling social logins, admin roles, and secure API communication, giving you the confidence to deploy production-grade auth in your own projects.
Project: Auth.js v5 Implementation Labs A comprehensive reference repository demonstrating the implementation of Auth.js (NextAuth) v5 within a Next.js 14+ environment. The project is divided into 8 incremental labs, each running in an isolated Docker container. Key concepts covered include: * Foundation: Tailwind CSS UI and Next.js App Router structure. * Core Auth: Credentials provider, OAuth (GitHub), and Magic Links (Nodemailer). * Persistence: SQLite database integration via Prisma ORM. * Authorization: Role-Based Access Control (RBAC) and Protected API Routes. * UX: Custom login pages and error handling using Server Actions.
Would you like me to generate a master README.md file that links to all 8 labs with this introduction included?
In this lab, we are setting up the "control" environment. We will build a Next.js app with a Home page (public) and a Members page (which should be private, but currently isn't). This highlights the problem we will solve in Lab 2.
Open your terminal and run the following commands to create the base Next.js application inside a lab1 folder.
# Create the app (accept all defaults: Yes to TypeScript, ESLint, Tailwind, App Router)
npx create-next-app@latest lab1
# Move into the folder
cd lab1Create these two files in the root of your lab1 folder (alongside package.json).
File: lab1/Dockerfile
FROM node:20-alpine
WORKDIR /app
# Copy package files first for better caching
COPY package*.json ./
RUN npm install
# Copy the rest of the application
COPY . .
# Next.js collects telemetry by default, we can disable it here
ENV NEXT_TELEMETRY_DISABLED 1
EXPOSE 3000
CMD ["npm", "run", "dev"]File: lab1/docker-compose.yml
version: '3.8'
services:
web:
build: .
ports:
- "3000:3000"
volumes:
- .:/app
- /app/node_modules
- /app/.next
environment:
- NODE_ENV=developmentWe need a navigation bar and our specific pages.
1. Create a Navbar Component
Create a new folder app/components and a file named Navbar.tsx.
File: lab1/app/components/Navbar.tsx
import Link from "next/link";
export default function Navbar() {
return (
<nav className="bg-slate-900 text-white p-4 shadow-md">
<div className="container mx-auto flex justify-between items-center">
<div className="text-xl font-bold">Auth.js Labs</div>
<div className="space-x-6">
<Link href="/" className="hover:text-blue-400 transition">
Home
</Link>
<Link href="/about" className="hover:text-blue-400 transition">
About
</Link>
<Link
href="/members"
className="bg-blue-600 hover:bg-blue-700 px-4 py-2 rounded transition"
>
Members Only
</Link>
</div>
</div>
</nav>
);
}2. Update the Global Layout We need to add the Navbar so it appears on every page.
File: lab1/app/layout.tsx
import type { Metadata } from "next";
import { Inter } from "next/font/google";
import "./globals.css";
import Navbar from "./components/Navbar";
const inter = Inter({ subsets: ["latin"] });
export const metadata: Metadata = {
title: "Lab 1: Static Foundation",
description: "Learning Auth.js",
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="en">
<body className={inter.className}>
<Navbar />
<main className="container mx-auto p-8">
{children}
</main>
</body>
</html>
);
}3. Create the Home Page (Public) Replace the default content.
File: lab1/app/page.tsx
export default function Home() {
return (
<div className="flex flex-col items-center justify-center min-h-[50vh] gap-4">
<h1 className="text-4xl font-bold text-slate-800">Welcome to Lab 1</h1>
<p className="text-xl text-slate-600">
This is a public landing page. Anyone can see this.
</p>
<div className="p-4 bg-yellow-100 border-l-4 border-yellow-500 text-yellow-700">
<strong>Current Status:</strong> No Authentication implemented.
</div>
</div>
);
}4. Create the "About" Page (Public)
Create folder app/about and file page.tsx.
File: lab1/app/about/page.tsx
export default function AboutPage() {
return (
<div>
<h1 className="text-3xl font-bold mb-4">About Us</h1>
<p>This is generic public information visible to the world.</p>
</div>
);
}5. Create the "Members" Page (The Goal)
Create folder app/members and file page.tsx.
File: lab1/app/members/page.tsx
export default function MembersPage() {
return (
<div className="bg-slate-100 p-8 rounded-lg border border-slate-200">
<h1 className="text-3xl font-bold text-red-600 mb-4">
Members Only Area
</h1>
<p className="text-lg mb-4">
If you are reading this, you have access to our secret content!
</p>
<div className="bg-white p-4 rounded shadow">
<h2 className="font-bold">Secret Data:</h2>
<ul className="list-disc list-inside mt-2">
<li>User ID: ???</li>
<li>Session Token: ???</li>
</ul>
</div>
<p className="mt-6 text-sm text-slate-500">
(In Lab 1, this page is currently unsecured and visible to everyone.)
</p>
</div>
);
}lab1):
bash
docker-compose up
http://localhost:3000.Validation Checklist:
Create a folder named lab1. Inside that folder, create the files as listed below.
These files define the project dependencies and settings.
File: lab1/package.json
{
"name": "lab1",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint"
},
"dependencies": {
"next": "14.1.0",
"react": "^18",
"react-dom": "^18"
},
"devDependencies": {
"@types/node": "^20",
"@types/react": "^18",
"@types/react-dom": "^18",
"autoprefixer": "^10.0.1",
"eslint": "^8",
"eslint-config-next": "14.1.0",
"postcss": "^8",
"tailwindcss": "^3.3.0",
"typescript": "^5"
}
}File: lab1/tsconfig.json
{
"compilerOptions": {
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./*"]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}File: lab1/next.config.mjs
/** @type {import('next').NextConfig} */
const nextConfig = {};
export default nextConfig;File: lab1/tailwind.config.ts
import type { Config } from "tailwindcss";
const config: Config = {
content: [
"./app/**/*.{js,ts,jsx,tsx,mdx}",
],
theme: {
extend: {},
},
plugins: [],
};
export default config;File: lab1/postcss.config.js
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};File: lab1/Dockerfile
FROM node:20-alpine
WORKDIR /app
# Copy package files
COPY package*.json ./
# Install dependencies inside the container
RUN npm install
# Copy the rest of the files
COPY . .
EXPOSE 3000
CMD ["npm", "run", "dev"]File: lab1/docker-compose.yml
version: '3.8'
services:
web:
build: .
ports:
- "3000:3000"
volumes:
- .:/app
- /app/node_modules
- /app/.nextCreate a folder named app inside lab1. Then create the files below inside it.
File: lab1/app/globals.css
@tailwind base;
@tailwind components;
@tailwind utilities;File: lab1/app/layout.tsx
import type { Metadata } from "next";
import "./globals.css";
import Navbar from "./components/Navbar";
export const metadata: Metadata = {
title: "Lab 1",
description: "Auth.js Lab 1",
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en">
<body>
<Navbar />
<main className="container mx-auto p-8">{children}</main>
</body>
</html>
);
}File: lab1/app/page.tsx (The Public Home Page)
export default function Home() {
return (
<div className="flex flex-col items-center justify-center min-h-[50vh] gap-4">
<h1 className="text-4xl font-bold">Welcome to Lab 1</h1>
<p className="text-xl text-gray-600">Public Landing Page</p>
<div className="p-4 bg-yellow-100 text-yellow-800 border border-yellow-300 rounded">
Status: <strong>No Authentication</strong>
</div>
</div>
);
}File: lab1/app/components/Navbar.tsx
(Create the components folder inside app first)
import Link from "next/link";
export default function Navbar() {
return (
<nav className="bg-slate-900 text-white p-4">
<div className="container mx-auto flex justify-between items-center">
<div className="font-bold text-xl">Auth.js Labs</div>
<div className="space-x-4">
<Link href="/" className="hover:text-blue-400">Home</Link>
<Link href="/about" className="hover:text-blue-400">About</Link>
<Link href="/members" className="bg-blue-600 px-4 py-2 rounded hover:bg-blue-700">
Members
</Link>
</div>
</div>
</nav>
);
}File: lab1/app/about/page.tsx
(Create the about folder inside app first)
export default function AboutPage() {
return <h1 className="text-2xl font-bold">Public About Page</h1>;
}File: lab1/app/members/page.tsx
(Create the members folder inside app first)
export default function MembersPage() {
return (
<div className="border p-8 rounded bg-gray-50">
<h1 className="text-2xl font-bold text-red-600 mb-4">Members Area</h1>
<p>If you see this, you are seeing protected content (currently insecure!).</p>
</div>
);
}lab1 folder.docker compose up.npm install to finish inside the container.http://localhost:3000.Validation: You should see the UI, and you should be able to click "Members" and see the content without logging in.
Shall we move to Lab 2 to add the actual Authentication logic?
In this lab, we will introduce next-auth (v5 beta). We will configure a "Credentials" provider which allows logging in with a username and password. For now, we will hardcode the user to understand the flow without worrying about a database yet.
lab1 folder and rename it to lab2.lab2 folder.We need to add the authentication library.
File: lab2/package.json
Find the dependencies section and add "next-auth": "5.0.0-beta.25" (or just "beta").
"dependencies": {
"next": "14.1.0",
"next-auth": "beta", <-- ADD THIS
"react": "^18",
"react-dom": "^18"
},File: lab2/docker-compose.yml
We need to add an AUTH_SECRET environment variable. This is used to encrypt the session tokens.
version: '3.8'
services:
web:
build: .
ports:
- "3000:3000"
volumes:
- .:/app
- /app/node_modules
- /app/.next
environment:
- AUTH_SECRET=my_super_secret_key_123 # <-- ADD THISCreate a new file named auth.ts in the root of lab2 (same level as package.json). This is the heart of Auth.js v5.
File: lab2/auth.ts
import NextAuth from "next-auth"
import Credentials from "next-auth/providers/credentials"
export const { handlers, signIn, signOut, auth } = NextAuth({
providers: [
Credentials({
// The name to display on the sign in form (e.g. "Sign in with...")
name: "Credentials",
// The credentials object is used to generate the inputs on the login page
credentials: {
email: { label: "Email", type: "text", placeholder: "test@example.com" },
password: { label: "Password", type: "password" }
},
// The logic to verify the user
authorize: async (credentials) => {
// HARDCODED USER FOR LAB 2
const user = { id: "1", name: "J Smith", email: "test@example.com", password: "password" }
if (credentials?.email === user.email && credentials?.password === user.password) {
// Any object returned will be saved in the `user` property of the JWT
return user
} else {
// If you return null then an error will be displayed advising the user to check their details.
return null
}
}
})
],
})Next.js needs an API route to handle the sign-in and sign-out requests.
Create the folder path: app/api/auth/[...nextauth]
Create the file: route.ts inside that folder.
File: lab2/app/api/auth/[...nextauth]/route.ts
import { handlers } from "@/auth" // Refers to auth.ts we just made
export const { GET, POST } = handlersThis file acts as a gatekeeper. It runs before every request. If the user tries to access a protected route without being logged in, this will stop them.
Create middleware.ts in the root of lab2.
File: lab2/middleware.ts
import { auth } from "@/auth"
export default auth((req) => {
const isLoggedIn = !!req.auth
// If trying to access /members and NOT logged in...
if (req.nextUrl.pathname.startsWith('/members') && !isLoggedIn) {
return Response.redirect(new URL('/api/auth/signin', req.nextUrl))
}
})
// Optionally, don't invoke Middleware on some paths
export const config = {
matcher: ["/((?!api|_next/static|_next/image|favicon.ico).*)"],
}1. Update the Navbar to show Sign In/Out
We will make this a server component so we can check the session directly.
File: lab2/app/components/Navbar.tsx
import Link from "next/link";
import { auth, signOut } from "@/auth"; // Import from our config
export default async function Navbar() {
const session = await auth();
return (
<nav className="bg-slate-900 text-white p-4">
<div className="container mx-auto flex justify-between items-center">
<div className="font-bold text-xl">Auth.js Lab 2</div>
<div className="flex gap-4 items-center">
<Link href="/" className="hover:text-blue-400">Home</Link>
<Link href="/members" className="hover:text-blue-400">Members</Link>
{session && session.user ? (
<div className="flex gap-4 items-center border-l pl-4 border-slate-600">
<span className="text-sm text-slate-300">Hi, {session.user.name}</span>
{/* Server Action to Sign Out */}
<form
action={async () => {
"use server"
await signOut()
}}
>
<button type="submit" className="bg-red-600 text-sm px-3 py-1 rounded hover:bg-red-700">
Sign Out
</button>
</form>
</div>
) : (
<Link href="/api/auth/signin" className="bg-blue-600 px-4 py-2 rounded hover:bg-blue-700">
Sign In
</Link>
)}
</div>
</div>
</nav>
);
}2. Update Members Page to Show User Data Now that we have a user, let's display their info on the protected page.
File: lab2/app/members/page.tsx
import { auth } from "@/auth";
export default async function MembersPage() {
const session = await auth();
return (
<div className="bg-white border p-8 rounded shadow-lg">
<h1 className="text-3xl font-bold text-green-700 mb-4">Access Granted</h1>
<p className="mb-6 text-gray-600">
Welcome to the private members area.
</p>
<div className="bg-slate-100 p-4 rounded border font-mono text-sm">
<h3 className="font-bold text-slate-700 border-b border-slate-300 mb-2 pb-1">Current Session Data:</h3>
<pre>{JSON.stringify(session, null, 2)}</pre>
</div>
</div>
);
}Since we changed package.json (added next-auth), we must rebuild the Docker image.
lab2.bash
docker-compose up --build
http://localhost:3000.How to Test:
test@example.compasswordConcept Check: You now have a working authentication system! However, if you restart the Docker container, you stay logged in only because the session is stored in a Cookie in your browser (JWT). We do not have a database yet, so we can't "create" new users.
Ready for Lab 3 to add SQLite and Prisma?
In Lab 2, the user "J Smith" disappeared every time you changed the code or restarted the server. In Lab 3, we will use Prisma (an ORM) and SQLite (a file-based database) to store our users permanently.
Important Concept: By default, when using the Credentials provider (Email/Password), Auth.js forces the session to be a JWT (cookie-based). However, we will use the database to store and retrieve the user profile.
lab2 folder and rename it to lab3.lab3 folder.We need to add Prisma and the adapter to package.json.
File: lab3/package.json
Update your dependencies and scripts exactly like this. Note the new dev script which ensures the database is created when Docker starts.
{
"name": "lab3",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "prisma db push && next dev",
"build": "prisma generate && next build",
"start": "next start",
"lint": "next lint"
},
"dependencies": {
"@auth/prisma-adapter": "^1.4.0",
"@prisma/client": "^5.10.0",
"next": "14.1.0",
"next-auth": "beta",
"react": "^18",
"react-dom": "^18"
},
"devDependencies": {
"@types/node": "^20",
"@types/react": "^18",
"@types/react-dom": "^18",
"autoprefixer": "^10.0.1",
"eslint": "^8",
"eslint-config-next": "14.1.0",
"postcss": "^8",
"prisma": "^5.10.0",
"tailwindcss": "^3.3.0",
"typescript": "^5"
}
}We need to define what our tables look like. Create a folder named prisma in the root, and a file named schema.prisma inside it.
File: lab3/prisma/schema.prisma
datasource db {
provider = "sqlite"
url = "file:./dev.db"
}
generator client {
provider = "prisma-client-js"
}
// The User model
model User {
id String @id @default(cuid())
name String?
email String? @unique
password String? // Added for Credentials login
emailVerified DateTime?
image String?
accounts Account[]
sessions Session[]
}
// Required for Auth.js (Social Logins, etc.)
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])
}
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])
}We need a helper file to connect to the DB efficiently without creating too many connections during development.
Create file: lab3/lib/prisma.ts (Create the lib folder first)
import { PrismaClient } from "@prisma/client"
const globalForPrisma = globalThis as unknown as { prisma: PrismaClient }
export const prisma = globalForPrisma.prisma || new PrismaClient()
if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = prismaauth.ts)Now we connect Auth.js to the database. We will also add a small logic hack to auto-create a user if one doesn't exist, so you don't have to write a seed script manually.
File: lab3/auth.ts
import NextAuth from "next-auth"
import Credentials from "next-auth/providers/credentials"
import { PrismaAdapter } from "@auth/prisma-adapter"
import { prisma } from "@/lib/prisma"
export const { handlers, signIn, signOut, auth } = NextAuth({
adapter: PrismaAdapter(prisma),
session: { strategy: "jwt" }, // Required when using Credentials with an Adapter
providers: [
Credentials({
name: "Credentials",
credentials: {
email: { label: "Email", type: "email", placeholder: "user@example.com" },
password: { label: "Password", type: "password" },
},
authorize: async (credentials) => {
if (!credentials?.email || !credentials?.password) {
return null;
}
const email = credentials.email as string;
// 1. Check if user exists in DB
let user = await prisma.user.findUnique({
where: {
email: email,
},
});
// 2. LAB HACK: If user doesn't exist, create them on the fly!
// (In a real app, you would have a separate Register page)
if (!user) {
user = await prisma.user.create({
data: {
email: email,
name: "New User",
password: "password", // In production, HASH this!
}
})
}
// 3. Validate Password
// (In production, use bcrypt.compare(credentials.password, user.password))
if (credentials.password === user.password) {
return user;
}
return null;
},
}),
],
})We need to ensure prisma commands can run inside the container.
File: lab3/Dockerfile
We add npx prisma generate to the build process so the TypeScript client is created.
FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
# Install dependencies
RUN npm install
COPY . .
# Generate Prisma Client
RUN npx prisma generate
EXPOSE 3000
# Note: The CMD is now handled by "npm run dev" in package.json
# which runs "prisma db push" first.
CMD ["npm", "run", "dev"](The docker-compose.yml from Lab 2 works fine as is, because it maps the volume. The SQLite file dev.db will appear in your lab3/prisma folder on your host machine.)
lab3.bash
docker-compose down
docker-compose up --build
What to expect:
Prisma schema loaded from prisma/schema.prisma and The database is now in sync with your schema.http://localhost:3000.admin@example.com) and the password password.auth.ts, since this user didn't exist, the code created it in SQLite immediately and logged you in.lab3/prisma folder, you will see a dev.db file. If you restart Docker, that file remains, and your user remains.Validation:
Once logged in, the Members page should show your JSON session. The sub (subject) ID in the JSON will now be a complex string (like clt...) which is the CUID generated by the Database, proving it came from SQLite!
Ready for Lab 4 to add GitHub OAuth?
In Lab 3, you authenticated using a database-backed email/password. In Lab 4, we add "Sign in with GitHub".
The Magic of Auth.js:
Remember the Account model you added to schema.prisma in Lab 3? That table exists specifically to link social identities (like a GitHub ID) to your User record. When a user signs in with GitHub, Auth.js will automatically create a User record and an Account record linking them.
lab3 folder and rename it to lab4.lab4 folder.You cannot code this part; you must configure it on GitHub.
Auth.js Lab 4http://localhost:3000http://localhost:3000/api/auth/callback/github/api/auth/callback/[provider] is standard for Auth.js)We need to pass these secrets to our container.
File: lab4/docker-compose.yml
Update the environment section. Replace the placeholders with the actual strings you just copied from GitHub.
version: '3.8'
services:
web:
build: .
ports:
- "3000:3000"
volumes:
- .:/app
- /app/node_modules
- /app/.next
environment:
- AUTH_SECRET=my_super_secret_key_123
# Add these lines:
- AUTH_GITHUB_ID=your_client_id_paste_here
- AUTH_GITHUB_SECRET=your_client_secret_paste_hereWe simply add the provider to the configuration.
File: lab4/auth.ts
import NextAuth from "next-auth"
import Credentials from "next-auth/providers/credentials"
import GitHub from "next-auth/providers/github" // <--- Import this
import { PrismaAdapter } from "@auth/prisma-adapter"
import { prisma } from "@/lib/prisma"
export const { handlers, signIn, signOut, auth } = NextAuth({
adapter: PrismaAdapter(prisma),
session: { strategy: "jwt" },
providers: [
// 1. GitHub Provider
GitHub({
clientId: process.env.AUTH_GITHUB_ID,
clientSecret: process.env.AUTH_GITHUB_SECRET,
}),
// 2. Credentials Provider (Kept from Lab 3)
Credentials({
name: "Credentials",
credentials: {
email: { label: "Email", type: "email", placeholder: "user@example.com" },
password: { label: "Password", type: "password" },
},
authorize: async (credentials) => {
if (!credentials?.email || !credentials?.password) return null;
const email = credentials.email as string;
let user = await prisma.user.findUnique({ where: { email } });
if (!user) {
user = await prisma.user.create({
data: { email, name: "New User", password: "password" }
})
}
if (credentials.password === user.password) return user;
return null;
},
}),
],
})bash
docker-compose up --build
http://localhost:3000.What to expect:
name: Your actual GitHub username.email: Your GitHub email.image: Your GitHub avatar (automatically pulled!).Database Check:
If you check the dev.db (using a SQLite viewer or Prisma Studio), you will see:
provider: "github" and your specific providerAccountId from GitHub. This is how Auth.js knows it's you next time.You have completed the "Standard Stack" (Next.js + Prisma + SQLite + OAuth).
Which one would you like to do next?
In previous labs, a user was either "logged in" or "not logged in." In the real world, you have Users, Admins, Editors, etc.
The Challenge:
session.user.role, it will yell at you because that property doesn't exist on the default type definition.lab4 folder and rename it to lab5.lab5 folder.We need to add a role column to our User table.
File: lab5/prisma/schema.prisma
Update the User model to include the role field. We set the default to "user" so new sign-ups don't accidentally become admins.
model User {
id String @id @default(cuid())
name String?
email String? @unique
password String?
role String @default("user") // <--- ADD THIS
emailVerified DateTime?
image String?
accounts Account[]
sessions Session[]
}
// ... rest of file remains the sameThis is the most common stumbling block in Auth.js. We need to tell TypeScript that our Session user now has a role.
Create a folder types in the root, and a file next-auth.d.ts.
File: lab5/types/next-auth.d.ts
import NextAuth, { DefaultSession } from "next-auth"
import { JWT } from "next-auth/jwt"
declare module "next-auth" {
interface Session {
user: {
role: string
} & DefaultSession["user"]
}
interface User {
role: string
}
}
declare module "next-auth/jwt" {
interface JWT {
role: string
}
}We need to do two things here:
admin@example.com.File: lab5/auth.ts
import NextAuth from "next-auth"
import Credentials from "next-auth/providers/credentials"
import GitHub from "next-auth/providers/github"
import { PrismaAdapter } from "@auth/prisma-adapter"
import { prisma } from "@/lib/prisma"
export const { handlers, signIn, signOut, auth } = NextAuth({
adapter: PrismaAdapter(prisma),
session: { strategy: "jwt" },
providers: [
GitHub({
clientId: process.env.AUTH_GITHUB_ID,
clientSecret: process.env.AUTH_GITHUB_SECRET,
// We can't easily force "admin" on GitHub login without a dashboard,
// so GitHub users will be "user" by default.
}),
Credentials({
name: "Credentials",
credentials: {
email: { label: "Email", type: "email", placeholder: "admin@example.com" },
password: { label: "Password", type: "password" },
},
authorize: async (credentials) => {
if (!credentials?.email || !credentials?.password) return null;
const email = credentials.email as string;
let user = await prisma.user.findUnique({ where: { email } });
if (!user) {
// LAB HACK: If email is admin@example.com, make them ADMIN
const role = email === "admin@example.com" ? "admin" : "user";
user = await prisma.user.create({
data: {
email,
name: "New User",
password: "password",
role: role // <--- Save role to DB
}
})
}
if (credentials.password === user.password) return user;
return null;
},
}),
],
// CALLBACKS - The Secret Sauce
callbacks: {
async jwt({ token, user }) {
// "user" is only available the very first time they login.
// We copy the role from the DB user object to the JWT token.
if (user) {
token.role = user.role
}
return token
},
async session({ session, token }) {
// We copy the role from the JWT token to the Session object
// so the frontend can see it.
if (session?.user && token.role) {
session.user.role = token.role
}
return session
}
}
})Let's create a page that only admins can see.
File: lab5/app/admin/page.tsx
(Create admin folder inside app)
import { auth } from "@/auth";
import { redirect } from "next/navigation";
export default async function AdminDashboard() {
const session = await auth();
// 1. Check if logged in
if (!session || !session.user) {
redirect("/api/auth/signin");
}
// 2. Check for Role
if (session.user.role !== "admin") {
return (
<div className="p-8 bg-red-50 text-red-800 border border-red-200 rounded">
<h1 className="text-3xl font-bold">403 Forbidden</h1>
<p>You are logged in as a <strong>{session.user.role}</strong>.</p>
<p>You do not have permission to view this page.</p>
</div>
);
}
return (
<div className="p-8 bg-purple-50 border border-purple-200 rounded">
<h1 className="text-3xl font-bold text-purple-900 mb-4">Admin Dashboard</h1>
<p className="text-lg">Welcome, Master Administrator.</p>
<div className="mt-4 p-4 bg-white rounded shadow">
<p>Only users with <code>role: 'admin'</code> can see this.</p>
</div>
</div>
);
}We want to show a link to the Admin Dashboard, but only if the user is actually an admin.
File: lab5/app/components/Navbar.tsx
import Link from "next/link";
import { auth, signOut } from "@/auth";
export default async function Navbar() {
const session = await auth();
return (
<nav className="bg-slate-900 text-white p-4">
<div className="container mx-auto flex justify-between items-center">
<div className="font-bold text-xl">Auth.js Lab 5</div>
<div className="flex gap-4 items-center">
<Link href="/" className="hover:text-blue-400">Home</Link>
<Link href="/members" className="hover:text-blue-400">Members</Link>
{/* CONDITIONAL RENDERING FOR ADMIN */}
{session?.user?.role === 'admin' && (
<Link href="/admin" className="text-purple-400 hover:text-purple-300 font-bold">
Admin Panel
</Link>
)}
{session && session.user ? (
<div className="flex gap-4 items-center border-l pl-4 border-slate-600">
<div className="flex flex-col text-right">
<span className="text-sm text-slate-300">{session.user.name}</span>
{/* Show Badge */}
<span className="text-[10px] uppercase bg-slate-700 px-1 rounded text-center">
{session.user.role}
</span>
</div>
<form action={async () => { "use server"; await signOut() }}>
<button type="submit" className="bg-red-600 text-sm px-3 py-1 rounded hover:bg-red-700">
Sign Out
</button>
</form>
</div>
) : (
<Link href="/api/auth/signin" className="bg-blue-600 px-4 py-2 rounded hover:bg-blue-700">
Sign In
</Link>
)}
</div>
</div>
</nav>
);
}prisma generate again).
bash
docker-compose down
docker-compose up --build
http://localhost:3000.Testing the Role Logic:
Test as Regular User:
joe@test.com / password).http://localhost:3000/admin in the URL bar.Test as Admin:
admin@example.compasswordauth.ts detects this specific email and writes role: 'admin' to the database.Concept Check: You now have a system where the Database holds the truth, the JWT carries the truth to the browser, and the Session provides that truth to your React components.
Ready for Lab 6 where we finally get rid of that ugly default white login page and build our own?
In Labs 1-5, we used the auto-generated NextAuth login page. It works, but it looks generic and you can't style it to match your brand. In this lab, we will tell Auth.js: "Stop using your default page; use mine instead."
lab5 folder and rename it to lab6.lab6 folder.We need to add the pages configuration to auth.ts. This tells Auth.js where to redirect users when they need to sign in.
File: lab6/auth.ts
Add the pages object near the bottom (before the closing })).
// ... imports and providers remain the same ...
export const { handlers, signIn, signOut, auth } = NextAuth({
adapter: PrismaAdapter(prisma),
session: { strategy: "jwt" },
providers: [
// ... keep your existing GitHub and Credentials providers ...
GitHub({
clientId: process.env.AUTH_GITHUB_ID,
clientSecret: process.env.AUTH_GITHUB_SECRET,
}),
Credentials({
name: "Credentials",
credentials: {
email: { label: "Email", type: "email" },
password: { label: "Password", type: "password" },
},
authorize: async (credentials) => {
// ... keep your existing authorize logic ...
if (!credentials?.email || !credentials?.password) return null;
const email = credentials.email as string;
let user = await prisma.user.findUnique({ where: { email } });
if (!user) {
const role = email === "admin@example.com" ? "admin" : "user";
user = await prisma.user.create({
data: { email, name: "New User", password: "password", role }
})
}
if (credentials.password === user.password) return user;
return null;
},
}),
],
// NEW CONFIGURATION HERE
pages: {
signIn: "/auth/signin", // <--- Tells Auth.js to use our custom page
},
callbacks: {
// ... keep your existing callbacks ...
async jwt({ token, user }) {
if (user) token.role = user.role
return token
},
async session({ session, token }) {
if (session?.user && token.role) {
session.user.role = token.role
}
return session
}
}
})In Next.js App Router, the best way to handle form submissions is via Server Actions. We will create a dedicated file to handle the login logic so our UI code stays clean.
Create a file: lab6/app/lib/actions.ts (Create lib folder inside app if needed).
File: lab6/app/lib/actions.ts
"use server"
import { signIn } from "@/auth"
import { AuthError } from "next-auth"
export async function authenticate(prevState: string | undefined, formData: FormData) {
try {
await signIn("credentials", formData)
} catch (error) {
if (error instanceof AuthError) {
switch (error.type) {
case "CredentialsSignin":
return "Invalid credentials."
default:
return "Something went wrong."
}
}
throw error
}
}
export async function authenticateGithub() {
await signIn("github")
}Now we build the actual page. We will use a standard HTML form for simplicity, hooked up to our Server Actions.
Create the path: app/auth/signin and the file page.tsx.
File: lab6/app/auth/signin/page.tsx
"use client"
import { useFormState } from "react-dom"
import { authenticate, authenticateGithub } from "@/app/lib/actions"
export default function SignInPage() {
// useFormState allows us to see the error message returned by the server action
const [errorMessage, dispatch] = useFormState(authenticate, undefined)
return (
<div className="flex min-h-screen items-center justify-center bg-slate-100">
<div className="w-full max-w-md space-y-8 rounded-lg bg-white p-10 shadow-xl border border-slate-200">
{/* Header */}
<div className="text-center">
<h2 className="text-3xl font-extrabold text-slate-900">
Sign in to Lab 6
</h2>
<p className="mt-2 text-sm text-slate-600">
This is a custom Tailwind page
</p>
</div>
{/* GitHub Button */}
<form action={authenticateGithub}>
<button
type="submit"
className="w-full flex justify-center items-center gap-2 rounded-md bg-[#24292F] px-4 py-3 text-sm font-semibold text-white hover:bg-[#24292F]/90 focus:outline-none focus:ring-2 focus:ring-[#24292F]/50 focus:ring-offset-2"
>
{/* Simple Github Icon SVG */}
<svg className="h-5 w-5" fill="currentColor" viewBox="0 0 24 24">
<path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"/>
</svg>
Sign in with GitHub
</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="bg-white px-2 text-gray-500">Or continue with</span>
</div>
</div>
{/* Credentials Form */}
<form action={dispatch} className="space-y-6">
<div>
<label className="block text-sm font-medium text-gray-700">
Email address
</label>
<div className="mt-1">
<input
name="email"
type="email"
required
className="block w-full rounded-md border border-gray-300 px-3 py-2 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-indigo-500 sm:text-sm"
placeholder="admin@example.com"
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">
Password
</label>
<div className="mt-1">
<input
name="password"
type="password"
required
className="block w-full rounded-md border border-gray-300 px-3 py-2 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-indigo-500 sm:text-sm"
placeholder="password"
/>
</div>
</div>
{/* Error Message Display */}
<div
className="flex h-8 items-end space-x-1"
aria-live="polite"
aria-atomic="true"
>
{errorMessage && (
<p className="text-sm text-red-500 font-bold">
❌ {errorMessage}
</p>
)}
</div>
<button
type="submit"
className="flex w-full justify-center rounded-md bg-indigo-600 px-4 py-3 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600"
>
Sign in with Credentials
</button>
</form>
</div>
</div>
)
}auth.ts and added server actions).
bash
docker-compose down
docker-compose up --build
http://localhost:3000.What to expect:
/auth/signin page.useFormState).admin@example.com and password. You should be redirected to Members.Concept Check: You now have full control over the login experience. You can add your logo, links to "Forgot Password", or Terms of Service.
Ready for Lab 7? We will ditch the password entirely and use Magic Links (Email) authentication. This requires simulating an Email Server in Docker!
In this lab, we remove the need for passwords entirely. We will use "Magic Links". The user enters their email, receives a link, clicks it, and is logged in.
The Challenge: To test this locally, we need to send emails.
The Solution: We will add a service called Mailhog to our Docker setup. Mailhog pretends to be an email server (SMTP). It traps every email sent by Auth.js and lets you view it in a web interface (http://localhost:8025), so you don't need a real email provider like Gmail or SendGrid yet.
lab6 folder and rename it to lab7.lab7 folder.We need nodemailer to handle the SMTP transport.
File: lab7/package.json
Add "nodemailer": "^6.9.0" to dependencies.
"dependencies": {
"nodemailer": "^6.9.0",
// ... existing dependencies
},We need to add the Mailhog service.
File: lab7/docker-compose.yml
Add the mailhog service and update the web environment variables to point to it.
version: '3.8'
services:
web:
build: .
ports:
- "3000:3000"
volumes:
- .:/app
- /app/node_modules
- /app/.next
environment:
- AUTH_SECRET=my_super_secret_key_123
- AUTH_GITHUB_ID=your_github_id
- AUTH_GITHUB_SECRET=your_github_secret
# SMTP Configuration for Mailhog
- EMAIL_SERVER_HOST=mailhog
- EMAIL_SERVER_PORT=1025
- EMAIL_FROM=noreply@example.com
# NEW SERVICE
mailhog:
image: mailhog/mailhog
ports:
- "1025:1025" # SMTP port (for Auth.js to send to)
- "8025:8025" # Web UI (for YOU to see the emails)We replace the Credentials provider (optional, but let's keep it for variety) or just add the Nodemailer provider.
File: lab7/auth.ts
import NextAuth from "next-auth"
import GitHub from "next-auth/providers/github"
import Nodemailer from "next-auth/providers/nodemailer" // <--- Import this
import { PrismaAdapter } from "@auth/prisma-adapter"
import { prisma } from "@/lib/prisma"
export const { handlers, signIn, signOut, auth } = NextAuth({
adapter: PrismaAdapter(prisma),
session: { strategy: "jwt" },
providers: [
GitHub({
clientId: process.env.AUTH_GITHUB_ID,
clientSecret: process.env.AUTH_GITHUB_SECRET,
}),
// MAGIC LINK PROVIDER
Nodemailer({
server: {
host: process.env.EMAIL_SERVER_HOST,
port: parseInt(process.env.EMAIL_SERVER_PORT || "1025"),
auth: null, // Mailhog doesn't require auth
},
from: process.env.EMAIL_FROM,
}),
],
pages: {
signIn: "/auth/signin",
verifyRequest: "/auth/verify-request", // <--- New Page we need to build
},
callbacks: {
async jwt({ token, user }) {
if (user) token.role = user.role
return token
},
async session({ session, token }) {
if (session?.user && token.role) {
session.user.role = token.role
}
return session
}
}
})We need a new action to handle the "Sign in with Email" form submission.
File: lab7/app/lib/actions.ts
Add this new function to your existing file.
// ... existing imports
export async function authenticateEmail(formData: FormData) {
const email = formData.get("email")
await signIn("nodemailer", { email, redirect: false })
// Note: We use redirect:false here usually to handle the UI manually,
// but for simplicity in this lab, Auth.js will redirect to the 'verifyRequest' page automatically.
}We need to add an "Email" input field specifically for the Magic Link flow.
File: lab7/app/auth/signin/page.tsx
Replace the previous credentials form with this "Magic Link" form (or add it alongside). Here, I will focus on the Magic Link UI.
"use client"
import { authenticateGithub, authenticateEmail } from "@/app/lib/actions"
export default function SignInPage() {
return (
<div className="flex min-h-screen items-center justify-center bg-slate-100">
<div className="w-full max-w-md space-y-8 rounded-lg bg-white p-10 shadow-xl">
<div className="text-center">
<h2 className="text-3xl font-bold text-slate-900">Passwordless Login</h2>
<p className="mt-2 text-sm text-slate-600">Lab 7: Magic Links</p>
</div>
{/* GITHUB */}
<form action={authenticateGithub}>
<button type="submit" className="w-full bg-black text-white p-3 rounded font-bold">
Sign in with GitHub
</button>
</form>
<div className="relative my-6">
<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="bg-white px-2 text-gray-500">Or via Email</span></div>
</div>
{/* MAGIC LINK FORM */}
<form action={authenticateEmail} className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700">Email address</label>
<input
name="email"
type="email"
required
className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 shadow-sm focus:border-indigo-500 focus:ring-indigo-500"
placeholder="you@example.com"
/>
</div>
<button
type="submit"
className="w-full flex justify-center rounded-md bg-indigo-600 px-4 py-3 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500"
>
Send Magic Link
</button>
</form>
</div>
</div>
)
}When the user clicks "Send Magic Link", Auth.js redirects them to a page telling them to check their email. We need to create that page.
File: lab7/app/auth/verify-request/page.tsx
(Create folder verify-request inside auth)
import Link from "next/link"
export default function VerifyRequestPage() {
return (
<div className="flex min-h-screen items-center justify-center bg-slate-50">
<div className="w-full max-w-md text-center p-8 bg-white rounded shadow border border-green-200">
<h1 className="text-3xl font-bold text-green-700 mb-4">Check your email</h1>
<p className="text-lg text-slate-700 mb-6">
A sign-in link has been sent to your email address.
</p>
<div className="text-sm text-slate-500 bg-slate-100 p-4 rounded">
<strong>Lab Hint:</strong> Since we are in Docker, go to <br/>
<a href="http://localhost:8025" target="_blank" className="text-blue-600 underline">
http://localhost:8025
</a> <br/>
to view the fake email.
</div>
<div className="mt-8">
<Link href="/" className="text-sm text-slate-400 hover:text-slate-600">
← Back to Home
</Link>
</div>
</div>
</div>
)
}bash
docker-compose down
docker-compose up --build
http://localhost:3000.magic@test.com) and click Send Magic Link.http://localhost:8025 (This is Mailhog).localhost:3000 and logged in as magic@test.com.Database Check:
In dev.db, you will now see a row in the VerificationToken table (created when you requested the email) and it was deleted (consumed) when you clicked the link. A User and Session row were created.
Final Lab (Lab 8):
We have mastered the frontend authentication. But if you are building an API, how do you protect a backend route (like /api/secret-data) so that curl requests fail unless they have a session?
This is the most critical security lab. Hiding a button on the frontend does not stop a hacker. A hacker can use tools like curl or Postman to send requests directly to your backend. You must check for a session on the server side.
lab7 folder and rename it to lab8.lab8 folder.We will create an endpoint that returns secret JSON data, but only if the user is logged in.
Create the path: app/api/secret/route.ts.
File: lab8/app/api/secret/route.ts
import { auth } from "@/auth";
import { NextResponse } from "next/server";
export async function GET(req: Request) {
// 1. Verify the session
const session = await auth();
// 2. If no session, kick them out immediately
if (!session || !session.user) {
return NextResponse.json(
{ error: "Unauthorized. You must be logged in." },
{ status: 401 }
);
}
// 3. If logged in, return the sensitive data
return NextResponse.json({
message: "Success! You are authenticated.",
user: session.user.email,
secretCode: "LAB8-TOP-SECRET-123",
timestamp: new Date().toISOString(),
});
}Server Actions are essentially API endpoints disguised as functions. We must secure them too. Let's create an action that simulates a "Delete Database" command, restricted to Admins only.
File: lab8/app/lib/actions.ts
(Append this to your existing file).
// ... existing imports
import { revalidatePath } from "next/cache";
export async function dangerousAdminAction() {
// 1. Check Session
const session = await auth();
// 2. Check Auth
if (!session || !session.user) {
return { success: false, message: "You are not logged in!" };
}
// 3. Check Role (RBAC)
if (session.user.role !== "admin") {
return { success: false, message: "Forbidden! Admins only." };
}
// 4. Perform Action
console.log(`USER ${session.user.email} PERFORMED ADMIN ACTION`);
return { success: true, message: "Operation successful. System updated." };
}We need a way to click buttons to test these API endpoints.
File: lab8/app/members/page.tsx
We will convert this to a Client Component briefly to handle the fetch calls easily for this demo.
"use client";
import { useState } from "react";
import { dangerousAdminAction } from "../lib/actions";
export default function MembersPage() {
const [apiResult, setApiResult] = useState<string>("Click button to fetch...");
const [actionResult, setActionResult] = useState<string>("");
// 1. Test the API Route
const callApi = async () => {
const res = await fetch("/api/secret");
const data = await res.json();
setApiResult(JSON.stringify(data, null, 2));
};
// 2. Test the Server Action
const callServerAction = async () => {
const result = await dangerousAdminAction();
setActionResult(result.message);
};
return (
<div className="p-8 space-y-8">
<h1 className="text-3xl font-bold">Lab 8: API Security</h1>
{/* TEST 1: API ROUTE */}
<div className="p-6 border rounded bg-white shadow">
<h2 className="font-bold text-xl mb-4">Test 1: Protected API Route</h2>
<p className="text-gray-600 mb-4">
This button fetches <code>/api/secret</code>.
</p>
<button
onClick={callApi}
className="bg-blue-600 text-white px-4 py-2 rounded hover:bg-blue-700"
>
Fetch Secret Data
</button>
<div className="mt-4 bg-slate-900 text-green-400 p-4 rounded font-mono text-sm whitespace-pre-wrap">
{apiResult}
</div>
</div>
{/* TEST 2: SERVER ACTION */}
<div className="p-6 border rounded bg-white shadow border-red-200">
<h2 className="font-bold text-xl mb-4 text-red-700">Test 2: Admin Server Action</h2>
<p className="text-gray-600 mb-4">
This triggers a server action protected by <code>role === 'admin'</code>.
</p>
<button
onClick={callServerAction}
className="bg-red-600 text-white px-4 py-2 rounded hover:bg-red-700"
>
Execute Admin Action
</button>
{actionResult && (
<p className="mt-4 font-bold text-lg">Result: {actionResult}</p>
)}
</div>
</div>
);
}bash
docker-compose up --build
http://localhost:3000."secretCode": "LAB8...".admin@example.com / password.We will try to access the data without using the browser.
bash
curl -i http://localhost:3000/api/secret
text
HTTP/1.1 401 Unauthorized
{"error":"Unauthorized. You must be logged in."}
This proves your API is secure. Even though curl can reach the server, Auth.js intercepts the request, checks for a session cookie (which curl doesn't have), and rejects it.You have completed the full Auth.js Masterclass.
You now have:
auth.ts.You can now keep these folders as reference templates. Whenever you start a new project, simply copy the lab8 folder (or whichever level of complexity you need) and you are ready to go!
Here are a few options for the closing paragraph, depending on how you want to frame the conclusion of your project.
Your Authentication Arsenal By completing these eight labs, you have built more than just a learning project; you have created a robust library of reusable authentication patterns. Whether you need a quick prototype with GitHub login, a secure internal dashboard with role-based access, or a passwordless consumer app, you now have the exact code templates to make it happen. These isolated environments serve as a permanent reference point, allowing you to copy, paste, and adapt proven security logic into any future Next.js application with confidence.
Beyond Localhost Authentication is often cited as the most difficult hurdle in web development, but you have now demystified the entire stack. You’ve moved from a static UI to a fully hardened, database-backed security architecture. While these labs run in Docker and SQLite for portability, the logic you’ve written is production-ready. Your next step is to take these patterns into the wild: swap SQLite for PostgreSQL, deploy to a live server, and build something secure for the real world. You have the foundation; now go build the house.
Conclusion This curriculum has provided a comprehensive deep dive into Auth.js v5. We successfully navigated the complexities of the Next.js App Router, implemented server-side session validation, managed database schemas with Prisma, and secured API endpoints against unauthorized access. With the completion of Lab 8, this repository now stands as a complete reference implementation for modern, secure full-stack development, demonstrating that robust security and good user experience can coexist seamlessly.
This guide explains how to add GitHub OAuth authentication to a basic Next.js application (like lab1) to create a secured application (like lab6).
npx create-next-app@latest).Install the necessary packages for authentication and database management.
npm install next-auth@beta @auth/prisma-adapter @prisma/client
npm install prisma --save-devInitialize Prisma:
bash
npx prisma init
Update prisma/schema.prisma:
Add the necessary models for Auth.js (User, Account, Session, VerificationToken).
datasource db {
provider = "sqlite"
url = "file:./dev.db"
}
generator client {
provider = "prisma-client-js"
}
model User {
id String @id @default(cuid())
name String?
email String? @unique
emailVerified DateTime?
image String?
accounts Account[]
sessions Session[]
}
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])
}
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])
}Generate Prisma Client:
bash
npx prisma generate
auth.ts)Create a file named auth.ts in your root directory (or src if using it).
import NextAuth from "next-auth"
import GitHub from "next-auth/providers/github"
import { PrismaAdapter } from "@auth/prisma-adapter"
import { prisma } from "@/lib/prisma" // Ensure you have a prisma client instance exported from here
export const { handlers, signIn, signOut, auth } = NextAuth({
adapter: PrismaAdapter(prisma),
providers: [
GitHub({
clientId: process.env.AUTH_GITHUB_ID,
clientSecret: process.env.AUTH_GITHUB_SECRET,
}),
],
})Create app/api/auth/[...nextauth]/route.ts to handle authentication requests.
import { handlers } from "@/auth" // Import from your auth.ts
export const { GET, POST } = handlersmiddleware.ts)Create middleware.ts in your root directory to protect routes.
import { auth } from "@/auth"
export default auth((req) => {
const isLoggedIn = !!req.auth
// Protect specific routes
if (req.nextUrl.pathname.startsWith('/protected') && !isLoggedIn) {
return Response.redirect(new URL('/api/auth/signin', req.nextUrl))
}
})
export const config = {
matcher: ["/((?!api|_next/static|_next/image|favicon.ico).*)"],
}.env)Create or update your .env file with the following keys:
AUTH_SECRET="your_generated_secret" # Run `npx auth secret` to generate
AUTH_GITHUB_ID="your_github_client_id"
AUTH_GITHUB_SECRET="your_github_client_secret"You can use the default sign-in page provided by Auth.js, or create a custom one.
To use a custom page:
1. Update auth.ts to include pages: { signIn: '/auth/signin' }.
2. Create app/auth/signin/page.tsx.
3. Use Server Actions to handle the sign-in process.
Example Server Action (lib/actions.ts):
"use server"
import { signIn } from "@/auth"
export async function authenticateGithub() {
await signIn("github")
}Example Button in Component:
import { authenticateGithub } from "@/lib/actions"
export function GitHubSignInButton() {
return (
<form action={authenticateGithub}>
<button type="submit">Sign in with GitHub</button>
</form>
)
}lab6gThis document explains how Google OAuth is implemented in this Next.js application.
next-auth (v5 beta): The core authentication library. It handles the OAuth protocol, session management, and callbacks.@auth/prisma-adapter: Connects Auth.js to your database using Prisma. It automatically saves user and session data to your database.@prisma/client & prisma: The ORM used to interact with the SQLite database.auth.ts: The heart of the authentication setup.
jwt and session) to customize the session object (e.g., adding user roles).handlers, signIn, signOut, and auth helpers..env: Stores sensitive environment variables (AUTH_GOOGLE_ID, AUTH_GOOGLE_SECRET, AUTH_SECRET). Never commit this file.
prisma/schema.prisma: Defines the database schema.
User: Stores user information (name, email, image, role).Account: Links the user to the OAuth provider (Google). Stores tokens.Session: Stores active session data for database-based sessions (though this app uses JWT strategy by default, the adapter supports both).app/api/auth/[...nextauth]/route.ts: The API route handler.
GET and POST handlers created in auth.ts./api/auth/signin, /api/auth/callback/google, /api/auth/signout, etc.middleware.ts: Runs on every request (except static files).
auth()./members) by redirecting unauthenticated users to the sign-in page.app/auth/signin/page.tsx: A custom sign-in page.
lib/actions.ts: Contains Server Actions.
authenticateGoogle(): Calls the signIn("google") function from auth.ts to start the OAuth flow.User Interaction:
/auth/signin and clicks "Sign in with Google".authenticateGoogle server action in lib/actions.ts.Redirect to Google:
signIn("google") redirects the user to Google's OAuth consent screen.Callback:
/api/auth/callback/google.User Creation/Update:
User and links an Account (Google).Account if not already linked.Session Creation:
jwt callback in auth.ts runs, allowing you to add custom data (like role) to the token.session callback runs, making that data available in the session object in your components.Final Redirect:
/members) or the home page.middleware.ts sees a valid session and allows access.This guide explains how to add Google OAuth authentication to a basic Next.js application (like lab1) to create a secured application (like lab6g).
npx create-next-app@latest).Install the necessary packages for authentication and database management.
npm install next-auth@beta @auth/prisma-adapter @prisma/client
npm install prisma --save-devInitialize Prisma:
bash
npx prisma init
Update prisma/schema.prisma:
Add the necessary models for Auth.js (User, Account, Session, VerificationToken).
datasource db {
provider = "sqlite"
url = "file:./dev.db"
}
generator client {
provider = "prisma-client-js"
}
model User {
id String @id @default(cuid())
name String?
email String? @unique
emailVerified DateTime?
image String?
accounts Account[]
sessions Session[]
}
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])
}
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])
}Generate Prisma Client:
bash
npx prisma generate
auth.ts)Create a file named auth.ts in your root directory (or src if using it).
import NextAuth from "next-auth"
import Google from "next-auth/providers/google"
import { PrismaAdapter } from "@auth/prisma-adapter"
import { prisma } from "@/lib/prisma" // Ensure you have a prisma client instance exported from here
export const { handlers, signIn, signOut, auth } = NextAuth({
adapter: PrismaAdapter(prisma),
providers: [
Google({
clientId: process.env.AUTH_GOOGLE_ID,
clientSecret: process.env.AUTH_GOOGLE_SECRET,
}),
],
})Create app/api/auth/[...nextauth]/route.ts to handle authentication requests.
import { handlers } from "@/auth" // Import from your auth.ts
export const { GET, POST } = handlersmiddleware.ts)Create middleware.ts in your root directory to protect routes.
import { auth } from "@/auth"
export default auth((req) => {
const isLoggedIn = !!req.auth
// Protect specific routes
if (req.nextUrl.pathname.startsWith('/protected') && !isLoggedIn) {
return Response.redirect(new URL('/api/auth/signin', req.nextUrl))
}
})
export const config = {
matcher: ["/((?!api|_next/static|_next/image|favicon.ico).*)"],
}.env)Create or update your .env file with the following keys:
AUTH_SECRET="your_generated_secret" # Run `npx auth secret` to generate
AUTH_GOOGLE_ID="your_google_client_id"
AUTH_GOOGLE_SECRET="your_google_client_secret"You can use the default sign-in page provided by Auth.js, or create a custom one.
To use a custom page:
1. Update auth.ts to include pages: { signIn: '/auth/signin' }.
2. Create app/auth/signin/page.tsx.
3. Use Server Actions to handle the sign-in process.
Example Server Action (lib/actions.ts):
"use server"
import { signIn } from "@/auth"
export async function authenticateGoogle() {
await signIn("google")
}Example Button in Component:
import { authenticateGoogle } from "@/lib/actions"
export function GoogleSignInButton() {
return (
<form action={authenticateGoogle}>
<button type="submit">Sign in with Google</button>
</form>
)
}