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
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
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
bcryptjs Verifies the Legacy Hash — No Special Configuration
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
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
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.js | Django | Laravel | Rails | Astro | Express | |
|---|---|---|---|---|---|---|
| Verifying the legacy hash | Bespoke bcryptjs.compare() | Reordered PASSWORD_HASHERS | Zero config — default hasher already bcrypt | bcrypt gem, a real $2a$-vs-$2y$ nuance | Same bcryptjs package, unchanged | Same bcryptjs package, a third time |
| Session model | JWT — no server-side record | Server-side session | Server-side session | Server-side session | JWT — same as Next.js | Server-side (express-session) |
| Revoking a session early | Needs a deliberate blacklist strategy | One server-side call | One server-side call | One server-side call | Needs the same blacklist strategy | One server-side call |
Hands-On Exercises
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 solutionAdd 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 solutionExplain, 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 solutionChapter 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 newAdminmodel bcryptjs.compare()— verifies the PHP-generated hash correctly, no special configurationauth()inupdatePageTitle— 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