DEV Community

Cover image for I Rebuilt My Laravel Auth on Top of Fortify (and Two Bugs the Integration Caught)
Dmitry Isaenko
Dmitry Isaenko

Posted on

I Rebuilt My Laravel Auth on Top of Fortify (and Two Bugs the Integration Caught)

Last time I tagged v0.1.0 of LaraFoundry, the foundation layer of a Laravel SaaS core I'm extracting in public from a live CRM. Primitives only: locale, filters, middleware, a small Vue UI kit. No auth yet.

This post is v0.2.x: authentication and users. And it's the one where two bugs slipped past 114 passing tests and only showed up when I actually wired the package into a real app. More on those at the end, because honestly they were the most useful part.

The decision that mattered: don't hand-roll auth

The legacy CRM had its own auth. Six login methods, custom rate limiting, a hand-written throttle, an admin 2FA built on a single shared secret. It worked for years. But when you re-read that kind of code on the way out, you notice how much of it is security-critical plumbing that Laravel already solved properly.

So I made a call early: the core builds on Laravel Fortify, not on a port of the old controllers.

Fortify is the headless auth backend behind Jetstream. It gives you login, registration, password reset, email verification, password confirmation, and real per-user two-factor (TOTP + recovery codes + QR enrollment + a confirm step) out of the box. No views, just routes and actions. That last part is exactly what you want when your frontend is Inertia + Vue.

The rule I held to: if Fortify already does it, I don't write it. The core only adds what Fortify doesn't cover.

What the core actually adds around Fortify

Fortify is the boring, correct center. The package wraps it with the stuff that's actually mine:

  • Session tracking. One row per device with a fingerprint (browser, OS, type), IP, login method, last activity, last route. This is what powers an "active sessions" screen and "log out my other devices."
  • OAuth via Socialite. Fortify doesn't touch social login. The package owns the redirect/callback, with an account-takeover guard (more below).
  • Blocked / deleted gating. A per-request middleware that logs out an account that got blocked or soft-deleted mid-session.
  • A IsLaraFoundryUser trait. The host's User model uses it to pick up identity, locale, OAuth fields, 2FA and session tracking, without inheriting anything about companies or roles. Those are separate traits in later phases, so one model can compose them.
  • Localized auth mail. The verification and reset emails are owned by the core through translation keys, so they ship translated and follow the locale standard from v0.1.0 instead of hardcoding English.
  • The Inertia/Vue pages. Login, register, forgot/reset, verify, 2FA challenge, 2FA settings, a blocked screen. Fortify is headless, so the UI is ours.

The whole thing is covered with Pest (backend) and Vitest (the Vue pages and UI kit), green CI on PHP 8.2/8.3/8.4.

The OAuth guard worth talking about

Here's the original OAuth callback from the legacy app, simplified:

$user = User::updateOrCreate(
    ['email' => $socialUser->email],
    [/* provider fields, email_verified_at => now() */]
);
Auth::login($user);
Enter fullscreen mode Exit fullscreen mode

Looks fine. It's not.

If someone already has a local account at victim@example.com (registered with a password), and an attacker controls a Google account that asserts the same email, this updateOrCreate quietly links the attacker's provider to the victim's account and logs them in. Account takeover, no exploit needed, just an email collision.

The core resolves strictly instead:

  1. match by provider identity (provider_name + provider_id) first, that's the only unconditional match;
  2. if that misses but the email matches a local account, link only when link_existing is explicitly enabled, otherwise refuse and tell the user to sign in locally and connect the provider from settings;
  3. otherwise create a new account.

Default is refuse. A Pest test pins the takeover case so it can't regress.

(There's a second, narrower issue here I haven't fully closed: a brand-new OAuth account gets email_verified_at = now() trusting the provider's email. For Google/GitHub primary emails that's fine, but I noted it as a hardening task before enabling OAuth in prod. Honesty over polish.)

Now the two bugs

This is the part I actually wanted to write down, because both of them passed every unit test and only died when the package met a real Laravel app.

Bug 1: session tracking on the wrong event

My first design recorded the session row by listening to Laravel's Login event. Clean, framework-native, fires on every login path including registration. Tests were green.

Then I logged in on the real host and got immediately bounced back to the login screen.

Turns out the Login event fires inside the auth guard, and Fortify regenerates the session id again afterwards, in its PrepareAuthenticatedSession step. So my listener wrote a row keyed to a session id that was dead a millisecond later. Then my "is this session still valid" check saw a mismatch and logged the user out. Every single time.

The unit tests never caught it because in isolation there's no second regeneration. you need the actual Fortify pipeline for the timing to bite.

The fix was to stop chasing the event and move tracking into a per-request middleware that runs against the final, live session id. Which, it turns out, is exactly how the old legacy app did it. sometimes the boring old approach was boring for a reason. Bonus: it also revived last_activity and last_route_name, which my event-based version never updated after login.

Bug 2: views => false is a trap with Inertia

This one's quick but I lost a good half hour to it.

For an SPA you instinctively set 'views' => false in config/fortify.php. Makes sense, right? You don't want Fortify rendering Blade, your frontend handles screens.

Except views => false doesn't just skip the view rendering. it stops Fortify from registering the GET routes entirely. So GET /login returns 405, and your Fortify::loginView(...) closure never runs.

For Inertia the correct setup is 'views' => true plus a loginView that returns Inertia::render('Auth/Login'). Fortify owns the route, your closure owns what it renders. The docs do say this, I just pattern-matched "SPA = views off" and skipped past it.

The takeaway

114 tests gave me confidence the units were correct. They said nothing about whether the pieces fit together in a real app. The integration smoke test, just spinning up the host and clicking through register / login / 2FA by hand, is where both real bugs lived.

So the workflow stays: extract, modernize, test, review, tag, and then actually run it in a real app before believing any of it.

Next phase is multi-tenancy, lifting the company/team layer out of the same production system. That's the one I'm slightly afraid of.

Code is on GitHub. If you're building something similar on Fortify, the session-id timing thing will get you too, so now you know.

Top comments (0)