Authentication and Authorization
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
-
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
-
Add credentials to
.env.local:AUTH_GOOGLE_ID="123456789.apps.googleusercontent.com" AUTH_GOOGLE_SECRET="GOCSPX-your-secret-here" -
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:
-
Storing passwords in plain text — Always hash with bcrypt (cost factor 10-12) or Argon2. If you see
password: input.passwordgoing directly to the database, that's a bug. -
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.
-
Exposing user IDs in client code — Internal database IDs should stay on the server. Use session tokens for client-server communication.
-
Not rate-limiting login attempts — Without rate limiting, attackers can try millions of passwords. Add a rate limiter to your login endpoint.
-
Trusting client-side auth checks — The
useSessionhook is for UI rendering (show/hide elements). It's not for security. Always verify sessions on the server before performing actions. -
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.