Authorisation Is Not Authentication in Laravel
Laravel's auth scaffolding answers who you are. Who may do what is yours to write, and the tests that prove it are the ones nobody writes - the ones asserting the wrong person is refused.
Laravel gets you authenticated in an afternoon. Registration, login, password reset, tokens, two-factor if you want it - installed, tested and conventional.
Then the application needs to decide what each authenticated person may do, and the framework correctly declines to guess. That part is yours, and it is where the security faults we find in audits actually live.
The distinction, stated plainly
Authentication establishes who is making the request. It is a solved problem and you should not be solving it yourself.
Authorisation decides whether that person may perform this action on this object. It is domain logic. Nobody can ship it for you, because it is a statement about your business.
Almost every serious access-control failure we have found in a Laravel application was an authorisation fault on a route where authentication worked perfectly.
The failure, in its most common form
public function show(Invoice $invoice)
{
return view('invoices.show', compact('invoice'));
}The route is behind auth middleware. The user is logged in. Route model
binding resolved the id into an invoice.
Nothing asked whether it is their invoice. Change the number in the URL and you have somebody else's. On a multi-tenant platform this is a cross-tenant data leak, and the route looks entirely normal in review.
The fix is one line and the discipline is remembering it every time:
public function show(Invoice $invoice)
{
$this->authorize('view', $invoice);
return view('invoices.show', compact('invoice'));
}One rule, one place, every entry point
The second failure is duplication. A permission is implemented in a controller for the web routes, then re-implemented in middleware for the API, then approximately re-implemented again in a Blade condition that hides the button.
Three copies of a rule drift. One of them becomes wrong, and it is usually the one nobody is looking at.
The rule belongs in a policy, and every entry point calls it:
class InvoicePolicy
{
public function view(User $user, Invoice $invoice): bool
{
return $user->team_id === $invoice->team_id;
}
}Controllers call it. API resources call it. Blade asks @can('view', $invoice)
to decide whether to render the button - and hiding the button is presentation,
never enforcement. A hidden button is still a route.
Jobs and console commands need this thought about too. A queued export that builds a report "for a user" without re-checking scope is a way of laundering an authorisation check out of the system.
Ownership beats roles
Role checks answer what kind of user this is. They pass happily while the user touches somebody else's data.
// passes for any admin, including another tenant's admin
if ($user->hasRole('admin')) { ... }
// asks the question that matters
return $user->team_id === $invoice->team_id
&& $user->hasRole('admin');On a multi-tenant system, apply the tenant constraint globally rather than remembering it per query - a global scope, a scoped binding, or a repository layer that cannot be bypassed accidentally. The rule is that forgetting should produce no rows, not another tenant's rows.
Test the refusal, not the permission
This is the practical takeaway.
it('refuses an invoice belonging to another team', function () {
$invoice = Invoice::factory()->create(); // some other team
$intruder = User::factory()->create();
$this->actingAs($intruder)
->get("/invoices/{$invoice->id}")
->assertForbidden();
});Happy-path tests are written by default because that is the feature being
built. The negative case is the one that catches the regression, and a test
suite containing no assertForbidden is a suite that has never checked
authorisation at all.
Write one per resource, for the wrong user, the wrong tenant and the unauthenticated request. It is a short afternoon and it is the highest-value testing you can do in a business application.
What we look for in an audit
Every route without an authorisation call. Every policy method that returns
true unconditionally. Mass-assignable fields that decide permissions - a
role or team_id in $fillable is an escalation waiting for a form post.
Anywhere a query is not scoped to the current tenant. And the API, always,
because it is where the web routes' rules were re-implemented by somebody in a
hurry.
None of it is exotic. It is the same small set of omissions, and they are cheaper to find with a checklist than with a disclosure.
An audit walks that checklist and writes down what it did not find. Those notes are the tests nobody had written. If the doubt is specifically about the API, start one level up: Sanctum and Passport draw the authorisation line in different places, and which one you are on decides how much of this you build yourself.
