# 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.

**Author:** Matt King | **Published:** June 30, 2026 | **Category:** Security

---

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](/blog/owasp-top-10-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.

```php
// 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.

```php
// 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.

```php
// 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.

```php
// 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.

```php
// 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.

```php
// 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.

```php
// 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](/blog/laravel-api-security-rate-limiting-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.

```php
// 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.

```php
// 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.

```php
// 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.

```php
// 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.

```php
// 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.

```php
// 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.

```php
// 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.

```php
// 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.

```php
// 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](/blog/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](/free-scan) and it will surface controllers that load models without an ownership check.

---

## 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.

