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
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.
Adding auth middleware to a route proves who the user is. It says nothing about whether they are allowed to access this specific record.
A Policy class in app/Policies/ protects nothing unless it is actually called. Code review rarely catches the gap between presence and invocation.
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.
The detector runs tiers in confidence order and stops at the first signal it finds. No signal means the route is flagged.
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.
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.
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.
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.
v2 detects five classes of broken authorisation that pass a quick code review but provide no real protection.
Nested route-model binding without scopeBindings(). Auth on the parent does not prove the child belongs to it.
// $order can belong to any team Route::get('/teams/{team}/orders/{order}', [OrderController::class, 'show']);
Route::get('/teams/{team}/orders/{order}', [OrderController::class, 'show']) ->scopeBindings();
Calling authorize() with Model::class instead of the bound instance. The policy receives a class string, not the record.
$this->authorize('update', Order::class); // policy cannot verify ownership
$this->authorize('update', $order); // pass the bound instance
A policy method that never references the model parameter. It checks the user's role, not who owns the record.
public function update(User $user): bool { // checks role, not ownership return $user->isAdmin(); }
public function update( User $user, Order $order ): bool { return $order->user_id === $user->id; }
A raw {id} param where the controller calls findOrFail() without scoping to the authenticated user first.
// any user can access any order $order = Order::findOrFail($id);
// scoped to the authenticated user $order = $request->user() ->orders()->findOrFail($id);
Gate::allows() called as a bare statement whose return value is never used. The check runs but has no effect.
// result discarded, no effect Gate::allows('update', $order);
abort_unless(
Gate::allows('update', $order), 403
);
A Form Request whose authorize() method contains only return true;. Structurally present, functionally absent.
public function authorize(): bool { return true; // always allows }
public function authorize(): bool { return $this->route('order') ->user_id === $this->user()->id; }
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.
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 { ... }
Route::get('/up', fn () => response()->json(['ok' => true])) ->name('health') ->withoutAuthAudit( 'Health check, no sensitive data' );
'custom_signals' => [ 'ensure.team.owner', 'App\\Services\\TeamAuthService::authorize', ],
The baseline system records the current state and enforces "no new violations" in CI without requiring every existing issue to be fixed upfront.
Snapshot current violations so CI does not break immediately on an existing codebase.
Baselined routes appear as ~ baselined and are excluded from the coverage percentage. New routes get no free pass.
Fix a violation, regenerate the baseline, and the coverage number improves automatically. When the file is empty, remove --compare.
These tools are excellent at what they do. None of them walk the route-to-controller-to-policy graph specifically for authorisation coverage.
Add the audit step after your tests. The command exits 0 on pass, 1 on failure — any CI system works.
- name: run auth audit run: php artisan auth-audit:run --min=90
- name: run auth audit run: php artisan auth-audit:run \ --compare=auth-audit-baseline.json \ --min=90
php artisan auth-audit:run --json \ | jq '.summary'
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