OWASP A01 — Broken Access Control

Your routes are behind auth.
But are they actually protected?

Laravel Auth Audit statically scans your routes, controllers, Form Requests, and Policies to report what is and is not authorised. Fails your CI build when coverage drops.

$ composer require phoenix1331/laravel-auth-audit --dev
php artisan auth-audit:run --min=90
Route Verb Auth Check Status
──────────────────────────────────────────────────────────────────────────────────
/orders/{order} PUT $this->authorize() ✓ authorised
/teams/{team}/orders/{order} GET unscoped-nested-binding ✗ unauthorised
/invoices/{id} GET unbound-identifier ✗ unauthorised
/reports/export GET can:view-reports ✓ authorised
/webhooks/stripe POST Signature verified - skipped
/users/{id} GET unbound-identifier ~ baselined
──────────────────────────────────────────────────────────────────────────────────
Coverage: 82% (211/257 routes)
18 unauthorised · 28 excluded · 13 skipped · 4 baselined
error: coverage 82% is below the required threshold of 90%

Broken Access Control has been the #1 vulnerability on the OWASP Top 10 for multiple release cycles. The most common form in Laravel is IDOR - Insecure Direct Object Reference. A logged-in user walks /users/1, /users/2, /users/3 in the address bar and the app resolves every one, because nothing ever checked whether that user is allowed to see each record.

false security

Auth middleware proves identity, not access

Adding auth middleware to a route proves who the user is. It says nothing about whether they are allowed to access this specific record.

invisible bug

Policies exist but are never wired up

A Policy class in app/Policies/ protects nothing unless it is actually called. Code review rarely catches the gap between presence and invocation.

the solution

Static analysis that follows the route graph

Auth Audit walks route to controller to model, checking every step for a real authorisation signal. No runtime required — it runs in CI alongside your type checker.

Four tiers. One answer per route.

The detector runs tiers in confidence order and stops at the first signal it finds. No signal means the route is flagged.

1 Middleware

Route middleware

can: middleware on the route definition is an explicit authorisation signal. Custom middleware strings can be registered in custom_signals config to extend this tier.

2 AST scan

Controller body — nikic/php-parser

The controller file is parsed as an AST. The detector recognises $this->authorize(), Gate::allows(), abort_unless($user->can(...)), relationship-scoped retrieval, and more. Form Request authorize() bodies are inspected too — a bare return true; is flagged as an anti-pattern.

3 Policy

Registered Policy for bound model

For each Eloquent model bound via route-model binding, the detector checks whether a Policy is registered and whether it has a method matching the implied CRUD action. Policy bodies are also inspected for the instance-blind-policy anti-pattern.

4 Custom

Custom signals escape hatch

If your authorisation lives in a service layer or custom middleware, register the class method or middleware name in custom_signals config. This prevents false positives for non-standard patterns.

Auth that looks right but isn't.

v2 detects five classes of broken authorisation that pass a quick code review but provide no real protection.

unscoped-nested-binding

Nested route-model binding without scopeBindings(). Auth on the parent does not prove the child belongs to it.

✗ flagged
// $order can belong to any team
Route::get('/teams/{team}/orders/{order}',
    [OrderController::class, 'show']);
✓ safe
Route::get('/teams/{team}/orders/{order}',
    [OrderController::class, 'show'])
    ->scopeBindings();
class-level-check-on-instance-route

Calling authorize() with Model::class instead of the bound instance. The policy receives a class string, not the record.

✗ flagged
$this->authorize('update', Order::class);
// policy cannot verify ownership
✓ safe
$this->authorize('update', $order);
// pass the bound instance
instance-blind-policy

A policy method that never references the model parameter. It checks the user's role, not who owns the record.

✗ flagged
public function update(User $user): bool
{
    // checks role, not ownership
    return $user->isAdmin();
}
✓ safe
public function update(
    User $user, Order $order
): bool {
    return $order->user_id === $user->id;
}
unbound-identifier

A raw {id} param where the controller calls findOrFail() without scoping to the authenticated user first.

✗ flagged
// any user can access any order
$order = Order::findOrFail($id);
✓ safe
// scoped to the authenticated user
$order = $request->user()
    ->orders()->findOrFail($id);
discarded-gate-result

Gate::allows() called as a bare statement whose return value is never used. The check runs but has no effect.

✗ flagged
// result discarded, no effect
Gate::allows('update', $order);
✓ safe
abort_unless(
    Gate::allows('update', $order), 403
);
bare-true-form-request

A Form Request whose authorize() method contains only return true;. Structurally present, functionally absent.

✗ flagged
public function authorize(): bool
{
    return true; // always allows
}
✓ safe
public function authorize(): bool
{
    return $this->route('order')
        ->user_id === $this->user()->id;
}

Bypass with intent. Not silence.

Every bypass requires a mandatory reason string. Silent suppression is not possible. Temporary bypasses can carry an expiry date — past the date, they automatically revert to violations.

WebhookController.php
use Phoenix1331\LaravelAuthAudit\Attributes\WithoutAuthAudit;

#[WithoutAuthAudit('Signature verified via Stripe
 webhook secret, not policy-gated')]
public function stripe(): void { ... }

// with expiry — cannot silently become permanent
#[WithoutAuthAudit(
    'Policy not written yet',
    expires: '2026-12-31'
)]
public function betaExport(): void { ... }
routes/web.php
Route::get('/up', fn () => response()->json(['ok' => true]))
    ->name('health')
    ->withoutAuthAudit(
        'Health check, no sensitive data'
    );
config/auth-audit.php
'custom_signals' => [
    'ensure.team.owner',
    'App\\Services\\TeamAuthService::authorize',
],

Adopt on large codebases without a big bang fix.

The baseline system records the current state and enforces "no new violations" in CI without requiring every existing issue to be fixed upfront.

Step 1

Generate the baseline

Snapshot current violations so CI does not break immediately on an existing codebase.

php artisan auth-audit:run --generate-baseline
Step 2

Enforce in CI

Baselined routes appear as ~ baselined and are excluded from the coverage percentage. New routes get no free pass.

auth-audit:run --compare=auth-audit-baseline.json --min=80
Step 3

Shrink it over time

Fix a violation, regenerate the baseline, and the coverage number improves automatically. When the file is empty, remove --compare.

php artisan auth-audit:run --generate-baseline
php artisan auth-audit:run --compare=auth-audit-baseline.json --min=90
/users/{id} GET unbound-identifier ~ baselined (suppressed)
/posts/{post} PUT $this->authorize() ✓ authorised (new route, passes)
/invoices/{id} GET unbound-identifier ✗ unauthorised (new route, fails)
error: new unauthorised routes detected outside baseline

Not a replacement. A gap filler.

These tools are excellent at what they do. None of them walk the route-to-controller-to-policy graph specifically for authorisation coverage.

Tool Type errors General security Authz coverage per route IDOR anti-patterns
Larastan / PHPStan
Static analysis, type checking
Enlightn
Laravel best-practice auditing
~ ~
Spatie Laravel Permission
Role and permission management
Laravel Auth Audit
Authorisation coverage auditing
~

One line in your pipeline.

Add the audit step after your tests. The command exits 0 on pass, 1 on failure — any CI system works.

GitHub Actions — simple
- name: run auth audit
  run: php artisan auth-audit:run --min=90
GitHub Actions — with baseline
- name: run auth audit
  run: php artisan auth-audit:run \
         --compare=auth-audit-baseline.json \
         --min=90
JSON output for downstream tooling
php artisan auth-audit:run --json \
  | jq '.summary'
HTML report artifact
php artisan auth-audit:run \
  --html=storage/auth-audit/report.html

- name: upload report
  uses: actions/upload-artifact@v4
  with:
    name: auth-audit-report
    path: storage/auth-audit/report.html