Admin Authentication

Website Rebuild with Laravel

Chapter 9 · Admin Authentication

Next.js Rebuild 9 wrote a bespoke bcryptjs.compare() call inside a NextAuth Credentials provider to match the existing site's own legacy bcrypt hash. Django Rebuild 9 had to reorder PASSWORD_HASHERS to add BCryptSHA256PasswordHasher, since Django's own default is PBKDF2, not bcrypt. Laravel needs neither workaround — its default hasher already is bcrypt.

Laravel's Default Hasher: Already Bcrypt

// config/hashing.php return [ 'driver' => 'bcrypt', // already the out-of-the-box default ... ];
This course's own standout "modernize in place" payoff
The legacy site's own admin credential is a bcrypt hash — the same algorithm PHP's own password_hash() has defaulted to for years. Laravel's Hash facade already defaults to that exact algorithm, with nothing to configure. Django needed a config change; Next.js needed a hand-written comparison function; Laravel needs neither — the framework's own out-of-the-box default already matches the legacy hash.

The Login Flow: Auth::attempt()

// app/Http/Controllers/AuthController.php class AuthController extends Controller { public function showLogin() { return view('admin.login'); } public function login(Request $request) { $credentials = $request->validate([ 'email' => 'required|email', 'password' => 'required', ]); if (Auth::attempt($credentials)) { $request->session()->regenerate(); return redirect()->intended('/admin'); } return back()->withErrors(['email' => 'Invalid credentials.']); } public function logout(Request $request) { Auth::logout(); $request->session()->invalidate(); $request->session()->regenerateToken(); return redirect('/'); } }

Auth::attempt($credentials) internally calls Hash::check($credentials['password'], $user->password) using whatever driver config/hashing.php names — bcrypt, by default. Since the legacy hash is itself bcrypt, that one built-in call is the entire authentication mechanism: no custom comparison function, no hasher class swap. session()->regenerate() issues a fresh session ID on login, guarding against session-fixation — the same reason Django Rebuild 9's own login flow rotated its session key.

A lighter parallel to Django's automatic hash upgrade
Django Rebuild 9's login flow quietly upgraded the stored hash to a stronger format on a successful login. Laravel offers the same idea manually via Hash::needsRehash($user->password) — checked and re-saved on login if the hash's own cost factor is out of date — but unlike Django, it isn't automatic by default; a route has to call it explicitly.

Protecting Chapter 8's Mutation — Closing the Gap

// routes/web.php Route::middleware('auth')->group(function () { Route::post('/admin/pages/{page}/title', [PageController::class, 'updateTitle']) ->name('pages.updateTitle'); });
// app/Http/Requests/UpdatePageTitleRequest.php public function authorize() { return Auth::check(); // Chapter 8's placeholder, now a real check }

Chapter 8's authorize() — deliberately left as a visible return true; placeholder — now returns Auth::check(). The auth middleware on the route provides one layer of protection; the FormRequest's own authorize() is a second, independent layer that fails the request with a 403 even if the route protection were ever misconfigured — the same defense-in-depth reasoning Django Rebuild 9's @staff_member_required decorator embodied for its own view.

Session-Based, Like Django — Not Like NextAuth's JWT

Laravel's default web guard is session-based — the same model Django's own session middleware already used. Next.js Rebuild 9's NextAuth setup, by contrast, issued a signed JWT stored in a cookie, with no server-side session record to invalidate directly. Laravel and Django both close a session in one server-side call (session()->invalidate() / Django's own logout()); revoking a NextAuth JWT before its own expiry needs a deliberate token-blacklist strategy that a plain session model doesn't.

Three Frameworks, Three Different Amounts of Work

Next.jsDjangoLaravel
Default hasherNone — hand-rolled Credentials providerPBKDF2Bcrypt
Work to match the legacy hashWrite a manual bcryptjs.compare() callAdd BCryptSHA256PasswordHasher to PASSWORD_HASHERSNone — the default already matches
Session modelSigned JWT cookieServer-side sessionServer-side session
No self-registration route
This is a single-admin site, not a multi-user application — there's deliberately no registration route or sign-up form. Only the one legacy admin credential, already present in the users table, can log in.

Hands-On Exercises

Exercise 1

Explain why Laravel needs no hasher-configuration change to authenticate against the legacy site's own bcrypt hash, while Django Rebuild 9 needed to add BCryptSHA256PasswordHasher to PASSWORD_HASHERS.

📄 View solution
Exercise 2

Explain what changed in UpdatePageTitleRequest::authorize() in this chapter, and why both the route's auth middleware and the FormRequest's own check together provide defense in depth rather than being redundant.

📄 View solution
Exercise 3

Explain why revoking a Next.js/NextAuth JWT session before its natural expiry is harder than ending a Laravel or Django session, and what a JWT-based approach would need to add to close that gap.

📄 View solution

Chapter 9 Quick Reference

  • Standout payoff — Laravel's default Hash driver is already bcrypt, matching the legacy hash with zero configuration
  • Auth::attempt($credentials) — validates the password against the stored hash using the configured driver, in one call
  • session()->regenerate() — issues a fresh session ID on login, guarding against session fixation
  • Hash::needsRehash() — Laravel's manual equivalent of Django's automatic hash-upgrade-on-login
  • Defense in depthauth middleware on the route, plus authorize() now returning Auth::check() on the FormRequest itself
  • Session-based — like Django, unlike Next.js's own JWT-cookie NextAuth setup
  • Next chapter: Admin CRUD Interface