Admin Authentication

Website Rebuild with Next.js

Chapter 9 · Admin Authentication

Chapter 8 left updatePageTitle genuinely callable by anyone, deliberately. This chapter closes that gap for real — and, just as importantly, migrates the legacy PHP site's own existing admin credential rather than forcing a reset.

The Judgment Call: Auth.js, Not Better Auth

Named directly, not silently decided
As of this course's own version-currency research (Chapter 1), NextAuth.js — rebranded Auth.js — is in maintenance mode, and the official documentation now points new projects at Better Auth instead. This chapter stays on Auth.js anyway: five of this course's own already-complete siblings (Django, Laravel, Rails, Astro, Express) each directly quote specific implementation details from this exact chapter — the authorize() callback shape, the bcryptjs comparison, the JWT-vs-session distinction. Switching to Better Auth here wouldn't just be a fresh choice; it would quietly invalidate five already-published cross-references. Series-wide consistency wins this specific tradeoff, named honestly rather than assumed.

Migrating the Legacy Admin Credential

// prisma/schema.prisma — new model model Admin { id Int @id @default(autoincrement()) email String @unique passwordHash String }

The legacy PHP site's own admin credential — a bcrypt hash generated by PHP's password_hash(), tagged $2y$ — is copied directly into passwordHash during migration. No password reset, no re-entry, no coordination with the site's own admin required.

Installing and Configuring NextAuth

npm install next-auth@beta bcryptjs
// auth.ts import NextAuth from 'next-auth'; import Credentials from 'next-auth/providers/credentials'; import bcrypt from 'bcryptjs'; import { prisma } from '@/lib/prisma'; export const { handlers, auth, signIn, signOut } = NextAuth({ providers: [ Credentials({ credentials: { email: {}, password: {} }, authorize: async (credentials) => { const admin = await prisma.admin.findUnique({ where: { email: credentials.email as string }, }); if (!admin) return null; const valid = await bcrypt.compare( credentials.password as string, admin.passwordHash ); if (!valid) return null; return { id: admin.id.toString(), email: admin.email }; }, }), ], });
// app/api/auth/[...nextauth]/route.ts import { handlers } from '@/auth'; export const { GET, POST } = handlers;

bcryptjs Verifies the Legacy Hash — No Special Configuration

A real interoperability fact, not an assumption
bcrypt.compare() above correctly verifies the legacy $2y$-tagged hash with zero special handling — the bcrypt algorithm itself is standardized enough across implementations that a hash generated by PHP's password_hash() verifies correctly through the JavaScript bcryptjs package, unchanged. This same confirmation is repeated twice more later in this series, by Astro Rebuild's own Chapter 9 and Express Rebuild's own Chapter 9 — both using this exact same bcryptjs package, since all three share the same Node.js/npm ecosystem.

Closing Chapter 8's Gap

// app/actions.ts — updated from Chapter 8 'use server'; import { prisma } from '@/lib/prisma'; import { updateTag } from 'next/cache'; import { auth } from '@/auth'; export async function updatePageTitle(pageId: number, newTitle: string) { const session = await auth(); if (!session) { throw new Error('Unauthorized'); } const page = await prisma.page.update({ where: { id: pageId }, data: { title: newTitle }, }); updateTag(`page:${page.fullPath}`); }

One new line at the very top of the function — the exploit demonstrated back in Chapter 8's own Exercise 1 no longer works.

JWT Sessions: A Real Structural Tradeoff

A Credentials provider forces this — it isn't a free choice
NextAuth's Credentials provider only supports the JWT session strategy — a signed token stored in a cookie, not a server-side session record in the database. This means there's no single row to delete for an instant, guaranteed revocation; a compromised token stays technically valid until its own expiry, unless a deliberate token-blacklist strategy is added on top. Laravel Rebuild's own Chapter 9 names this directly as a real point of contrast: its own session()->invalidate() and Django's own logout() both close a session in one server-side call, something this chapter's own JWT-based approach genuinely can't do as simply.

Password Hashing, Compared Across the Series

Next.jsDjangoLaravelRailsAstroExpress
Verifying the legacy hashBespoke bcryptjs.compare()Reordered PASSWORD_HASHERSZero config — default hasher already bcryptbcrypt gem, a real $2a$-vs-$2y$ nuanceSame bcryptjs package, unchangedSame bcryptjs package, a third time
Session modelJWT — no server-side recordServer-side sessionServer-side sessionServer-side sessionJWT — same as Next.jsServer-side (express-session)
Revoking a session earlyNeeds a deliberate blacklist strategyOne server-side callOne server-side callOne server-side callNeeds the same blacklist strategyOne server-side call

Hands-On Exercises

Exercise 1

Configure the Admin model, the Credentials provider, and the API route from this chapter, then confirm signing in with the legacy $2y$-tagged bcrypt hash succeeds with no special configuration for the hash format.

📄 View solution
Exercise 2

Add the auth() check to updatePageTitle, then repeat Chapter 8's own Exercise 1 exploit attempt with no session present, and confirm it now fails with the Unauthorized error.

📄 View solution
Exercise 3

Explain, in your own words, why this chapter's own JWT-based session can't be revoked with a single server-side call the way Django's logout() or Laravel's session()->invalidate() can — and what would need to be built to close that gap.

📄 View solution

Chapter 9 Quick Reference

  • Auth.js over Better Auth — a named tradeoff, chosen for consistency with five already-published sibling chapters
  • Legacy credential migrated, not reset — the existing $2y$-tagged bcrypt hash, copied as-is into a new Admin model
  • bcryptjs.compare() — verifies the PHP-generated hash correctly, no special configuration
  • auth() in updatePageTitle — Chapter 8's own gap, closed in one added check
  • JWT sessions — forced by the Credentials provider; no single-call revocation, unlike every server-side-session sibling
  • Next chapter: Admin CRUD Interface