Authentication

Chapter 8 — Authentication | Next.js Course
Next.js Rebuild Course Chapter 8 of 10

Authentication with NextAuth.js

osztromok.com has one admin — you. No OAuth, no user registrations, no password reset emails. Just a username + password backed by bcrypt, a secure session cookie, and middleware that protects the entire (admin) route group.

🏁

Chapter milestone: Visiting /admin without a session redirects to /login. A correct username/password sets a signed JWT cookie and redirects back. Server Components and Server Actions can both read the session. An incorrect password returns a clear error message.

Why credentials-only auth for a personal site

OAuth providers (Google, GitHub…)

  • Ideal for multi-user apps where anyone should sign up
  • Requires registering an OAuth app for every provider
  • Callback URLs need updating on every domain change
  • Overkill when there's exactly one admin

Credentials provider ✓ ours

  • Username + password stored in .env — no DB table needed
  • Password hashed with bcrypt — never stored in plain text
  • Single admin, so no multi-user complexity
  • Session stored as a signed JWT cookie — stateless
NextAuth.js v5 (Auth.js) — this chapter uses the latest major version, also called Auth.js. The package name changed from next-auth (v4) to the beta next-auth@5 which has first-class App Router support. The API is significantly cleaner: one auth.ts config file, auth() replaces getServerSession(), and middleware is one line.

Install packages and generate secrets

Install NextAuth.js v5 + bcrypt Terminal
# NextAuth.js v5 (App Router native)
npm install next-auth@beta

# bcryptjs — pure JS bcrypt, no native binaries to compile
npm install bcryptjs
npm install -D @types/bcryptjs
Generate AUTH_SECRET and hash your password Terminal
# Generate a cryptographically random AUTH_SECRET (required by NextAuth)
npx auth secret
# Prints something like: AUTH_SECRET="abc123...64chars"
# Copy this into your .env.local

# Hash your admin password — run this once, copy the output
node -e "
const bcrypt = require('bcryptjs');
bcrypt.hash('your-password-here', 12).then(h => console.log(h));
"
# Output: $2a$12$randomsalt...  ← copy this into .env.local as ADMIN_PASSWORD_HASH
.env.local — add auth variables Environment
# Generated by: npx auth secret
AUTH_SECRET="your-64-char-random-string"

# Admin credentials — never the plain text password
ADMIN_USERNAME="admin"
ADMIN_PASSWORD_HASH="$2a$12$your-bcrypt-hash-here"

# Where to redirect after login (optional, defaults to /)
NEXTAUTH_URL="http://localhost:3000"

auth.ts — the single source of truth

In NextAuth v5 everything lives in one auth.ts file at the project root. It exports auth (read the session), signIn, signOut, and handlers (the API route). You import from @/auth everywhere.

auth.ts — project root (same level as app/) TypeScript · NextAuth v5 config
import NextAuth             from 'next-auth'
import Credentials         from 'next-auth/providers/credentials'
import { compare }          from 'bcryptjs'
import { z }                 from 'zod'

// ── Validate the login form fields with Zod ────────────────────
const LoginSchema = z.object({
  username: z.string().min(1),
  password: z.string().min(1),
})

export const { handlers, auth, signIn, signOut } = NextAuth({
  // ── Session strategy ─────────────────────────────────────────
  // JWT: session data stored in an encrypted cookie — no DB table needed.
  session: { strategy: 'jwt' },

  // ── Custom pages ──────────────────────────────────────────────
  pages: {
    signIn: '/login',   // redirect here instead of NextAuth's default page
  },

  // ── Callbacks — shape the JWT and session ────────────────────
  callbacks: {
    jwt({ token, user }) {
      // On sign-in, user is populated. Add the role to the token.
      if (user) token.role = user.role
      return token
    },
    session({ session, token }) {
      // Expose role in session.user so components can read it.
      if (token.role) session.user.role = token.role as string
      return session
    },
  },

  // ── Providers ────────────────────────────────────────────────
  providers: [
    Credentials({
      credentials: {
        username: { label: 'Username', type: 'text'     },
        password: { label: 'Password', type: 'password' },
      },

      async authorize(credentials) {
        // Validate shape first
        const parsed = LoginSchema.safeParse(credentials)
        if (!parsed.success) return null

        const { username, password } = parsed.data

        // Check username
        if (username !== process.env.ADMIN_USERNAME) return null

        // Compare password against stored hash — timing-safe
        const hash = process.env.ADMIN_PASSWORD_HASH ?? ''
        const valid = await compare(password, hash)
        if (!valid) return null

        // Return the user object — this becomes token.user in the JWT callback
        return { id: '1', name: 'Admin', email: '', role: 'admin' }
      },
    }),
  ],
})
Return null from authorize(), never throw. Returning null tells NextAuth the credentials were wrong — it redirects back to the login page with an ?error=CredentialsSignin param. Throwing an error shows a generic crash page instead.

Extending the Session and JWT types

By default session.user only has name, email, and image. We added a role field — TypeScript doesn't know about it yet. Use module augmentation to extend the NextAuth types:

types/next-auth.d.ts — augment NextAuth types TypeScript · Declaration file
import 'next-auth'
import 'next-auth/jwt'

declare module 'next-auth' {
  interface User {
    role?: string
  }
  interface Session {
    user: User & {
      name?:  string | null
      email?: string | null
      role?:  string
    }
  }
}

declare module 'next-auth/jwt' {
  interface JWT {
    role?: string
  }
}

The NextAuth API route handler

NextAuth needs a catch-all Route Handler to process sign-in, sign-out, and callback requests. In v5 this is just three lines:

app/api/auth/[...nextauth]/route.ts TypeScript · Route Handler
import { handlers } from '@/auth'

export const { GET, POST } = handlers

// That's it. NextAuth handles:
//   GET  /api/auth/session        → returns current session as JSON
//   POST /api/auth/signin         → processes credentials sign-in
//   POST /api/auth/signout        → clears the session cookie
//   GET  /api/auth/csrf           → returns CSRF token

middleware.ts — protecting /admin in one place

We started a basic middleware in Chapter 7 for API token checks. Now we replace it with the NextAuth middleware that guards the entire (admin) route group. Any visitor without a valid session is redirected to /login automatically.

middleware.ts — replace the Chapter 7 version TypeScript · Edge Runtime
import { auth }         from '@/auth'
import { NextResponse } from 'next/server'

export default auth((req) => {
  const { pathname } = req.nextUrl
  const isLoggedIn   = !!req.auth   // req.auth is the session, null if not logged in

  // ── Protect all /admin routes ─────────────────────────────────
  if (pathname.startsWith('/admin') && !isLoggedIn) {
    const loginUrl = new URL('/login', req.url)
    loginUrl.searchParams.set('callbackUrl', pathname)
    return NextResponse.redirect(loginUrl)
  }

  // ── Redirect logged-in users away from /login ─────────────────
  if (pathname === '/login' && isLoggedIn) {
    return NextResponse.redirect(new URL('/admin', req.url))
  }

  // ── Protect admin API routes ──────────────────────────────────
  if (pathname.startsWith('/api/admin') && !isLoggedIn) {
    return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
  }
})

export const config = {
  // Run middleware on all routes except static assets
  matcher: ['/((?!_next/static|_next/image|favicon.ico|resources).*)'],
}
The callbackUrl param makes the login flow feel polished. If a user bookmarks /admin/pages/edit/42 and their session expires, they'll land on /login?callbackUrl=/admin/pages/edit/42. After signing in, NextAuth reads this param and redirects them straight back. No need to implement this yourself — NextAuth handles it automatically.

app/login/page.tsx — the login form

actions/auth.ts — sign in / sign out Server Actions TypeScript · Server Actions
'use server'

import { signIn, signOut } from '@/auth'
import { AuthError }       from 'next-auth'

export async function loginAction(formData: FormData) {
  try {
    await signIn('credentials', {
      username:    formData.get('username'),
      password:    formData.get('password'),
      redirectTo:  formData.get('callbackUrl') as string || '/admin',
    })
  } catch (err) {
    // NextAuth throws a redirect internally — let those through!
    if (err instanceof AuthError) {
      switch (err.type) {
        case 'CredentialsSignin':
          return { error: 'Invalid username or password.' }
        default:
          return { error: 'Something went wrong. Please try again.' }
      }
    }
    throw err  // re-throw the redirect so NextAuth can process it
  }
}

export async function logoutAction() {
  await signOut({ redirectTo: '/' })
}
app/login/page.tsx — login page (Server Component + Client form) TypeScript
import type { Metadata } from 'next'
import LoginForm from './LoginForm'

export const metadata: Metadata = { title: 'Login' }

export default function LoginPage({
  searchParams,
}: {
  searchParams: { callbackUrl?: string; error?: string }
}) {
  return (
    <div className="flex min-h-[70vh] items-center justify-center px-4">
      <div className="w-full max-w-sm">
        <h1 className="text-2xl font-bold text-site-text mb-2 text-center">Admin login</h1>
        <p className="text-sm text-site-dim text-center mb-8">osztromok.com</p>
        <LoginForm
          callbackUrl={searchParams.callbackUrl}
          urlError={searchParams.error}
        />
      </div>
    </div>
  )
}
app/login/LoginForm.tsx — Client Component for the form TypeScript · Client Component
'use client'

import { useFormState, useFormStatus } from 'react-dom'
import { loginAction } from '@/actions/auth'

function SubmitButton() {
  const { pending } = useFormStatus()
  return (
    <button
      type="submit"
      disabled={pending}
      className="w-full badge-accent py-2 rounded text-sm font-semibold
                 disabled:opacity-50 transition-opacity"
    >
      {pending ? 'Signing in…' : 'Sign in'}
    </button>
  )
}

interface Props { callbackUrl?: string; urlError?: string }

export default function LoginForm({ callbackUrl, urlError }: Props) {
  const [state, formAction] = useFormState(loginAction, null)

  const errorMsg =
    state?.error ||
    (urlError === 'CredentialsSignin' ? 'Invalid username or password.' : null)

  return (
    <form action={formAction} className="card p-6 space-y-4">
      <input type="hidden" name="callbackUrl" value={callbackUrl ?? '/admin'} />

      {errorMsg && (
        <div className="bg-red-500/10 border border-red-500/30 rounded px-3 py-2
                      text-sm text-red-400 text-center">
          {errorMsg}
        </div>
      )}

      <div>
        <label className="block text-sm text-site-muted mb-1">Username</label>
        <input
          name="username"
          type="text"
          autoComplete="username"
          className="w-full bg-site-deep border border-site-border rounded px-3 py-2
                     text-sm text-site-text focus:outline-none focus:border-site-accent"
          required
        />
      </div>

      <div>
        <label className="block text-sm text-site-muted mb-1">Password</label>
        <input
          name="password"
          type="password"
          autoComplete="current-password"
          className="w-full bg-site-deep border border-site-border rounded px-3 py-2
                     text-sm text-site-text focus:outline-none focus:border-site-accent"
          required
        />
      </div>

      <SubmitButton />
    </form>
  )
}

Reading the session everywhere it's needed

The auth() function from @/auth returns the current session in any context — Server Components, Route Handlers, and Server Actions. The method differs slightly depending on context.

Context How to read the session Notes
Server Component const session = await auth() Returns null if not logged in. No hooks needed.
Route Handler const session = await auth() Same call — works in any async server context.
Server Action const session = await auth() Always re-check inside actions — never trust the client to pass the role.
Client Component const { data } = useSession() Requires wrapping root layout with <SessionProvider>. Use sparingly — prefer passing session data as props from Server Components.
Middleware req.auth Available when using auth() as the middleware function (as we do).
app/(admin)/layout.tsx — admin layout reads session, shows logout TypeScript · Server Component
import { auth }          from '@/auth'
import { redirect }      from 'next/navigation'
import { logoutAction }  from '@/actions/auth'

export default async function AdminLayout({ children }: { children: React.ReactNode }) {
  const session = await auth()

  // Belt-and-suspenders check — middleware should have handled this already
  if (!session) redirect('/login')

  return (
    <div className="min-h-screen">
      {/* Admin top bar */}
      <header className="bg-site-deep border-b border-site-border px-6 py-3
                          flex items-center justify-between">
        <span className="text-sm font-semibold text-site-text">
          Admin — osztromok.com
        </span>
        <div className="flex items-center gap-4">
          <span className="text-xs text-site-dim">
            {session.user.name}
          </span>
          <form action={logoutAction}>
            <button
              type="submit"
              className="text-xs text-site-dim hover:text-site-text transition-colors"
            >
              Sign out
            </button>
          </form>
        </div>
      </header>

      <main className="max-w-site mx-auto px-6 py-8">
        {children}
      </main>
    </div>
  )
}
Guarding a Server Action with a session check TypeScript · Server Action
'use server'

import { auth }    from '@/auth'
import { prisma }  from '@/lib/prisma'
import { PageContentSchema } from '@/lib/schemas'

export async function deletePageContent(id: number) {
  // Always re-verify auth inside every sensitive Server Action.
  // Never rely on the admin layout redirect alone —
  // a race condition or direct POST could bypass the UI check.
  const session = await auth()
  if (!session || session.user.role !== 'admin') {
    throw new Error('Unauthorized')
  }

  await prisma.pageContent.delete({ where: { id } })
}
Always check auth inside every sensitive Server Action — even if your middleware already blocks unauthenticated users from the admin UI. A Server Action is ultimately a POST to an internal URL; it's possible to call it directly. The session check inside the action is your real security boundary.

The complete auth flow end-to-end

Visit /adminNo session cookie
MiddlewareRedirects to /login?callbackUrl=/admin
Login pageUser enters credentials
loginActionCalls signIn('credentials')
authorize()bcrypt.compare() ✓
JWT cookie setRedirect to /admin
/adminsession found ✓
Wrong password
authorize()returns null
AuthErrortype: CredentialsSignin
loginActionreturns { error: '…' }
LoginFormshows error inline

Test every auth scenario

Visiting /admin unauthenticated redirects to /login

Open an incognito window, go to http://localhost:3000/admin. You should land on /login?callbackUrl=/admin. The URL bar should show the callbackUrl param.

Wrong password shows an inline error

On the login page, enter any wrong password. The red error box should appear inside the form — no page reload, no redirect to an error page.

Correct password redirects back to /admin

Enter the correct credentials. You should land on /admin. The admin top bar should show "Admin" and a "Sign out" button.

Visiting /login while logged in redirects to /admin

While logged in, manually navigate to /login. Middleware should redirect you straight to /admin.

Sign out clears the session

Click "Sign out". You should be redirected to /. Attempting to visit /admin again should redirect back to login.

Direct call to a guarded Server Action throws

Temporarily call deletePageContent(1) from a public page without a session. You should see an "Unauthorized" error caught by the error.tsx boundary — not a silent success.

Up next — Chapter 9: The Admin CRUD UI. With auth in place, we build the admin pages: subject/subtopic/page management, a TinyMCE rich-text editor wired to the saveContentSection Server Action, and drag-and-drop position reordering. This is where the content management loop closes.