Skip to main content

Authorization — deciding who can do what with Gates and Policies

The previous chapter got users logging in. But anyone logged in can do anything.

This chapter decides who may do what to which resource. Along the way we build the order listing and detail endpoints. Each of those two exposes an authorization trap in a different form.

Passing authentication is not enough​

Write the order detail endpoint straightforwardly.

app/Http/Controllers/OrderController.php
public function show(Order $order)
{
return new OrderResource($order->load('items'));
}
routes/api.php
Route::middleware('auth:sanctum')->group(function () {
Route::post('/auth/logout', [AuthController::class, 'logout']);
Route::post('/orders', [OrderController::class, 'store']);
Route::get('/orders', [OrderController::class, 'index']); // ← added in this chapter
Route::get('/orders/{order}', [OrderController::class, 'show']); // ← added in this chapter
});

With auth:sanctum in place, a missing token is rejected with a 401. That much is the previous chapter.

The problem is that anyone with a token can fetch anyone's order.

GET /api/v1/orders/1 ← your own order
GET /api/v1/orders/2 ← someone else's. Also returns 200
GET /api/v1/orders/3 ← same

As chapter 4 said, with sequential ids, changing the number walks the whole set. Signing up is all it takes to read other members' order contents and email addresses.

Authentication only confirmed who you are. Whether you may see this is a separate decision.

Creating a Policy​

Laravel gathers permission decisions about a model into a class called a Policy.

php artisan make:policy OrderPolicy --model=Order

Adding --model=Order generates stubs for the typical methods1. We keep only the ones this series uses.

app/Policies/OrderPolicy.php
<?php

namespace App\Policies;

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

class OrderPolicy
{
/**
* Whether the order may be viewed
*/
public function view(User $user, Order $order): bool
{
return $user->id === $order->user_id;
}

/**
* Whether the order may be cancelled
*/
public function cancel(User $user, Order $order): bool
{
return $user->id === $order->user_id;
}
}

A method takes the user as the first argument and the target model as the second, and returns a bool1.

Do not put "cannot cancel once shipped" in the Policy

You will want to add && $order->status === OrderStatus::Pending to cancel. It works, but the code that comes back is 403.

403 means "you do not have permission to do this." What is actually happening is "this order has already shipped and cannot be changed," which has nothing to do with permission. Chapter 5's exercise said that writing the same thing in a Form Request's authorize() makes people misdiagnose the cause. The situation is identical in a Policy.

Split the decision by status code.

  • 403 is right (a permission problem) → the Policy
  • 409 is right (a state problem) → throw an exception in the service class and convert it to 409 with chapter 9's mechanism

A Policy looks only at ownership. State checks go on the processing side, in something like OrderCancellationService. Chapter 6 wrote Order::isCancellable() precisely so the model can answer that from its own data.

Registering a Policy​

Laravel finds Policies by naming convention. app/Policies/OrderPolicy.php resolves to App\Models\Order.

If you put it somewhere off-convention, register it explicitly1.

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

public function boot(): void
{
Gate::policy(Order::class, OrderPolicy::class);
}

Calling it from the controller​

Use Gate::authorize()1.

app/Http/Controllers/OrderController.php
use Illuminate\Support\Facades\Gate;

public function show(Order $order)
{
Gate::authorize('view', $order);

return new OrderResource($order->load('items'));
}

The first argument is the Policy method name and the second is the target model. If it returns false, an AuthorizationException is thrown and you get a 403.

It rides on the error conversion built in chapter 9, so the response comes back as JSON.

Other people's orders can no longer be fetched.

A Policy does not take effect on a listing​

Next, the listing. This is the most important part of the chapter.

❌ Bad: 'I wrote a Policy, so we are fine' does not hold
public function index()
{
$orders = Order::paginate(20);

return OrderResource::collection($orders);
}

We created OrderPolicy. It works in show. But this listing returns every member's orders.

The reason is simple. A Policy is a mechanism for deciding "this user, against this model," so it needs a model to decide against. At the time you fetch a listing, which models are involved has not been settled. You can write Gate::authorize('viewAny', Order::class), but that asks "may you view a listing at all," and which rows come back is a separate question.

Filtering happens in the query.

✅ Good: limit it to your own at query time
public function index(Request $request)
{
$orders = Order::where('user_id', $request->user()->id)
->latest()
->paginate(20);

return OrderResource::collection($orders);
}

The authorization logic ends up in two places: the Policy for detail, and the query's where for the listing. That is less a Laravel constraint than the nature of authorization as a problem.

The danger is not noticing the asymmetry. Think "I wrote a Policy, so authorization is done" and only the listing goes unguarded. And the listing is where the damage is worse. Detail leaks one record at a time; a listing leaks everything at once.

Going through the relationship works the same way​

✅ Fetching from the relationship narrows it naturally
public function index(Request $request)
{
$orders = $request->user()
->orders()
->latest()
->paginate(20);

return OrderResource::collection($orders);
}

Define an orders() relationship on the User model and you can write it this way. The "only mine" constraint appears structurally, so a forgotten where cannot happen.

app/Models/User.php
public function orders(): HasMany
{
return $this->hasMany(Order::class);
}

Make this your first choice when building a listing.

When to use a Gate​

A Policy is a decision tied to a model. For decisions with no model involved, use a Gate1.

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

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

This example assumes the users table has a role column. This series does not build admin features, so we do not add the column. Read it as an illustration of a Gate's shape.

Gate::authorize('access-admin-panel');

It covers permissions that are not about a particular piece of data, such as "may you enter the admin panel?" or "may you export a report?"

Here is the rule of thumb.

DecisionWhat to use
May you view this order?Policy
May you edit this product?Policy
May you use the admin features?Gate
May you call the statistics API?Gate

If the target model appears in the arguments, use a Policy; if not, use a Gate.

Choosing between 403 and 404​

When someone accesses another person's order, should you return 403 or 404?

403 says "it exists, but you lack permission." To an attacker that is information. If GET /api/v1/orders/500 returns 403, order 500 exists; if 404, it does not. Walk the range and you learn the total number of orders.

The ID enumeration risk mentioned in chapter 4 becomes concrete here.

The criterion is whether the resource's very existence is a secret.

SituationWhat to returnWhy
Someone else's order404You do not want its existence known
Your own order in an invalid state (cancelling a shipped order)409You already know it exists
A regular member calling an admin feature403There is no need to hide that admin features exist

To return 404 for someone else's order, narrow it in the query rather than the Policy.

app/Http/Controllers/OrderController.php
public function show(Request $request, Order $order)
{
if ($order->user_id !== $request->user()->id) {
abort(404);
}

return new OrderResource($order->load('items'));
}

Alternatively, narrow it before route model binding.

public function show(Request $request, int $orderId)
{
$order = $request->user()->orders()->findOrFail($orderId);

return new OrderResource($order->load('items'));
}

findOrFail throws a 404 when nothing is found. Because it searches within your own orders, someone else's simply "does not exist." In this form there is no room to forget the condition.

What this series chooses, and where the Policy goes

Order detail returns 404, because hiding existence is worth a lot here. Since show takes the findOrFail form, OrderPolicy::view is no longer called from this endpoint.

That does not make the Policy unnecessary. Where 403 is the right answer, a Policy is used as intended. In this series, cancelling an order is that case, and OrderPolicy::cancel decides "is this your order?" A cancellation request for an order you do not own gets 403. There is no need to hide existence at that point — the caller already knows the order ID, and trying to fetch its detail gets a 404.

Making everything 404 goes too far. Return 404 from an admin feature and users conclude the URL is wrong and get in touch. A 403 conveys "you lack permission" and leads to the next action of asking an administrator.

Tests​

As promised in chapter 10, we always write authorization tests.

A happy-path test passes even with authorization broken. If anyone can access anything, then you can access your own data too.

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

test('your own order can be fetched', function () {
$user = User::factory()->create();
$order = Order::factory()->create(['user_id' => $user->id]);

Sanctum::actingAs($user);

$this->getJson("/api/v1/orders/{$order->id}")->assertStatus(200);
});

test("another user's order returns 404", function () {
$owner = User::factory()->create();
$other = User::factory()->create();
$order = Order::factory()->create(['user_id' => $owner->id]);

Sanctum::actingAs($other);

$this->getJson("/api/v1/orders/{$order->id}")->assertStatus(404);
});

test('the order listing returns only your own orders', function () {
$user = User::factory()->create();
$other = User::factory()->create();

Order::factory()->count(3)->create(['user_id' => $user->id]);
Order::factory()->count(5)->create(['user_id' => $other->id]);

Sanctum::actingAs($user);

$this->getJson('/api/v1/orders')
->assertStatus(200)
->assertJsonCount(3, 'data');
});

The third one detects the listing gap. Because it checks the count, returning everything makes it 8 and the test fails.

In this series we put one "cannot access someone else's" test on every endpoint that needs authorization.

What matters in production​

An authorization gap in a listing leaks everything​

To repeat, this is the thing to watch most carefully in this chapter.

Let the comfort of having written a Policy carry a listing through unguarded and that endpoint becomes an API returning every member's data. And it comes back as a normal 200, leaving nothing in the error log. You find out when a user reports "I can see other people's orders."

Every time you build a new listing endpoint, check whose data it returns.

Do not reach for Gate::before casually​

There is a way to let administrators through every authorization check.

Gate::before(function (User $user) {
return $user->role === 'admin' ? true : null;
});

Convenient, but every condition written in a Policy is ignored. A state check like "a shipped order cannot be cancelled" stops applying to administrators too. An administrator can cancel a shipped order by mistake.

Granting permission and lifting business constraints are different things. If you use Gate::before, put state checks in the service class rather than the Policy.

Do not substitute a Resource for authorization​

❌ Bad: settling for showing fewer fields
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'total_amount' => $this->when($request->user()->id === $this->user_id, $this->total_amount),
];
}

Chapter 7's when() is about what to display, not authorization. Written this way, being able to access someone else's order is not prevented at all. id and status still come back, and the request returns 200 in the first place.

Whether access is allowed is decided by a Policy or the query; a Resource handles only what to show someone who is already allowed in.

Do not leave the authorization decision to the frontend​

"Not showing the admin button on screen" is not authorization. It is irrelevant to anyone calling the API directly.

Always decide on the server. Hiding things in the UI is there to improve the user's experience.

Summary​

  • Passing authentication only tells you who. Whether they may do this is a separate decision
  • A Policy is a decision tied to a model; a Gate is a decision with no model involved
  • A Policy does not take effect on a listing. Narrow with the query's where or through the relationship; the relationship form leaves nothing to forget
  • An authorization gap in a listing leaks everything at once, and returns 200, so nothing appears in the log
  • Resources whose existence you want hidden get 404 rather than 403. But making admin features 404 confuses users
  • Put one "cannot access someone else's" test on every endpoint that needs authorization
  • Gate::before ignores every Policy condition. Do not mix state checks with permission checks

The next chapter, Middleware and rate limiting, inserts processing at the entrance of the path a request takes. We gather processing common to every request into middleware and implement signature verification for webhooks from the payment service. The rate limiting that protects the previous chapter's login API from brute forcing goes in there too.

Exercises​

The following implementation works correctly for detail but leaves a gap in another endpoint. Where?
class OrderPolicy
{
public function view(User $user, Order $order): bool
{
return $user->id === $order->user_id;
}
}

class OrderController extends Controller
{
public function show(Order $order)
{
Gate::authorize('view', $order);

return new OrderResource($order);
}

public function index()
{
return OrderResource::collection(Order::latest()->paginate(20));
}
}

Sample answer

index has no authorization. Order::latest() returns every member's orders, newest first.

OrderPolicy is written, and show works correctly. That is exactly what makes it easy to miss: the fact that a Policy exists creates a feeling that authorization is handled.

But a Policy is a decision against a model instance. A listing has no instance to decide against. You could run view over each of the 20 records fetched by pagination, but then the page contents become gappy per user. Fetch 20 and if 3 are yours, only 3 display. Counts and page numbers stop meaning anything.

The right thing is to narrow it at fetch time.

public function index(Request $request)
{
return OrderResource::collection(
$request->user()->orders()->latest()->paginate(20)
);
}

The damage differs in scale too. The show gap leaks one record per request. The index gap leaks 20 per request, and everything if you page through. The conclusion is that the listing deserves checking first.

Should the policy of "return 404 for other people's resources" be applied to every endpoint?

Sample answer

No. The decision comes down to whether the resource's existence is itself a secret.

404 is right when knowing something exists is itself information.

  • Someone else's order — with sequential order IDs, a run of 403s reveals the total order count
  • Someone else's draft article — sometimes "there is something at that ID" conveys that they are writing
  • Invitation-only resources — knowing they exist makes uninvited people start asking

403 is right when existence is public knowledge and only permission is lacking.

  • The admin panel — everyone knows it exists. There is no point hiding it
  • A paid-plan-only feature — you actually want to convey "subscribe and you can use it"
  • A feature pending approval — it leads to the next action of "ask your manager"

The price of returning 404 is that users cannot work out the cause. Get a 404 from an admin feature and they conclude the URL is wrong and start investigating. Since it is really a permission problem, no amount of investigating helps. Support tickets come in as "the page is not found," and it takes time to realize permissions are the issue.

As an order of operations, defaulting to 403 and dropping to 404 only where there is a reason to hide existence is easier to work with. Make everything 404 and you obscure things that never needed hiding.


Footnotes​

  1. Source: Authorization (Laravel documentation 13.x). On generating policies with make:policy --model=, policy method arguments and return values, manual registration with Gate::policy, defining gates with Gate::define, and calling Gate::authorize from a controller. ↩ ↩2 ↩3 ↩4 ↩5