Option 1: The "Zero-to-Hero" Overview

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.

Option 2: The "Educational Philosophy" Intro (Best for a tutorial/blog)

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.

Option 3: The Technical Abstract (Short & Concise)

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?

Lab 1: The Static Foundation.

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.

Step 1: Initialize the Project

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 lab1

Step 2: Docker Configuration

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

Step 3: The Application Code

We 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>
  );
}

Step 4: Run the Lab

  1. Ensure Docker Desktop is running.
  2. In your terminal (inside lab1): bash docker-compose up
  3. Open your browser to http://localhost:3000.

Validation Checklist:


Lab 1.

Create a folder named lab1. Inside that folder, create the files as listed below.

1. Configuration Files (The Root)

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: {},
  },
};

2. Docker Setup (The Infrastructure)

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/.next

3. Application Code (The UI)

Create 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>
  );
}

How to Run

  1. Open your terminal in the lab1 folder.
  2. Run docker compose up.
  3. Wait for the initial npm install to finish inside the container.
  4. Visit 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?

Lab 2: Simple Authentication.

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.

Step 1: Setup the Folder

  1. Copy your entire lab1 folder and rename it to lab2.
  2. Open the lab2 folder.

Step 2: Update Configuration

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 THIS

Step 3: The Authentication Logic

Create 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
        }
      }
    })
  ],
})

Step 4: The API Route

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

Step 5: Middleware Protection

This 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).*)"],
}

Step 6: Update the UI

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>
  );
}

Step 7: Run Lab 2

Since we changed package.json (added next-auth), we must rebuild the Docker image.

  1. Open terminal in lab2.
  2. Run: bash docker-compose up --build
  3. Go to http://localhost:3000.

How to Test:

  1. Click Members. You should be instantly redirected to a login page (auto-generated by Auth.js).
  2. Try a fake password. It should fail.
  3. Login with:
  4. You should be redirected to the Members page and see your JSON session data.
  5. The Navbar should now say "Hi, J Smith" and show a "Sign Out" button.

Concept 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?

Lab 3: Database Persistence (SQLite & 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.

Step 1: Setup the Folder

  1. Copy your lab2 folder and rename it to lab3.
  2. Open the lab3 folder.

Step 2: Install Dependencies

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"
  }
}

Step 3: The Database Schema

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])
}

Step 4: The Prisma Client Instance

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

Step 5: Update Auth Logic (auth.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;
      },
    }),
  ],
})

Step 6: Docker Configuration Update

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


Step 7: Run Lab 3

  1. Rebuild is mandatory because we added dependencies and changed the Dockerfile.
  2. Open terminal in lab3.
  3. Run: bash docker-compose down docker-compose up --build

What to expect:

  1. Watch the terminal logs. You will see Prisma schema loaded from prisma/schema.prisma and The database is now in sync with your schema.
  2. Open http://localhost:3000.
  3. Go to Members. You will be redirected to Sign In.
  4. Enter ANY email (e.g., admin@example.com) and the password password.
  5. Magic: Because of our logic in auth.ts, since this user didn't exist, the code created it in SQLite immediately and logged you in.
  6. If you look in your 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?

Lab 4: Adding 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.

Step 1: Setup the Folder

  1. Copy your lab3 folder and rename it to lab4.
  2. Open the lab4 folder.

Step 2: Get GitHub Credentials

You cannot code this part; you must configure it on GitHub.

  1. Log in to your GitHub account.
  2. Go to Settings (click your profile icon top-right) -> Developer settings (at the very bottom left) -> OAuth Apps.
  3. Click New OAuth App.
  4. Fill in the form:
  5. Click Register application.
  6. Copy the Client ID.
  7. Click Generate a new client secret and Copy the Client Secret.

Step 3: Update Docker Configuration

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_here

Step 4: Update Auth Logic

We 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;
      },
    }),
  ],
})

Step 5: Run Lab 4

  1. Rebuild (Environment variables changed). bash docker-compose up --build
  2. Open http://localhost:3000.
  3. Click Members (or Sign In).

What to expect:

  1. You will now see two buttons on the login page:
  2. Click Sign in with GitHub.
  3. You will be redirected to GitHub.com to authorize the app.
  4. Once you agree, you are redirected back to your Members page.
  5. Look at the JSON output on the Members page.

Database Check: If you check the dev.db (using a SQLite viewer or Prisma Studio), you will see:

  1. A new User row (with your GitHub image).
  2. A new Account row. This row contains the provider: "github" and your specific providerAccountId from GitHub. This is how Auth.js knows it's you next time.

Ready for the Advanced Labs?

You have completed the "Standard Stack" (Next.js + Prisma + SQLite + OAuth).

Which one would you like to do next?

Lab 5: Role-Based Access Control (RBAC).

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:

  1. Database: We need to store the role (e.g., "admin" or "user").
  2. Session: By default, the session cookie only contains name, email, and image. It does not know about your database roles. We must "inject" the role into the session.
  3. TypeScript: Next.js is strict. If you try to type session.user.role, it will yell at you because that property doesn't exist on the default type definition.

Step 1: Setup the Folder

  1. Copy your lab4 folder and rename it to lab5.
  2. Open the lab5 folder.

Step 2: Update the Database Schema

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 same

Step 3: TypeScript Definition (Crucial)

This 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
  }
}

Step 4: Update Auth Logic

We need to do two things here:

  1. Logic: When creating a new user in our "Credentials Mock Logic", assign the "admin" role if the email is admin@example.com.
  2. Callbacks: Pass the role from the Database -> Token -> Session.

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
    }
  }
})

Step 5: Create the Admin Dashboard

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>
  );
}

Step 6: Update Navbar

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>
  );
}

Step 7: Run Lab 5

  1. Rebuild: (We changed Schema and Dockerfile needs to run prisma generate again). bash docker-compose down docker-compose up --build
  2. Open http://localhost:3000.

Testing the Role Logic:

  1. Test as Regular User:

  2. Test as Admin:


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?

Lab 6: Custom Login UI.

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

Step 1: Setup the Folder

  1. Copy your lab5 folder and rename it to lab6.
  2. Open the lab6 folder.

Step 2: Update Auth Configuration

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
    }
  }
})

Step 3: Create Server Actions for Login

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")
}

Step 4: Create the Custom Page (The UI)

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>
  )
}

Step 5: Run Lab 6

  1. Rebuild: (We modified auth.ts and added server actions). bash docker-compose down docker-compose up --build
  2. Open http://localhost:3000.
  3. Click Members.

What to expect:

  1. Instead of the plain white default page, you are redirected to your new /auth/signin page.
  2. It should look professional with a centered card, a dark GitHub button, and a styled form.
  3. Test Failure: Enter a wrong password. The page should not reload, but the text "❌ Invalid credentials." should appear below the inputs (thanks to useFormState).
  4. Test Success: Enter 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!

Lab 7: Magic Links (Passwordless Auth).

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.

Step 1: Setup the Folder

  1. Copy your lab6 folder and rename it to lab7.
  2. Open the lab7 folder.

Step 2: Install Dependencies

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
  },

Step 3: Update Docker Infrastructure

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)

Step 4: Update Auth Configuration

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
    }
  }
})

Step 5: Update Server Actions

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

Step 6: Update the Login UI

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>
  )
}

Step 7: Create the "Verify Request" Page

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">
            &larr; Back to Home
          </Link>
        </div>
      </div>
    </div>
  )
}

Step 8: Run Lab 7

  1. Rebuild: (Mandatory, we added a new Docker service). bash docker-compose down docker-compose up --build
  2. The Flow:
  3. The "Magic":

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?

Lab 8: Protecting API Routes & Server Actions.

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.

Step 1: Setup the Folder

  1. Copy your lab7 folder and rename it to lab8.
  2. Open the lab8 folder.

Step 2: Create a Protected API Route

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(),
  });
}

Step 3: Create a Protected Server Action

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." };
}

Step 4: The Tester UI

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>
  );
}

Step 5: Run Lab 8

  1. Rebuild: bash docker-compose up --build
  2. Open http://localhost:3000.

Step 6: The Security Tests

Test A: The Happy Path (Logged In)

  1. Log in using GitHub (Role: User) or Magic Link (Role: User).
  2. Click Fetch Secret Data.
  3. Click Execute Admin Action.

Test B: The Admin Path

  1. Log out.
  2. Sign in with Credentials: admin@example.com / password.
  3. Click Execute Admin Action.

Test C: The Hacker Path (Critical)

We will try to access the data without using the browser.

  1. Open a new terminal window (leave Docker running).
  2. Run this command: bash curl -i http://localhost:3000/api/secret
  3. Result: 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.

Congratulations!

You have completed the full Auth.js Masterclass.

You now have:

  1. Lab 1: A solid Next.js + Tailwind UI structure.
  2. Lab 2: Core authentication logic using auth.ts.
  3. Lab 3: Database persistence with Prisma & SQLite.
  4. Lab 4: Social Login (GitHub).
  5. Lab 5: Role-Based Access Control (Admins vs Users).
  6. Lab 6: Fully custom, branded login pages.
  7. Lab 7: Passwordless Magic Links (via Email).
  8. Lab 8: Hardened Backend Security (APIs & Actions).

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.

Option 1: The "Toolkit" Outro (Best for a README)

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.

Option 2: The "Next Steps" Outro (Inspirational)

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.

Option 3: The Technical Summary (Concise)

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.


UPDATES GITHUB OAUTH

How to Add GitHub OAuth to a Next.js Application

This guide explains how to add GitHub OAuth authentication to a basic Next.js application (like lab1) to create a secured application (like lab6).

Prerequisites

Step 1: Install Dependencies

Install the necessary packages for authentication and database management.

npm install next-auth@beta @auth/prisma-adapter @prisma/client
npm install prisma --save-dev

Step 2: Configure the Database (Prisma)

  1. Initialize Prisma: bash npx prisma init

  2. 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])
    }
  3. Generate Prisma Client: bash npx prisma generate

Step 3: Configure Auth.js (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,
    }),
  ],
})

Step 4: Create API Route

Create app/api/auth/[...nextauth]/route.ts to handle authentication requests.

import { handlers } from "@/auth" // Import from your auth.ts
export const { GET, POST } = handlers

Step 5: Add Middleware (middleware.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).*)"],
}

Step 6: Environment Variables (.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"

Step 7: Implement Sign-In (Optional Custom Page)

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>
  )
}

UPDATES GOOGLE OAUTH

HOW IT WORKS

Google OAuth Implementation in lab6g

This document explains how Google OAuth is implemented in this Next.js application.

1. Key Libraries

2. Key Files and Folders

Configuration

Database

API Routes

Middleware

UI & Actions

3. The Authentication Flow

  1. User Interaction:

  2. Redirect to Google:

  3. Callback:

  4. User Creation/Update:

  5. Session Creation:

  6. Final Redirect:

How to Add Google OAuth to a Next.js Application

This guide explains how to add Google OAuth authentication to a basic Next.js application (like lab1) to create a secured application (like lab6g).

Prerequisites

Step 1: Install Dependencies

Install the necessary packages for authentication and database management.

npm install next-auth@beta @auth/prisma-adapter @prisma/client
npm install prisma --save-dev

Step 2: Configure the Database (Prisma)

  1. Initialize Prisma: bash npx prisma init

  2. 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])
    }
  3. Generate Prisma Client: bash npx prisma generate

Step 3: Configure Auth.js (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,
    }),
  ],
})

Step 4: Create API Route

Create app/api/auth/[...nextauth]/route.ts to handle authentication requests.

import { handlers } from "@/auth" // Import from your auth.ts
export const { GET, POST } = handlers

Step 5: Add Middleware (middleware.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).*)"],
}

Step 6: Environment Variables (.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"

Step 7: Implement Sign-In (Optional Custom Page)

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>
  )
}