phoenix1331 / laravel-auth-audit
Authorisation coverage reporting for Laravel applications. Statically scans routes, controllers, Form Requests, and Policies to report what is and is not protected.
Requires
- php: ^8.2
- illuminate/console: ^10.0|^11.0|^12.0|^13.0
- illuminate/routing: ^10.0|^11.0|^12.0|^13.0
- illuminate/support: ^10.0|^11.0|^12.0|^13.0
- nikic/php-parser: ^5.0
Requires (Dev)
- laravel/pint: ^1.0
- orchestra/testbench: ^8.0|^9.0|^10.0|^11.0
- pestphp/pest: ^2.0|^3.0
Suggests
None
Provides
None
Conflicts
None
Replaces
None
This package is not auto-updated.
Last update: 2026-09-07 19:30:58 UTC
README
Authorisation coverage reporting for Laravel. Larastan tells you your types are right. Auth Audit tells you your endpoints are actually protected.
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 - and it is trivially easy to introduce without realising it.
A developer who adds auth middleware to a route has proved who the user is - not that they are allowed to see or edit that specific record. The gap between the two is where IDOR lives. 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 profile. Auth Audit is a Composer dev-dependency that statically scans your routes, controllers, Form Requests, and Policies to find exactly these gaps, reports them as a coverage percentage, and fails your CI build when coverage drops below a threshold you set.
Quick start
composer require phoenix1331/laravel-auth-audit --dev php artisan auth-audit:run
That is it. No configuration required. The command scans your route collection and prints a table.
For CI, add a minimum threshold:
php artisan auth-audit:run --min=90
The command exits with code 1 when coverage is below the threshold, 0 otherwise.
How detection works
The detector runs four tiers in confidence order, stopping at the first signal it finds:
Tier 1 - Middleware
can: middleware on the route definition counts as an explicit authorisation signal. Custom middleware strings can be registered in custom_signals config to extend this tier.
Tier 2 - Controller body (AST)
The controller file is parsed as an AST using nikic/php-parser - the same library PHPStan and Rector use internally. The detector looks for:
$this->authorize()orGate::authorize()Gate::allows(),Gate::check(),Gate::any(),Gate::none(),Gate::inspect()abort_unless($user->can(...))andabort_if(!$user->can(...))$this->authorizeResource()in the constructor- Relationship-scoped retrieval:
$request->user()->orders()->findOrFail($id)
A Form Request type-hint on the method signature is also inspected - if its authorize() method contains only return true;, it is flagged as the bare-true-form-request anti-pattern. If its authorize() references $this->route(), the signal is labelled [instance-scoped].
Tier 3 - Policy
For each Eloquent model bound to the route via route-model binding, the detector checks whether a Policy is registered for that model and whether the policy has a method matching the implied CRUD action. v2 also parses the policy method body and flags instance-blind-policy when the method never references the model parameter.
Tier 4 - Custom signals
An escape hatch for teams whose authorisation lives in a service layer or a custom middleware. Register the class method or middleware name in custom_signals config.
Known limitation - call graph: authorisation that lives inside a called service method is not visible to the static AST pass. If AuthService::authorizeView() internally calls Gate::authorize(), the detector sees only that the method was called - it does not follow the call graph. Register the method in custom_signals to handle this pattern explicitly.
Known limitation - single-route dispatchers (MCP, RPC, webhook hubs): some architectures expose one route that internally dispatches to many different handlers, each with its own authorisation requirement. A common example is an MCP server where a single POST /mcp route routes requests to N different tools:
// routes/api.php Route::post('/mcp', [McpController::class, 'invoke']); // one route, N tools // McpController::invoke() dispatches based on request payload: // - tools/list -> no per-resource auth needed // - invoices/get -> must verify the invoice belongs to the authenticated tenant // - users/delete -> must verify admin role
The audit sees one route and one controller method. If that method contains any authorisation signal, the entire route is reported as covered - it cannot reason about the N internal dispatch paths. This is a coarse-grained result by design: the static AST pass works at route granularity, not payload granularity.
Recommended workarounds for dispatcher routes:
Option A - suppress with a documented reason and enforce authorisation in the dispatcher itself:
#[WithoutAuthAudit('MCP gateway: per-tool authorisation enforced inside McpController::invoke() via ToolAuthService')] public function invoke(Request $request): JsonResponse { // authorisation happens per tool inside dispatch() }
Option B - register a custom_signals entry once per-tool authorisation has been audited and confirmed:
// config/auth-audit.php 'custom_signals' => [ 'App\\Http\\Controllers\\McpController::invoke', ],
Both options make the coverage decision explicit and auditable rather than silently green.
Anti-patterns detected
v2 detects five classes of broken authorisation that look correct at a glance but provide no real protection:
unscoped-nested-binding
A route with two or more route-model-bound parameters where neither ->scopeBindings() is applied nor the child follows Laravel's naming convention ({team}/{team_order}). An auth check on the parent does not prove the child belongs to the parent.
// before - flagged Route::get('/teams/{team}/orders/{order}', [OrderController::class, 'show']); // $this->authorize() on $team proves the user owns the team // but nothing proves the $order belongs to that team // after - safe Route::get('/teams/{team}/orders/{order}', [OrderController::class, 'show']) ->scopeBindings();
class-level-check-on-instance-route
authorize() or Gate::* called with Model::class instead of the bound instance. The policy receives the class string, not the record, so it cannot verify ownership.
// before - flagged $this->authorize('update', Order::class); // after - safe $this->authorize('update', $order); // pass the bound instance
instance-blind-policy
A policy method that either has no model parameter or never references it in its body. The policy runs but cannot enforce per-record ownership.
// before - flagged public function update(User $user): bool { return $user->isAdmin(); // checks role, not which order is being accessed } // after - safe public function update(User $user, Order $order): bool { return $order->user_id === $user->id; }
unbound-identifier
A route with a raw scalar param ({id} without type-hinting to a model) where the controller calls find(), findOrFail(), firstWhere(), or where('id', ...) without first scoping the query to the authenticated user.
// before - flagged public function show(Request $request, int $id): Response { $order = Order::findOrFail($id); // any user can access any order } // after - safe (relationship-scoped retrieval) public function show(Request $request, int $id): Response { $order = $request->user()->orders()->findOrFail($id); }
discarded-gate-result
Gate::allows(), Gate::check(), or Gate::any() called as a bare statement whose return value is never used in a conditional, abort_unless, ternary, or return.
// before - flagged Gate::allows('update', $order); // result discarded, no effect // after - safe abort_unless(Gate::allows('update', $order), 403); // or Gate::authorize('update', $order);
Baseline adoption
On a large existing codebase, fixing every violation before shipping v2 detection is not always practical. The baseline system lets you record the current state and enforce "no new violations" in CI without touching existing ones.
Step 1 - Generate the baseline:
php artisan auth-audit:run --generate-baseline
Writes auth-audit-baseline.json at the project root (path configurable via baseline_path in config/auth-audit.php).
Step 2 - CI with the baseline:
php artisan auth-audit:run --compare=auth-audit-baseline.json --min=80
Routes in the baseline appear as baselined and are excluded from the coverage percentage. New routes are not grandfathered in - they must be authorised or they fail the build.
Step 3 - Shrink the baseline over time:
Fix a violation, then regenerate:
php artisan auth-audit:run --generate-baseline
When the baseline is empty, remove --compare and enforce full coverage.
If routes in the baseline no longer exist, the command reports stale entries and reminds you to regenerate.
CLI reference
# basic scan php artisan auth-audit:run # fail CI below 90% coverage php artisan auth-audit:run --min=90 # machine-readable JSON output php artisan auth-audit:run --json # write a self-contained HTML report php artisan auth-audit:run --html=storage/auth-audit/report.html # generate a baseline file php artisan auth-audit:run --generate-baseline # compare against a baseline (suppress known violations, fail on new ones) php artisan auth-audit:run --compare=auth-audit-baseline.json --min=90
Sample console output:
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
Configuration
Publish the config file:
php artisan vendor:publish --tag=auth-audit-config
This creates config/auth-audit.php. The package works without publishing - defaults are merged automatically.
| Key | Type | Default | Purpose |
|---|---|---|---|
enabled |
bool | true |
Global on/off switch |
min_coverage |
int | 80 |
CI threshold - exit code 1 below this |
exclude |
array | auth/password routes | URI or route name patterns excluded from the scan |
exclude_middleware |
array | ['guest'] |
Routes behind these middleware are excluded automatically |
scan_paths |
array | app/Http/Controllers |
Directories walked for controller discovery |
custom_signals |
array | [] |
Additional class methods or middleware that count as authorised |
flag_bare_true_form_requests |
bool | true |
Flag authorize() { return true; } as a named anti-pattern |
baseline_path |
string | auth-audit-baseline.json |
Default path for --generate-baseline and --compare |
html.output_path |
string | storage/auth-audit/report.html |
Default path for --html output |
html.title |
string | Auth Audit Report |
Report header text |
require_exclusion_reasons |
bool | true |
Every exclude entry must carry a documented reason string |
The require_exclusion_reasons option is the anti-gaming mechanism. The HTML report always surfaces the full exclusion list with reasons - nothing is hidden.
Bypassing the audit
Two colocated bypass mechanisms are available. Both require a mandatory reason string - silent suppression is not possible.
Attribute
use Phoenix1331\LaravelAuthAudit\Attributes\WithoutAuthAudit; // on a specific action #[WithoutAuthAudit('Signature verified via Stripe webhook secret, not policy-gated')] public function stripe(): void { ... } // on an entire controller #[WithoutAuthAudit('Public marketing pages')] class MarketingController extends Controller { ... } // with an expiry date - past the date, the bypass reverts to a flagged violation automatically #[WithoutAuthAudit('Policy not written yet', expires: '2026-12-31')] public function betaExport(): void { ... }
The expires parameter is the key differentiator from @SuppressWarnings-style annotations elsewhere in the ecosystem. A temporary bypass cannot quietly become permanent technical debt.
Route macro
Route::get('/up', fn () => response()->json(['ok' => true])) ->name('health') ->withoutAuthAudit('Health check endpoint, no sensitive data, no auth required');
Skip volume as a metric
Skip counts are reported separately from the coverage percentage, specifically so teams cannot game the headline number by skipping instead of fixing. A CI comment showing +3 skips this PR is a visible signal even when the coverage number looks fine.
Custom signals
If your authorisation lives in a custom middleware, register it in config so the detector counts it correctly:
// config/auth-audit.php 'custom_signals' => [ 'ensure.team.owner', 'App\\Services\\TeamAuthService::authorize', ],
Without this entry the route appears red (false positive). With it, it appears green. The config is the documented contract for non-standard patterns.
CI recipe
- name: run auth audit run: php artisan auth-audit:run --min=90
With baseline (no regressions allowed, existing violations suppressed):
- name: run auth audit run: php artisan auth-audit:run --compare=auth-audit-baseline.json --min=90
Why I built this
Broken access control has been the number 1 issue on the OWASP Top 10 for multiple release cycles. The most recognisable instance in Laravel is IDOR - Insecure Direct Object Reference. A developer adds a route, wires up a controller, puts it behind auth middleware, and ships it. Nothing enforces that they also checked whether the authenticated user is allowed to access that specific record.
The result: any logged-in user walks /users/1, /users/2, /users/3 and reads every profile in the system. The route looks secure at a glance - it sits behind auth, a Policy class even exists in the codebase - but nobody wired the Policy check to this route.
Existing tooling does not close this gap:
- Larastan and PHPStan catch type errors, not missing authorisation
- Enlightn covers general best practices but does not walk the route to controller to model graph specifically for authorisation coverage
- Spatie Laravel Permission gives you tools to build authorisation - it does not audit whether you used them correctly everywhere you needed to
Laravel Auth Audit sits in the gap. It does not invent a new authorisation concept. It audits whether the ones Laravel already ships with were actually applied.
References:
- OWASP A01:2021 Broken Access Control - owasp.org/Top10/2021/A01_2021-Broken_Access_Control
- OWASP IDOR attack pattern - owasp.org/www-community/attacks/Insecure_Direct_Object_Reference
See UPGRADING.md if you are migrating from v1.
Roadmap
- v3: runtime detection tier - an opt-in middleware for staging environments that logs actual Gate and Policy invocations, reconciled against the static report to catch service-layer authorisation the AST pass cannot see
- v3/v4: optional in-app dashboard (Telescope/Pulse-style) as a new consumer of the existing
AuditReportdata model - historical trend charts across commits, no scanning logic duplicated - stretch: a GitHub Action wrapper (
uses: phoenix1331/laravel-auth-audit-action@v1) for zero-config CI adoption
Contributing
Pull requests are welcome. To add a new detection signal:
- Clone the repo and install dependencies:
git clone https://github.com/phoenix1331/laravel-auth-audit
cd laravel-auth-audit
composer install
- The test suite uses Pest with Orchestra Testbench - no database required:
./vendor/bin/pest
- Add a fixture controller in
tests/Fixtures/Controllers/demonstrating the pattern - Add a unit test in
tests/Unit/AuthorisationDetectorTest.phpcovering the new signal - Add a feature test in
tests/Feature/AuthAuditRunCommandTest.phpasserting end-to-end behaviour - Run
./vendor/bin/pintbefore committing
Prerequisites: PHP 8.2+, Composer. osv-scanner is required for the pre-commit hook (go install github.com/google/osv-scanner/cmd/osv-scanner@latest).
Licence
MIT - see LICENSE.