Skip to content

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.

5 min read

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.

Related questions

Where should authorisation live?
In policies, called from every entry point. The failure we find most often is a rule implemented in a controller for the web and again in middleware for the API, drifting apart until one of them is wrong - and it is usually the API, because nobody is looking at it.
Is a role check enough?
Roles answer what kind of user this is, not whether this user may touch this record. Most real breaches in business applications are a valid user reaching another tenant's row, and a role check passes that every time. The ownership check is the one that matters.
What does a good authorisation test look like?
It asserts a refusal. Tests are usually written from the happy path - the owner can open their own invoice - and the case that matters is that somebody else cannot. A suite with no 403 assertions is not testing authorisation at all.
Does route model binding handle this?
No, and it is worth being explicit about. Binding resolves the id into a model; it does not ask whether the current user may have it. Scoped bindings constrain the lookup to a parent relationship, which helps a great deal and is still not a substitute for a policy.

← Back to all articles

Call us+1 848 272 7583WhatsApp+90 850 308 5436Emailinfo@codefacture.comContact page