Security 12 min read Markdown

Laravel Authorization Done Right: Gates, Policies, and the Holes Teams Leave

Authentication proves who a user is. Authorization decides what they are allowed to touch. Most Laravel apps get the first part right and then load /orders/123 without ever checking the order belongs to the logged-in user. Here is how Gates and Policies actually work, and where the access-control holes hide.

Matt King
Matt King
June 30, 2026
Last updated: June 30, 2026
Laravel Authorization Done Right: Gates, Policies, and the Holes Teams Leave

Your Laravel app has authentication. Login works, sessions are signed, passwords are hashed with bcrypt. You might think you are covered, but authentication only answers one question: who is this person? It says nothing about what they are allowed to do once they are inside.

That second question is authorization, and it is where most real breaches happen. A user logs in as themselves, then changes a number in a URL and reads an invoice that belongs to someone else. No password was cracked. No token was stolen. The app simply never checked ownership.

This is the gap between the front door and the filing cabinet. Below is how Laravel's authorization tools actually work, where teams leave holes, and how to close each one with code you can run today.


Authentication is not authorization

The two words sound similar and get conflated constantly, which is part of the problem.

Authentication verifies identity. The auth middleware, Sanctum tokens, session cookies, two-factor codes: all of that proves you are who you claim to be.

Authorization verifies permission. Given that we know who you are, are you allowed to view, edit or delete this specific thing?

The OWASP Top 10 ranks Broken Access Control as A01, the single highest risk category. It overtook injection years ago, and authorization failures are the bulk of it. Within that category, the most common pattern has a name: Insecure Direct Object Reference, or in the API security world, Broken Object Level Authorization.

It looks like this. A route /orders/{order} resolves to an Order model through route model binding. The controller loads order 123, returns it, and never asks whether order 123 belongs to the person making the request. Increment the ID, and you are reading a stranger's order. The authentication layer waved this request through because the user is logged in. Nobody checked whether they should see that row.

If you want the wider context on where this sits among the other risks, the OWASP Top 10 for Laravel walks through all ten categories with Laravel-specific examples.


Gates versus Policies, and when to use each

Laravel gives you two primitives for authorization. They overlap, but they have distinct sweet spots.

A Gate is a closure registered against an ability name. It suits checks that are standalone or not bound to a particular model instance.

// app/Providers/AppServiceProvider.php
use Illuminate\Support\Facades\Gate;

public function boot(): void
{
    Gate::define('access-admin', function (User $user) {
        return $user->is_admin;
    });

    Gate::define('view-billing', function (User $user) {
        return $user->hasRole('owner') || $user->hasRole('finance');
    });
}

A Policy is a class that collects every authorization rule for one model. Generate one with php artisan make:policy OrderPolicy --model=Order and you get method stubs that map to actions.

// app/Policies/OrderPolicy.php
namespace App\Policies;

use App\Models\Order;
use App\Models\User;

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

    public function update(User $user, Order $order): bool
    {
        return $user->id === $order->user_id;
    }

    public function delete(User $user, Order $order): bool
    {
        return $user->id === $order->user_id;
    }
}

In Laravel 11 and 12, policies are auto-discovered: an OrderPolicy in app/Policies is matched to the Order model by convention, so the explicit $policies array of older versions is gone. You only register a mapping manually when your naming breaks the convention.

The rule of thumb is simple. If the check needs a model instance to answer the question, use a Policy. Anything to do with ownership, draft state, or a record's relationships lives there. If the check is about the user alone, a Gate is lighter. Whether someone can reach the admin panel does not require loading a model, so a Gate fits.


The enforcement points

Defining a rule does nothing on its own. Something has to call it. Laravel offers several call sites, and each one protects a different slice of the app.

// In a controller method (throws AuthorizationException -> 403)
$this->authorize('update', $order);

// The static facade, same effect, works anywhere
Gate::authorize('update', $order);

// As a route middleware
Route::put('/orders/{order}', [OrderController::class, 'update'])
    ->middleware('can:update,order');

// Boolean check in PHP, no exception thrown
if ($user->can('update', $order)) {
    // ...
}

// In a Blade view
@can('update', $order)
    <a href="{{ route('orders.edit', $order) }}">Edit</a>
@endcan

Gate::authorize arrived as the recommended controller-agnostic helper, and it behaves identically to $this->authorize from the AuthorizesRequests trait. Both throw a 403 when the check fails. The can variants return a boolean and never throw, which makes them right for hiding UI but wrong as the only guard on a destructive action.

Here is the part teams miss. These call sites do not all cover the same ground.

Enforcement point What it protects What it does NOT protect
$this->authorize() / Gate::authorize() in a controller The single controller action where it is called Any other controller, command, or job that touches the model
can: route middleware The exact route it is attached to Other routes hitting the same controller; anything bypassing the router
@can in Blade Whether the UI element renders The actual request, since the endpoint still runs without a server-side check
$user->can() in code Only the branch you wrap in the if Everything outside that conditional
Policy before() hook Centralised admin override Becomes a liability if it returns true too broadly

The recurring lesson: @can in a template is a convenience, not a control. Hiding the Edit button does not stop a crafted POST request. The server-side authorize call is the real guard.


The holes teams actually leave

These are the patterns that turn up in audits over and over. Each shows the vulnerable version first, then the fix.

1. Forgetting authorize() on update and destroy

The read path often gets attention while the write paths slip through, because the happy-path demo never exercises them with a hostile user.

// VULNERABLE
// app/Http/Controllers/OrderController.php
public function update(Request $request, Order $order)
{
    $order->update($request->validated());

    return redirect()->route('orders.show', $order);
}

Route model binding loaded $order by ID. No ownership check runs, so any authenticated user can update any order by guessing the route.

// FIXED
public function update(Request $request, Order $order)
{
    $this->authorize('update', $order);

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

    return redirect()->route('orders.show', $order);
}

One line. It must sit before the mutation, and it must run on every write method, not just the showy ones.

2. API controllers that skip policies entirely

Web controllers sometimes get policies while the API resource controller that backs the mobile app gets none. Attackers love APIs precisely because the checks are thinner.

// VULNERABLE
// app/Http/Controllers/Api/InvoiceController.php
public function show(Invoice $invoice)
{
    return new InvoiceResource($invoice);
}

The JSON endpoint returns whatever invoice ID you ask for. Pair this with rate limits that are too generous and you have a scraping pipeline.

// FIXED
public function show(Invoice $invoice)
{
    Gate::authorize('view', $invoice);

    return new InvoiceResource($invoice);
}

Authorization and throttling are separate concerns, and both matter on APIs. For the throttling side, see Laravel API rate limiting and Sanctum.

3. Checking the action but not ownership inside the policy

This one is subtle because the policy exists and the controller calls it. The bug is in the rule itself.

// VULNERABLE
// app/Policies/DocumentPolicy.php
public function update(User $user, Document $document): bool
{
    // Only checks that the user has the role,
    // never that they own this document.
    return $user->hasRole('editor');
}

Every editor can now edit every document in the system, including ones from other tenants. The role check is necessary but not sufficient.

// FIXED
public function update(User $user, Document $document): bool
{
    return $user->hasRole('editor')
        && $user->team_id === $document->team_id;
}

The policy method receives the model for a reason. Use it. A role grants a capability; ownership or tenancy grants the scope.

4. A before() hook that masks other bugs

The admin shortcut is the most tempting trap in the entire authorization system.

// VULNERABLE
// app/Policies/OrderPolicy.php
public function before(User $user, string $ability): ?bool
{
    return $user->is_admin ? true : false;
}

Returning false for non-admins looks tidy, but it short-circuits every ability check for ordinary users, so the real view, update and delete methods never run. Worse, a regression in admin detection now silently grants everything.

// FIXED
public function before(User $user, string $ability): ?bool
{
    if ($user->is_admin) {
        return true;
    }

    return null; // fall through to the individual methods
}

Returning null is the difference between a clean override and an accidental allow-all. The non-admin path must defer to the ability methods, not deny everything blindly. Keep the admin condition as narrow as you can; a single overbroad before() can erase the protection of an entire policy.

5. Nested resources where the child is not scoped to the parent

Nested routes like /projects/{project}/tasks/{task} invite a specific mistake: you authorize the project, then load the task by its own global ID.

// VULNERABLE
// app/Http/Controllers/TaskController.php
public function show(Project $project, Task $task)
{
    $this->authorize('view', $project);

    // $task is loaded by its own id, unrelated to $project.
    return view('tasks.show', compact('task'));
}

If you can view any project, you can append any task ID and read tasks that belong to projects you have no access to. The parent check gives a false sense of safety.

// FIXED — scope the child to the parent
public function show(Project $project, Task $task)
{
    $this->authorize('view', $project);

    abort_unless($task->project_id === $project->id, 404);

    return view('tasks.show', compact('task'));
}

Returning 404 rather than 403 here is deliberate: you avoid confirming that the task exists at all. Even better is to let the framework enforce the relationship for you, which is the next section.


Defensive patterns that scale

Spot-fixing controllers works, but it relies on every developer remembering every call site. These patterns shift the burden onto the framework.

Scoped route model binding

When a route nests one model inside another, Laravel can resolve the child through the parent's relationship instead of by global ID. That removes the entire class of bug from hole number five.

// routes/web.php
use Illuminate\Support\Facades\Route;

Route::get('/projects/{project}/tasks/{task}', [TaskController::class, 'show'])
    ->scopeBindings();

// Or apply it to a whole group
Route::scopeBindings()->group(function () {
    Route::resource('projects.tasks', TaskController::class);
});

With scopeBindings(), the {task} is resolved as $project->tasks()->where(...), so a task that does not belong to the project yields a 404 automatically. Laravel also enables this implicitly when the child binding uses a custom key, but stating it explicitly is clearer and survives refactors.

authorizeResource() in controllers

If your controller is a standard resource controller, you can wire every method to its policy ability in one line.

// app/Http/Controllers/OrderController.php
public function __construct()
{
    $this->authorizeResource(Order::class, 'order');
}

This maps index, show, create, store, edit, update and destroy to the matching policy methods. The win is structural: a developer adding a new method to the controller does not have to remember to call authorize, because the resource methods are already covered. It will not cover custom actions you add, so those still need an explicit call, but it eliminates the most common omission.

Write authorization tests

Code review catches some missing checks. Tests catch the rest, and they catch the regressions that creep back in six months later.

// tests/Feature/OrderAuthorizationTest.php
use App\Models\Order;
use App\Models\User;

test('owner can update their order', function () {
    $owner = User::factory()->create();
    $order = Order::factory()->for($owner)->create();

    $this->actingAs($owner)
        ->put("/orders/{$order->id}", ['status' => 'shipped'])
        ->assertRedirect();
});

test('a different user cannot update the order', function () {
    $owner = User::factory()->create();
    $stranger = User::factory()->create();
    $order = Order::factory()->for($owner)->create();

    $this->actingAs($stranger)
        ->put("/orders/{$order->id}", ['status' => 'shipped'])
        ->assertForbidden(); // 403
});

The second test is the one that earns its keep. It is the negative case, the one that fails loudly the moment someone deletes an authorize call. Write a pair like this for every endpoint that loads a record by ID, across web and API routes alike.

For a wider sweep of what to check beyond authorization, the Laravel security audit guide covers the surrounding configuration and dependency risks.


A quick decision guide

When you are staring at a controller and wondering what to add, this is the order to think in:

  • Does this action load a model by ID? If yes, it needs an ownership check, full stop.
  • Is the model nested under a parent? Use scopeBindings() so the child is resolved through the parent.
  • Is the check about the user alone, with no model? A Gate is enough.
  • Is the check about a specific record? A Policy method that uses the model instance is the right home.
  • Are you hiding a button with @can? Good for UX, but the server-side authorize still has to exist.

None of these tools are heavy. The reason holes appear is not that authorization is hard to write; it is that the missing check is invisible until someone goes looking for it. A controller that forgets authorize looks exactly like one that remembers, right up until the wrong person changes an ID in the URL.


The one thing to do this week

Pick every route that loads a record by ID and write the negative authorization test for it: a second user, the same record, an assertion of 403. That single habit converts silent access-control holes into red CI builds, which is where you want them.

If you would rather start with a map of where those holes already are, run a free StackShield scan and it will surface controllers that load models without an ownership check.

Free security check

Is your Laravel app exposed right now?

34% of Laravel apps we scan have at least one critical issue. Most teams don't find out until something breaks. Our free scan checks your live application in under 60 seconds.

18% have debug mode on
72% missing security headers
12% have exposed .env
Scan My App Free No signup required. Results in 60 seconds.

Frequently Asked Questions

What is the difference between a Gate and a Policy in Laravel?

A Gate is a closure registered in a service provider, keyed by an ability name like view-dashboard. It is a good fit for checks that are not tied to a specific Eloquent model, such as whether a user can access an admin area. A Policy is a dedicated class that groups all the authorization rules for one model, with methods like view, update and delete. Reach for a Policy whenever the question is can this user act on this particular record, because that is exactly where ownership checks belong.

Why does loading a record by ID cause a security hole?

Route model binding turns /orders/123 into an Order model automatically, but binding only fetches the row, it does not check who owns it. If the controller acts on that model without an authorization call, any authenticated user can change the ID in the URL and read or modify someone else's data. This is Insecure Direct Object Reference, also called Broken Object Level Authorization, and it is the most common form of OWASP A01. The fix is an explicit authorize call or a query scoped to the current user.

Does the can middleware on a route replace policy checks in the controller?

Only for the specific route it guards. The can middleware runs the matching policy method before the controller, which is genuinely useful, but it protects that single route and nothing else. If another controller, an API endpoint, an Artisan command or a queued job touches the same model, the middleware never runs. Treat middleware as one layer rather than the whole defence, and keep the authorize call close to the action that changes data.

What does a before hook do in a Laravel Policy?

A before method on a policy runs ahead of every ability check for that policy. If it returns true, every ability is granted; if it returns null, the individual methods decide. Teams often use it to give administrators a blanket pass, which is convenient but dangerous, because a true return short-circuits every other check and can hide bugs in the methods underneath. Return null rather than false for the non-admin case so the real ability methods still get to run.

How do I test that authorization actually works?

Write a feature test for every endpoint that loads a record by ID, and assert two things: the owner gets a 200, and a different authenticated user gets a 403. Laravel makes this easy with actingAs and factories, so each test is a few lines. These negative tests are the ones that catch a missing authorize call before it ships. A StackShield scan can flag controllers that load models without an ownership check, but a test suite is what stops the regression from coming back.

Stay Updated on Laravel Security

Get actionable security tips, vulnerability alerts, and best practices for Laravel apps.