Skip to content
Lesson 13 of 22

Authentication and Authorization

13 min read

Why Auth is Hard

Authentication is one of the most complex parts of any web application. It seems simple on the surface — users log in, users see their stuff — but underneath there's a maze of security concerns:

  • Session management — How do you keep users logged in across page loads without making it easy for attackers to hijack sessions?
  • Token security — JWTs can be stolen from localStorage. Cookies need proper flags (HttpOnly, Secure, SameSite).
  • Password hashing — You never store plain text passwords. You hash them with bcrypt or Argon2, using salt to prevent rainbow table attacks.
  • OAuth flows — Redirecting to Google, receiving callbacks, exchanging codes for tokens, handling edge cases when providers are down.
  • CSRF protection — Preventing other websites from making requests on behalf of your logged-in users.
  • Rate limiting — Stopping brute-force login attempts before someone guesses a password.

The good news: you don't need to implement any of this from scratch. Libraries like NextAuth.js and services like Clerk handle the hard parts. AI helps you configure them correctly. Your job is understanding what each piece does so you can make good decisions and prompt AI effectively.

Authentication vs. Authorization

These two terms sound similar but mean very different things.

Authentication answers: "Who are you?" It's the login process — verifying identity through passwords, OAuth, magic links, or biometrics.

Authorization answers: "What can you do?" It's the permissions system — determining which resources and actions a user can access based on their role, plan, or other attributes.

A practical example: when you log into a project management app (authentication), you can see your own projects but not other people's private projects (authorization). An admin user can see all projects and delete any of them (different authorization level, same authentication system).

Both are essential. A system with authentication but no authorization lets every logged-in user do everything. A system with authorization but no authentication can't verify who's making the request.

NextAuth.js Setup and Configuration

NextAuth.js (now called Auth.js) is the most popular authentication library for Next.js. It handles OAuth providers, sessions, callbacks, and security — you just configure it.

Installation

npm install next-auth@beta

The Auth Configuration File

Create your auth configuration:

// auth.ts (root of project)
import NextAuth from "next-auth"
import Google from "next-auth/providers/google"
import GitHub from "next-auth/providers/github"

export const { handlers, auth, signIn, signOut } = NextAuth({
  providers: [
    Google({
      clientId: process.env.AUTH_GOOGLE_ID!,
      clientSecret: process.env.AUTH_GOOGLE_SECRET!,
    }),
    GitHub({
      clientId: process.env.AUTH_GITHUB_ID!,
      clientSecret: process.env.AUTH_GITHUB_SECRET!,
    }),
  ],
  callbacks: {
    authorized({ auth, request: { nextUrl } }) {
      const isLoggedIn = !!auth?.user
      const isProtected = nextUrl.pathname.startsWith("/dashboard")
      if (isProtected && !isLoggedIn) {
        return Response.redirect(new URL("/login", nextUrl))
      }
      return true
    },
  },
})

API Route Handler

// app/api/auth/[...nextauth]/route.ts
import { handlers } from "@/auth"

export const { GET, POST } = handlers

Environment Variables

# .env.local
AUTH_SECRET="generate-a-random-32-char-string"  # npx auth secret
AUTH_GOOGLE_ID="your-google-client-id"
AUTH_GOOGLE_SECRET="your-google-client-secret"
AUTH_GITHUB_ID="your-github-client-id"
AUTH_GITHUB_SECRET="your-github-client-secret"

The AUTH_SECRET is used to encrypt session tokens. Generate it with npx auth secret or any random string generator. Never reuse it across environments.

Implementing OAuth Login

OAuth lets users log in with existing accounts (Google, GitHub, etc.) instead of creating new credentials. It's more secure (no passwords to store) and more convenient (one click to sign in).

Google Provider Step-by-Step

  1. Create credentials in the Google Cloud Console:

    • Go to APIs & Services > Credentials
    • Create an OAuth 2.0 Client ID
    • Set the application type to "Web application"
    • Add authorized redirect URI: http://localhost:3000/api/auth/callback/google
  2. Add credentials to .env.local:

    AUTH_GOOGLE_ID="123456789.apps.googleusercontent.com"
    AUTH_GOOGLE_SECRET="GOCSPX-your-secret-here"
    
  3. Create a sign-in button:

// components/sign-in-button.tsx
import { signIn } from "@/auth"

export function SignInButton() {
  return (
    <form
      action={async () => {
        "use server"
        await signIn("google", { redirectTo: "/dashboard" })
      }}
    >
      <button
        type="submit"
        className="flex items-center gap-2 px-4 py-2 bg-white border rounded-lg hover:bg-gray-50"
      >
        <GoogleIcon className="w-5 h-5" />
        Sign in with Google
      </button>
    </form>
  )
}

The flow: User clicks button → redirected to Google → user approves → Google redirects back with a code → NextAuth exchanges the code for a token → session is created → user is redirected to /dashboard.

Prompt: "Set up Google OAuth login with NextAuth.js. Create the auth config, API route, sign-in button, and sign-out button. Redirect to /dashboard after login."

Email/Password Authentication

Sometimes you need traditional email/password login. NextAuth supports this through the Credentials provider, but it comes with caveats — you're responsible for password security.

import Credentials from "next-auth/providers/credentials"
import bcrypt from "bcryptjs"

export const { handlers, auth, signIn, signOut } = NextAuth({
  providers: [
    Credentials({
      credentials: {
        email: { label: "Email", type: "email" },
        password: { label: "Password", type: "password" },
      },
      async authorize(credentials) {
        const user = await db.user.findUnique({
          where: { email: credentials.email as string },
        })

        if (!user) return null

        const passwordMatch = await bcrypt.compare(
          credentials.password as string,
          user.password
        )

        if (!passwordMatch) return null

        return { id: user.id, email: user.email, name: user.name }
      },
    }),
  ],
  session: { strategy: "jwt" },
})

Registration Flow

The registration endpoint hashes the password before storing it:

// app/api/auth/register/route.ts
import bcrypt from "bcryptjs"
import { z } from "zod"

const RegisterSchema = z.object({
  name: z.string().min(2),
  email: z.string().email(),
  password: z.string().min(8)
    .regex(/[A-Z]/, "Must contain an uppercase letter")
    .regex(/[0-9]/, "Must contain a number"),
})

export async function POST(request: Request) {
  const body = await request.json()
  const result = RegisterSchema.safeParse(body)

  if (!result.success) {
    return NextResponse.json(
      { error: result.error.flatten() },
      { status: 400 }
    )
  }

  const existingUser = await db.user.findUnique({
    where: { email: result.data.email },
  })

  if (existingUser) {
    return NextResponse.json(
      { error: "Email already registered" },
      { status: 409 }
    )
  }

  const hashedPassword = await bcrypt.hash(result.data.password, 12)

  const user = await db.user.create({
    data: {
      name: result.data.name,
      email: result.data.email,
      password: hashedPassword,
    },
  })

  return NextResponse.json(
    { data: { id: user.id, email: user.email } },
    { status: 201 }
  )
}

The number 12 in bcrypt.hash(password, 12) is the cost factor — how many rounds of hashing to perform. Higher numbers are slower but more secure. Twelve is a good balance.

Session Management

Once a user is authenticated, you need to maintain their session — remembering who they are across requests.

JWT vs. Database Sessions

JWT (JSON Web Token): The session data is encoded in a token stored in a cookie. The server doesn't need to look up the session — the token contains the information. Fast, but harder to revoke.

Database sessions: The session is stored in your database. The cookie only contains a session ID. The server looks up the session on each request. Slower, but you can revoke sessions instantly.

For most apps, JWT is fine. Use database sessions if you need the ability to force-logout users or see active sessions.

Accessing Session Data

In Server Components:

import { auth } from "@/auth"

export default async function DashboardPage() {
  const session = await auth()

  if (!session?.user) {
    redirect("/login")
  }

  return <h1>Welcome, {session.user.name}</h1>
}

In Client Components:

"use client"

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

export function UserMenu() {
  const { data: session, status } = useSession()

  if (status === "loading") return <Skeleton />
  if (!session) return <SignInButton />

  return (
    <div className="flex items-center gap-2">
      <img src={session.user.image} className="w-8 h-8 rounded-full" />
      <span>{session.user.name}</span>
    </div>
  )
}

In API Routes:

import { auth } from "@/auth"

export async function GET() {
  const session = await auth()

  if (!session) {
    return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
  }

  // Proceed with authenticated logic...
}

Session Provider

Wrap your app's root layout to make sessions available to Client Components:

// app/layout.tsx
import { SessionProvider } from "next-auth/react"

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html>
      <body>
        <SessionProvider>{children}</SessionProvider>
      </body>
    </html>
  )
}

Protecting Routes and API Endpoints

Authentication is useless if users can bypass it by navigating directly to protected URLs.

Middleware-Based Protection

The most efficient approach — runs before the page renders:

// middleware.ts
import { auth } from "@/auth"

export default auth((req) => {
  const isLoggedIn = !!req.auth
  const isProtectedRoute = req.nextUrl.pathname.startsWith("/dashboard")
  const isAuthRoute = req.nextUrl.pathname.startsWith("/login")

  if (isProtectedRoute && !isLoggedIn) {
    return Response.redirect(new URL("/login", req.nextUrl))
  }

  if (isAuthRoute && isLoggedIn) {
    return Response.redirect(new URL("/dashboard", req.nextUrl))
  }
})

export const config = {
  matcher: ["/dashboard/:path*", "/login", "/register"],
}

This handles two cases: redirecting unauthenticated users to login, and redirecting already-authenticated users away from the login page.

Server-Side Protection

For individual pages that need extra checks:

import { auth } from "@/auth"
import { redirect } from "next/navigation"

export default async function SettingsPage() {
  const session = await auth()
  if (!session) redirect("/login")

  // Only reached by authenticated users
  return <SettingsForm user={session.user} />
}

API Route Protection

export async function DELETE(
  request: Request,
  { params }: { params: { id: string } }
) {
  const session = await auth()

  if (!session) {
    return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
  }

  // Verify the user owns this resource
  const post = await db.post.findUnique({ where: { id: params.id } })

  if (post?.authorId !== session.user.id) {
    return NextResponse.json({ error: "Forbidden" }, { status: 403 })
  }

  await db.post.delete({ where: { id: params.id } })
  return NextResponse.json({ success: true })
}

Note the difference between 401 (not logged in) and 403 (logged in but not allowed). This matters for the frontend to show the right message.

Role-Based Access Control

Most apps need more than just "logged in" or "not logged in." You need roles — admin, editor, viewer — that determine what each user can do.

Adding Roles to Your Data Model

enum Role {
  USER
  EDITOR
  ADMIN
}

model User {
  id    String @id @default(cuid())
  email String @unique
  name  String
  role  Role   @default(USER)
  // ... other fields
}

Extending the Session with Role

// auth.ts
export const { handlers, auth, signIn, signOut } = NextAuth({
  callbacks: {
    async session({ session, token }) {
      if (token.role) {
        session.user.role = token.role as Role
      }
      return session
    },
    async jwt({ token, user }) {
      if (user) {
        const dbUser = await db.user.findUnique({
          where: { email: user.email! },
        })
        token.role = dbUser?.role
      }
      return token
    },
  },
  // ... providers
})

Checking Roles in Middleware

export default auth((req) => {
  const isAdmin = req.auth?.user?.role === "ADMIN"
  const isAdminRoute = req.nextUrl.pathname.startsWith("/admin")

  if (isAdminRoute && !isAdmin) {
    return Response.redirect(new URL("/dashboard", req.nextUrl))
  }
})

Conditional UI Based on Role

import { auth } from "@/auth"

export default async function Sidebar() {
  const session = await auth()

  return (
    <nav>
      <Link href="/dashboard">Dashboard</Link>
      <Link href="/projects">Projects</Link>
      {session?.user?.role === "ADMIN" && (
        <Link href="/admin">Admin Panel</Link>
      )}
    </nav>
  )
}

Prompt: "Add role-based access control with USER, EDITOR, and ADMIN roles. Editors can create and edit posts. Admins can do everything including managing users. Protect the /admin routes for admins only."

Clerk as a Managed Alternative

If you want authentication without managing any of the infrastructure, Clerk is a managed service that handles everything — sign-up, sign-in, session management, user profiles, organizations, and more.

Why Choose Clerk

  • Pre-built components — Sign-in forms, user buttons, and profile pages that look professional out of the box
  • Managed infrastructure — No session tokens to configure, no OAuth credentials to manage (Clerk acts as the OAuth client)
  • Webhook integration — Sync user data to your database when events happen (user created, updated, deleted)
  • Zero auth code to write — You configure, not code

Setup Walkthrough

npm install @clerk/nextjs
// app/layout.tsx
import { ClerkProvider } from "@clerk/nextjs"

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <ClerkProvider>
      <html>
        <body>{children}</body>
      </html>
    </ClerkProvider>
  )
}
// middleware.ts
import { clerkMiddleware, createRouteMatcher } from "@clerk/nextjs/server"

const isProtectedRoute = createRouteMatcher(["/dashboard(.*)", "/api/protected(.*)"])

export default clerkMiddleware(async (auth, req) => {
  if (isProtectedRoute(req)) {
    await auth.protect()
  }
})

Built-In Components

import { SignInButton, SignedIn, SignedOut, UserButton } from "@clerk/nextjs"

export function Header() {
  return (
    <header className="flex justify-between items-center p-4">
      <Logo />
      <SignedOut>
        <SignInButton />
      </SignedOut>
      <SignedIn>
        <UserButton />
      </SignedIn>
    </header>
  )
}

That's it. No auth configuration file. No provider setup. No callback handlers. Clerk manages all of it.

When to Use Clerk vs. NextAuth

Choose Clerk when: you want to move fast, you don't want to manage auth infrastructure, you're fine with a third-party dependency, and your app doesn't need unusual auth flows.

Choose NextAuth when: you need full control over the auth flow, you want to self-host everything, you need custom providers, or you're building auth into an existing system.

Prompt: "Set up Clerk authentication with Google and GitHub login. Protect all /dashboard routes. Add a webhook endpoint that creates a user in my database when someone signs up through Clerk."

Common Auth Mistakes

These mistakes appear frequently in AI-generated auth code. Watch for them:

  1. Storing passwords in plain text — Always hash with bcrypt (cost factor 10-12) or Argon2. If you see password: input.password going directly to the database, that's a bug.

  2. Not validating tokens server-side — Never trust a JWT without verifying its signature. NextAuth handles this, but if you're building custom auth, this is critical.

  3. Exposing user IDs in client code — Internal database IDs should stay on the server. Use session tokens for client-server communication.

  4. Not rate-limiting login attempts — Without rate limiting, attackers can try millions of passwords. Add a rate limiter to your login endpoint.

  5. Trusting client-side auth checks — The useSession hook is for UI rendering (show/hide elements). It's not for security. Always verify sessions on the server before performing actions.

  6. Forgetting to protect API routes — Every API route that accesses user data should check the session. It's easy to add a new route and forget the auth check.

Prompt: "Review the auth implementation for security issues. Check for plain text passwords, missing server-side validation, unprotected API routes, and missing rate limiting."

Prompt Patterns for Auth

These prompts reliably generate solid auth implementations:

OAuth setup: "Add Google OAuth login using NextAuth.js. Create the auth config with JWT session strategy, the API route handler, a sign-in page with a Google button, and a sign-out button in the header."

Route protection: "Protect the /dashboard route — redirect to /login if not authenticated. Also redirect authenticated users away from /login to /dashboard."

Role-based access: "Add admin role and restrict /admin pages to admin users only. Show a 403 page if a non-admin tries to access admin routes. Add an admin check helper function."

Full auth system: "Implement complete authentication with NextAuth.js: Google and GitHub OAuth providers, email/password with registration, session management, protected routes, and role-based access control with USER and ADMIN roles."

What's Next

Your app now has secure authentication, protected routes, and role-based authorization. Users can sign in, and the system knows who they are and what they're allowed to do.

But how do you know everything works correctly? How do you prevent bugs from creeping in as you add features? In the next lesson, we'll dive into testing — letting AI write unit tests, integration tests, and end-to-end tests that give you confidence to ship without fear.