DEV Community

Cover image for Broken Access Control in Laravel — Why Being Logged In Isn't Enough

Broken Access Control in Laravel — Why Being Logged In Isn't Enough

This is the tenth article in a series on PHP and Laravel application security.

So far we have covered:

  • Detecting SQL injection attempts in PHP logs
  • Why URL encoding blinds most PHP security checks
  • The decode bomb problem with unlimited URL decoding
  • Why parameterized queries are the only real fix for SQL injection
  • XSS prevention in Laravel and why {!! !!} is the line between safe and hacked
  • How attackers enumerate your Laravel app before exploiting it
  • File upload security — the file that isn't what it claims to be
  • Path traversal in PHP — how ../ escapes your application
  • Command injection in PHP — when exec() becomes an attack surface

Every article in this series follows the same principle: understand the attack before you try to stop it.

Broken Access Control has been ranked the number one risk in the OWASP Top 10 since 2021. Not the most technically complex. The most common. Because developers assume that if a user is logged in they must be allowed to do what they are trying to do.

That assumption is wrong every time.

The Distinction Most Developers Miss

Authentication and authorization are two completely different things.

Authentication answers one question: who are you? You log in with your email and password. The system verifies your identity. You get a session.

Authorization answers a different question: what are you allowed to do? You are logged in — but can you access this specific resource? Can you edit this specific record? Can you see this specific user's data?

Broken access control happens when authentication works correctly but authorization is missing, incomplete, or wrong.

A developer who locks the front door but leaves every room inside unlocked has authentication without authorization. The attacker cannot get into the building. But once they are in — as any legitimate user — every unlocked room becomes accessible.

The Four Types of Broken Access Control

1. IDOR — Insecure Direct Object Reference

This is the most common and most exploited form of broken access control.

Your application shows a user their invoice:

https://yoursite.com/invoices/1234
Enter fullscreen mode Exit fullscreen mode

The developer checked that the user is logged in. They did not check that invoice 1234 belongs to this user.

An attacker changes the URL:

https://yoursite.com/invoices/1235
https://yoursite.com/invoices/1236
Enter fullscreen mode Exit fullscreen mode

They see someone else's invoice. Then another. They enumerate every invoice in your system by incrementing the number. Every customer's billing history. Every order. Every private document.

In production, this looks like a single authenticated user requesting dozens of sequential resource IDs in a short period. Traditional access logs record the requests but rarely highlight the pattern. Security monitoring tools such as Kriosa can identify this enumeration behavior and flag it as a potential authorization probe long before large amounts of data are exposed.

2. Missing Function-Level Access Control

A developer protects the admin dashboard with middleware but individual admin functions have no separate authorization check:

// Route is protected
Route::middleware(['auth', 'can:access-admin'])->group(function () {
    Route::get('/admin', [AdminController::class, 'index']);
});

// This API endpoint was added later and has no protection
Route::post('/api/delete-user', [AdminController::class, 'deleteUser']);
Enter fullscreen mode Exit fullscreen mode

A regular user who discovers /api/delete-user calls it directly — bypassing middleware entirely because the API route was added separately and the developer forgot to protect it.

3. Privilege Escalation

Horizontal — accessing another user's data at the same privilege level. User A reading User B's messages. Commonly caused by IDOR.

Vertical — gaining higher privileges than your role allows. A regular user becoming an admin.

Example of vertical escalation through mass assignment:

// Dangerous — user controls their own role
$user->update([
    'name'  => $request->name,
    'email' => $request->email,
    'role'  => $request->role  // attacker sends role=admin
]);
Enter fullscreen mode Exit fullscreen mode

If role is not protected from mass assignment an attacker updates their own role to admin through a standard profile update form.

4. Forced Browsing

An attacker directly accesses URLs that should be restricted:

/admin/users          — protected correctly
/admin/export-users   — developer forgot to protect this one
Enter fullscreen mode Exit fullscreen mode

The attacker discovers /admin/export-users and downloads your entire user database. No exploit needed. Just a URL the developer forgot to add middleware to.


How Laravel Handles Authorization

1. Middleware — Who Can Access This Route

Route::middleware(['auth'])->group(function () {
    Route::get('/dashboard', [DashboardController::class, 'index']);
});

Route::middleware(['auth', 'can:access-admin'])->group(function () {
    Route::get('/admin', [AdminController::class, 'index']);
    Route::post('/admin/delete-user', [AdminController::class, 'deleteUser']);
});
Enter fullscreen mode Exit fullscreen mode

The critical rule: every route that requires any form of access restriction must be inside the correct middleware group. Adding a new route outside the group — especially API routes — is how function-level access control vulnerabilities appear.

Good authorization prevents unauthorized access but it does not tell you when someone is actively searching for authorization weaknesses. Kriosa analyzes incoming requests for behaviors such as resource enumeration, repeated authorization failures, and suspicious access patterns that often precede a successful exploit.

The gap middleware does not fill: middleware checks who the user is and what permissions they have. It does not check whether this specific user owns this specific resource. For that you need gates or policies.

2. Gates — Can This User Perform This Action

// app/Providers/AuthServiceProvider.php
Gate::define('edit-post', function (User $user, Post $post): bool {
    return $user->id === $post->user_id;
});
Enter fullscreen mode Exit fullscreen mode
// In your controller
public function edit(Post $post)
{
    Gate::authorize('edit-post', $post);
    return view('posts.edit', compact('post'));
}
Enter fullscreen mode Exit fullscreen mode

If the gate returns false Laravel handles the 403 response automatically.

3. Policies — Organized Authorization for Models

php artisan make:policy InvoicePolicy --model=Invoice
Enter fullscreen mode Exit fullscreen mode
// app/Policies/InvoicePolicy.php
class InvoicePolicy
{
    public function view(User $user, Invoice $invoice): bool
    {
        return $user->id === $invoice->user_id;
    }

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

    public function delete(User $user, Invoice $invoice): bool
    {
        return $user->id === $invoice->user_id
            || $user->isAdmin();
    }
}
Enter fullscreen mode Exit fullscreen mode
// In your controller
public function show(Invoice $invoice)
{
    $this->authorize('view', $invoice);
    return view('invoices.show', compact('invoice'));
}
Enter fullscreen mode Exit fullscreen mode

4. Form Requests — Authorization in Larger Applications

public function authorize(): bool
{
    return $this->user()->can('update', $this->invoice);
}
Enter fullscreen mode Exit fullscreen mode

The IDOR Fix — Three Approaches

Approach 1 — Policy authorization:

// Dangerous — only checks authentication
public function show(Invoice $invoice)
{
    return view('invoices.show', compact('invoice'));
}

// Safe — checks ownership through policy
public function show(Invoice $invoice)
{
    $this->authorize('view', $invoice);
    return view('invoices.show', compact('invoice'));
}
Enter fullscreen mode Exit fullscreen mode

Approach 2 — Scoped queries:

// This can never return another user's invoice
public function show(int $id)
{
    $invoice = auth()->user()->invoices()->findOrFail($id);
    return view('invoices.show', compact('invoice'));
}
Enter fullscreen mode Exit fullscreen mode

Approach 3 — Scoped route model binding (strongest):

Route::get(
    '/users/{user}/invoices/{invoice}',
    InvoiceController::class
)->scopeBindings();
Enter fullscreen mode Exit fullscreen mode

This automatically ensures the invoice belongs to the specified user without any manual ownership check in the controller.

Protecting Against Mass Assignment Privilege Escalation

// app/Models/User.php
protected $fillable = [
    'name',
    'email',
    'password',
    // role and is_admin are NOT here
];
Enter fullscreen mode Exit fullscreen mode

Or blacklist instead:

protected $guarded = ['role', 'is_admin', 'permissions'];
Enter fullscreen mode Exit fullscreen mode

Pick one approach and stick to it. Never include role, is_admin, or permissions in $fillable.

API Routes and Authorization

API routes are the most common place authorization is forgotten:

Route::middleware(['auth:sanctum'])->group(function () {
    Route::get('/invoices/{invoice}', function (Invoice $invoice) {
        $this->authorize('view', $invoice);
        return response()->json($invoice);
    });
});
Enter fullscreen mode Exit fullscreen mode

Being authenticated via Sanctum does not mean the user is authorized to access this specific invoice. The ownership check is required on every API endpoint that returns user-specific data.

Cleaner with scoped route model binding:

Route::get(
    '/users/{user}/invoices/{invoice}',
    [InvoiceApiController::class, 'show']
)->scopeBindings()->middleware('auth:sanctum');
Enter fullscreen mode Exit fullscreen mode

The Authorization Checklist

For every route in your application ask these questions:

  • Is this route inside the correct middleware group?
  • Does every API route have the same authorization as its web equivalent?
  • For routes that return a specific resource — is there an ownership check via policy, gate, or scoped query?
  • Are sensitive fields like role and is_admin excluded from $fillable?
  • Are admin-only functions inside admin middleware — including API endpoints added later?
  • Does your application use policies for resource-level authorization rather than ad-hoc checks scattered across controllers?
  • Have you considered scoped route model binding for nested resources?

If any of these questions has no clear answer in your code — you have a broken access control vulnerability waiting to be found.

Where Detection Fits

Broken access control attacks are harder to detect than SQL injection or XSS because the requests themselves look legitimate. An authenticated user accessing /invoices/1235 when they own /invoices/1234 sends a request that looks completely normal at the HTTP layer.

What is detectable is the pattern:

  • Rapid sequential requests to the same resource type with incrementing IDs
  • Multiple 403 Forbidden responses from the same authenticated account
  • Access attempts to resources owned by different users
  • Requests to privileged endpoints from accounts without the required permissions

This is exactly where Kriosa fits into the security stack. Laravel policies, gates, and middleware prevent unauthorized actions. Kriosa complements those controls by monitoring incoming requests for behavioral indicators of authorization probing — sequential ID enumeration, repeated 403 responses, and attempts to access privileged endpoints. Instead of simply blocking requests, its Explainable AI dashboard shows why a request was considered suspicious, helping developers understand what attackers are trying to do.

Prevention stops the breach. Detection tells you someone is trying to find one.

Try it free: kriosa.com
Install it: composer require kriosa-ai/kriosa-php

Built by a developer from Cameroon, for developers who want to understand their security — not just outsource it.

The Series So Far

  • Article 1: What your PHP logs actually look like during a SQL injection attack
  • Article 2: Why URL encoding can break PHP security checks
  • Article 3: The decode bomb problem — why unlimited URL decoding can be its own vulnerability
  • Article 4: Parameterized queries — the only real fix for SQL injection
  • Article 5: XSS prevention in Laravel and why {!! !!} is the line between safe and hacked
  • Article 6: How attackers enumerate your Laravel app and what to hide
  • Article 7: File upload security in PHP and Laravel
  • Article 8: Path traversal in PHP — how ../ escapes your application
  • Article 9: Command injection in PHP — when exec() becomes an attack surface
  • Article 10: This article — broken access control in Laravel and why being logged in is not enough

Top comments (0)