How I Handle Authentication and Authorization in Laravel Apps - hero image showing Laravel access control with policies, gates, and roles

How I Handle Authentication & Authorization in Laravel Apps

Rahmat Ullah profile photoRahmat Ullah
10 min readEngineering Insights, Laravel, Security

Most Laravel apps don't have security problems on day one. They have them after users, roles, and edge cases start piling up.

The first version of access control is always clean. One user role. Two endpoints. A Auth::check() here, an if ($user->is_admin) there, and the app ships. Six months later there are five roles, two are deprecated but still in the database, the admin check has been copy-pasted into eleven controllers, and a junior dev just opened a PR to add a sixth role. That is the moment a small bug becomes a CVE.

This is the fifth post in a Laravel series. I have written about the mistakes I stopped making, the structure I land on by default, performance work in production, and API design that does not break later. This post is the layer underneath all of them: who is allowed to do what, and how that decision stays correct as the app grows.

This is where most systems don't break in code, but in decisions.

The Real Problem

Authentication is easy. Authorization is where things break.

Knowing "who the user is" takes ten minutes and a users table. Knowing "what they are allowed to do" is the hard part, and it is the part most teams underinvest in. Authentication is a yes/no question with one answer. Authorization is a graph: this user, on this resource, in this state, with this role, at this point in the workflow.

The bug I have seen ship the most often is not "the wrong user logged in." It is "the right user did the wrong thing because nobody told the controller no." Privilege creep is quiet. By the time it shows up in an incident report, the fix is a multi-week refactor.


Where Things Go Wrong

The same five patterns show up in almost every Laravel codebase I have inherited.

  • Hardcoded role checks. if ($user->role === 'admin') scattered through controllers. Adding a new role means grep-and-replace across the codebase, and missing one is a privilege escalation.
  • Authorization logic inside controllers. Five lines of permission math at the top of every method. Testable only by hitting the route. Drifts the moment two endpoints have "almost" the same rule.
  • No central permission system. Each feature invents its own. can_edit_orders lives in a column, view-reports lives in a config file, is_finance is a method on the User model. None of them know about each other.
  • Trusting the frontend. Hiding a button in Vue is not access control. The endpoint is the access control. If the route does not enforce it, it is not enforced.
  • Premature RBAC frameworks. Pulling in a full role/permission package on day one of a five-user MVP. The abstraction outpaces the requirement and slows every future change.

Every one of these is a reasonable mistake the first time. None of them is reasonable the third time.


How I Handle Authentication

For most projects today, the answer is surprisingly simple.

APIs and SPAs: Laravel Sanctum. Token-based for mobile and third-party clients, cookie-based for first-party SPAs on the same domain. Sanctum is small, well-documented, and does not pretend to be an OAuth server. If I genuinely need OAuth (third-party developers, public API), I reach for Passport. Otherwise Sanctum stays.

Server-rendered web apps: sessions. Laravel's built-in session guard. No extra dependency, CSRF handled by the framework, login throttling out of the box.

Multi-channel apps: both, deliberately separated. A web guard for the dashboard and an api guard for the mobile app, never sharing the same authentication path. Mixing them is how you end up with session fixation bugs in your token flow.

The choice is not about features. It is about matching the channel: cookies for browsers on your domain, tokens for everything else.


How I Handle Authorization (Where It Actually Matters)

This is where the design decisions actually matter.

Policies as the default

Every model that has access rules gets a Policy. The Policy is the only place that knows the rules. Controllers, jobs, console commands, and tests all call into the same Policy, so the rule cannot drift between entry points.

class OrderPolicy
{
    public function view(User $user, Order $order): bool
    {
        return $user->id === $order->user_id || $user->hasRole('admin');
    }

    public function update(User $user, Order $order): bool
    {
        return $this->view($user, $order) && $order->status !== 'shipped';
    }
}

Two rules in one place. Both reusable. Both testable without hitting an HTTP route.

Gates for ambient permissions

A Gate is the right tool when the check is not tied to a model. "Can view the admin dashboard." "Can export reports." "Can impersonate users." These are statements about the user, not about a resource.

Gate::define('access-admin', fn (User $user) => $user->hasRole('admin'));

I do not force a Policy onto a check that has no model. The shoehorn is worse than the inconsistency.

Roles and permissions, scaled to the app

For small apps with three or four fixed roles, a role column on users and a hasRole() method is enough. No package, no junction tables, no overhead.

For anything more dynamic, where customers can define their own roles, where permissions need to be granted per-tenant, where the rule set grows monthly, I reach for spatie/laravel-permission. It is the well-trodden path and it integrates cleanly with Policies.

The decision is not "use a package or don't." It is: start with the simplest thing that works, and graduate when the requirements actually demand it. I have seen far more apps suffer from premature RBAC than from missing it.


A Real Example

A B2B order management endpoint. Rules: a user can view their own order. An admin can view any order. Once an order is shipped, nobody but a tenant admin can update it.

Three layers, each doing one job.

// Controller: thin, delegates the decision
public function update(UpdateOrderRequest $request, Order $order)
{
    $this->authorize('update', $order);

    $order->update($request->validated());

    return new OrderResource($order);
}
// Policy: owns the rule
public function update(User $user, Order $order): bool
{
    if ($order->status === 'shipped') {
        return $user->hasRole('tenant-admin');
    }

    return $user->id === $order->user_id || $user->hasRole('admin');
}
// Test: the rule is testable on its own
public function test_owner_cannot_update_shipped_order(): void
{
    $user  = User::factory()->create();
    $order = Order::factory()->shipped()->for($user)->create();

    $this->assertFalse($user->can('update', $order));
}

The controller does not know the rule. The Policy does not know the route. The test does not need either. When the rule changes (and it always does), exactly one file moves.


What I Avoid Now

  • Hardcoding role checks in controllers. Even one. The first one becomes the template.
  • Scattering permission logic across services and jobs. Every entry point asks the Policy. No exceptions.
  • Treating frontend hiding as security. A hidden button is UX. A 403 from the API is access control.
  • Adopting a permission package before there is a permission problem. Two roles do not need a permissions table.
  • Letting authorization leak into business logic. A service should not check if ($user->is_admin). The Policy decided already; the service runs the operation.

Key Principles I Follow

  • Authentication identifies. Authorization decides. Two questions, two layers, two places in the code.
  • One rule, one place. If the same check exists in two files, one of them is wrong.
  • Never trust the frontend. Every protected route is enforced server-side, every time.
  • Start simple, graduate when forced. Roles in a column scale further than people admit. RBAC frameworks are powerful and expensive; do not pay until you must.
  • Test the rules, not the routes. A Policy test is fast, isolated, and catches the bug at the layer that owns it.

Closing Thoughts

Authentication tells you who the user is. Authorization decides what they are allowed to do. Mixing the two is where most problems start, and centralizing access logic in Policies is what keeps Laravel access control sane as the app grows.

Role based access control in Laravel is not about adopting the most powerful package. It is about putting every decision in one place, naming that place clearly, and making sure no other layer of the app gets a vote. Get that right and "we have a permissions bug" stops being a sentence anyone says in your standup.

Bad authentication logs the wrong user in.
Bad authorization lets the right user do the wrong thing.

How do you handle authorization in your Laravel apps?