Install
$ agentstack add skill-artemproshkovskiy-laravel-maintenance-skills-laravel-authorization-review ✓ scanned · ✓ verified — works with Claude Code, Cursor, and more.
Security review
✓ PassedNo issues found. Passed automated security review. · v0.1.0 How review works →
- ✓ Prompt-injection patterns
- ✓ Secret / credential exfiltration
- ✓ Dangerous shell & filesystem operations
- ✓ Untrusted network calls
- ✓ Known-malicious package signatures
What it can access
- ✓ Network access No
- ✓ Filesystem access No
- ✓ Shell / process execution No
- ● Environment & secrets Used
- ✓ Dynamic code execution No
From automated source analysis of v0.1.0. “Used” means the capability is present in the source — more access means more to trust, not that it’s unsafe.
About
Laravel Authorization Review
> SAST tells you where data flows. This tells you where it flows to the wrong user.
Context
This skill is a judgment layer for the one security category automated scanners structurally cannot do: broken object-level authorization (IDOR / BOLA) — #1 in the OWASP API Security Top 10. Taint scanners trace untrusted input; they cannot decide whether Order::find($id) should have been scoped to the current user. That is a question about intent, and it needs reasoning across middleware, controller, policy, and query — exactly what an LLM can do and grep cannot.
The reason this skill is trustworthy and not a hallucination engine is its ground truth anchor: php artisan route:list --json is the deterministic inventory of every endpoint and its middleware — Laravel's equivalent of composer audit's JSON. Every finding traces to a real route in that list and a cited file:line. If you cannot point to both, you do not report it.
You are an authorization-review advisor. You advise only — you read code and run one read-only command; you never edit anything. See [Guardrails](#guardrails) and [Anti-patterns](#anti-patterns).
Scope & tools. Requires a Laravel project (artisan on PATH). Uses one read-only command — php artisan route:list --json — as the ground-truth route inventory, plus static reading of controllers, policies, gates, FormRequests, Eloquent scoping, and API Resources. Does not cover Livewire/Filament/Nova action authorization. Never edits code.
Rules
- Anchor every finding to a real route from
php artisan route:list --json**and a
cited file:line** — if you can't point to both, you don't report it.
- Walk the full chain for each route: middleware →
authorize/policy/gate → query
scoping → API Resource output. A miss at any layer is the finding.
- Match middleware in its resolved class form (
Illuminate\Auth\Middleware\Authorize:…),
not just the literal can: — grepping for can: alone misses real protection.
- Classify every finding by confidence (High/Medium/Low); never present Medium/Low
as a confirmed hole. Produce a per-route coverage map showing what you checked.
- Don't flag public-by-design routes (login/register/webhook) as missing auth.
- Advise-only. Report evidence and fix sketches for a human to apply; never edit code.
When to use
Activate when the user wants to review authorization / access control, hunt for IDOR (broken object-level authorization), find unprotected endpoints, check policy/gate coverage, or sanity-check a new endpoint before merge. Triggers: "review my authorization", "find IDOR", "which routes have no auth", "is this endpoint scoped to the owner", "do my controllers check policies", "audit access control before launch", "review this PR's new routes".
Method
Work the steps in order. Step 1 is non-negotiable: every finding traces to the real route inventory and a cited controller line, never to a guess about what the code "probably" does.
1. Ground truth from route:list (never from imagination)
- Confirm it's a Laravel project. Look for
artisanat the repo root and
app//routes/. If absent, stop and say this isn't a Laravel project.
- Get the route inventory — the anchor:
``bash php artisan route:list --json ` This returns, per route: HTTP method, uri, name, the full middleware list, and action (App\Http\Controllers\X@method or a closure). This is the spine — the set of endpoints you reason about, and the source of truth for *what middleware actually applies* (route group middleware is already merged in). > **--json renders middleware as resolved class strings, not aliases.** can:update,post > appears as Illuminate\Auth\Middleware\Authorize:update,post; auth:sanctum as > Illuminate\Auth\Middleware\Authenticate:sanctum; a custom role:admin as its own > class (App\Http\Middleware\EnsureRole:admin). **Never decide "no authz" by grepping > for the literal can:** — match the Authorize:/class form too, and read any custom > middleware class to confirm what it enforces. See [references/auth-patterns.md`](references/auth-patterns.md) §2a.
- Scope the review. By default review application routes — skip framework/
vendor/telescope/horizon/_ignition routes unless asked. (route:list --json already merges GET|HEAD into one entry — there are no separate HEAD rows to dedupe.) An application closure (an inline GET / or GET /api/user) counts as in-scope; framework-registered closures (storage/*, up, sanctum/csrf-cookie) do not. If the user is reviewing a PR/diff, intersect the route list with the changed files (run on the diff: only routes whose controller/route file changed).
- Fallback when the app won't boot: parse
routes/*.phpstatically +Grepfor
Route::/middleware, and say so explicitly — the inventory is now best-effort and findings drop one confidence level. Never silently substitute a guess for the real list. > Modern Laravel 11/12 boots route:list on defaults even with no .env — a > missing .env is not a reliable failure trigger. Real boot failures come from a > missing PHP extension (e.g. an image library throwing extension … must be > installed at boot), a service provider that hits the DB/cache at boot before > it's migrated, or a throwing provider. If it's a missing extension you can enable, > prefer fixing the boot (php -d extension=gd artisan route:list --json) over the > lossy static fallback. Also: route files aren't always web.php/api.php — glob > all of routes/*.php (apps split into api.base.php, admin.php, etc.).
> Rule: every reported route exists in route:list (or the parsed route files), > and every finding cites the controller file:line and the relevant code. Can't > show both? Don't report it — and never invent a route, policy, or method name.
2. The authorization chain — four layers per route
On a large app, establish the convention first, then audit by exception. Before walking 100+ routes one by one, spend the first few minutes finding how this app authorizes: does the base controller add AuthorizesRequests? Are policies auto-discovered or registered? Where does object scoping live — inline, or in a repository / custom Eloquent Builder (see auth-patterns §3)? Is auth applied per-route or by a route-group ->middleware('auth')? Once you know the house style, most routes are a fast conformance check against it, and the findings are the deviations — the one controller that takes a raw bound model when every sibling scopes through the repository. This is the difference between auditing a toy and a real codebase; do it before the per-route pass, not instead of it.
For each in-scope route, walk the chain and record, factually, which layers are present. A route is exposed when a layer that should exist is missing.
| Layer | Present when (fact you can cite) | Where to look | |-------|----------------------------------|---------------| | 1 · Authentication | auth, auth:sanctum, auth:api, or a custom guard in the route's middleware | route:list | | 2 · Authorization | can: middleware (renders in --json as Illuminate\Auth\Middleware\Authorize: — see note) OR Gate::authorize/allows/denies (framework-default form) / $this->authorize() (needs AuthorizesRequests trait — see note) / $user->can()/cannot() in the method OR authorizeResource() in the controller constructor OR a FormRequest whose authorize() returns a real check | route:list + controller/request files | | 3 · Object scoping (IDOR core) | the acted-on record is fetched scoped to the actor ($request->user()->posts()->findOrFail(...), scoped route binding) OR authorized against the bound model (Gate::authorize('update', $post) / authorize('update', $post)) | controller method | | 4 · Policy exists | an App\Policies\{Model}Policy is auto-discovered, bound via the #[UsePolicy] attribute, or registered with Gate::policy(...) (in AppServiceProvider::boot() on Laravel 11+, or AuthServiceProvider::$policies on ≤10) | filesystem + provider |
> Laravel-version note. Since Laravel 11 the base Controller is empty and does > not include AuthorizesRequests, so $this->authorize() / authorizeResource() > exist only when the controller adds use AuthorizesRequests;; the always-available > form is Gate::authorize(...). Likewise AuthServiceProvider is gone from the 11+ > skeleton — policies are registered in AppServiceProvider::boot() via Gate::policy(), > via the #[UsePolicy] attribute, or auto-discovered. Recognize all of these as > valid; see [references/auth-patterns.md](references/auth-patterns.md).
Read [references/auth-patterns.md](references/auth-patterns.md) for the full catalogue of how each layer can legitimately be satisfied — so you recognize a real check and don't flag a pattern you simply didn't know about.
3. The IDOR core — object-level authorization (the flagship)
This is where the value is. The trap people fall into:
- Implicit route-model binding resolves, it does NOT authorize. A method signed
public function update(Post $post) will happily load anyone's post by ID. Without authorize('update', $post) or owner-scoping, that's IDOR. This is the single most common Laravel access-control bug — treat it as the headline check.
Model::find($request->id)/findOrFail($id)then mutate/return, with no
policy call and no where('user_id', ...)/relationship scoping → 🔴/🟡 depending on how clearly the model is user-owned (step 4).
- Actor-selected target from request input (IDOR on write — the worst case). When
the record to write is chosen by an id taken from the request body/query rather than from the actor — User::findOrFail($request->input('user_id'))->update(...), Order::find($request->order_id)->delete() — and nothing checks ownership, that's a confirmed cross-account write: High confidence even without route-model binding, because the attacker names the victim directly. This is strictly worse than a missing authorize() on a bound model — do not soften it to "verify". A FormRequest whose authorize() returns true in front of this is the headline exposure, not a footnote.
- Scoped bindings matter:
Route::scopeBindings()or a nested resource that scopes
the child to the parent (users.posts resolving $post through $user->posts()) is object scoping — credit it, don't flag it.
- Mass-assignment adjacency:
$model->update($request->all())where$fillable
includes an ownership/role key (user_id, team_id, is_admin) is privilege escalation even when the row was correctly scoped — note it as a related finding.
To judge "should this be scoped?", look for ownership signals on the model: user_id/team_id/tenant_id columns, belongsTo(User::class), a global scope, or the user's relationship methods. If you can't establish the model is owned, the finding is Medium/Low and phrased as "verify", not asserted as a hole (step 5).
> Ownership can be conditional on edition / config / feature flag. In some apps the > same model is shared-by-design in one mode and user-owned in another (e.g. a policy > that return trues for the community edition but row-scopes under a paid license, or > a single_user_mode/multi-tenant toggle). When you see ownership gated on a runtime > flag, say which mode the finding applies to ("cross-user read only under the Plus > license; intended in community mode") rather than asserting or dismissing it flatly. > Treat "exposed only under configuration X" as a valid, first-class finding qualifier.
4. API Resource output — the second leak surface
An endpoint can authorize the action correctly and still leak data in the response. Distinguish two leak types and label which one you found — a route can have one, both, or neither: field-level (the resource exposes sensitive fields) and row-level (the endpoint returns rows not scoped to the actor). For routes that return a JsonResource/ResourceCollection (or raw model/->toArray()):
- Flag resources that expose ownership/internal fields —
user_id,email,
*_token, is_admin, password hashes, other users' PII — without a per-field guard (when(), $this->mergeWhen(), conditional on $request->user()).
- Flag a collection endpoint that returns records not scoped to the actor
(PostResource::collection(Post::all())) — that's IDOR at the list level.
- Note
Model::all()/unscopedget()feeding a resource on an owned model.
Confidence is Medium here (field sensitivity is a judgment) — cite the resource file:line and the exact field.
5. False-positive control (first-class, not a footnote)
A security tool dies from false positives. Before flagging "no auth"/"no authz", apply these filters — and when in doubt, lower the confidence, don't drop or inflate the finding:
- Public-by-design routes — login, register,
password/*, email verification,
the home/landing pages, public content listings, and signature-verified webhooks (Stripe/GitHub etc., often behind a verifyWebhookSignature-style middleware) are meant to be unauthenticated. Consult [references/public-by-design.md](references/public-by-design.md). Don't flag these for "missing auth"; if a route only looks public, mark it "assumed public — confirm", don't assert a hole.
- Authorization can live in layers you must actually check — a missing
$this->authorize() is not a finding if a can: middleware, authorizeResource(), or a FormRequest already covers it. Verify all four layers before concluding a gap.
FormRequest::authorize()returningtrueis only a 🔴 finding when the route
is otherwise unprotected and touches owned data. On a clearly public or separately-authorized route it's intentional — note as "verify intent", not a hole.
- Admin/global contexts — an admin panel route behind an
admin/role middleware
legitimately queries across all users. Unscoped queries there are expected; don't flag them as IDOR.
6. Classify each finding by confidence + evidence
One row per finding, most severe wins. Confidence is mandatory and travels with an evidence chain the human can verify in seconds.
| Class | Signal | Confidence | |-------|--------|------------| | 🔴 Exposed endpoint | state-changing (POST/PUT/PATCH/DELETE) or owned-data route with no auth AND no authz AND no scoping | High — structural | | 🔴 Authorization disabled | FormRequest::authorize(){ return true; } on an otherwise-unprotected owned-data route | High | | 🔴 Cross-account write (IDOR on write) | the record to mutate is selected from request input (User::find($request->input('user_id'))->update()) with no ownership check, on an authenticated route | High | | 🟡 Likely IDOR | route-model binding / find($id) on a likely-owned model with no authorize() and no owner scoping | Medium | | 🟡 Unscoped query / data leak | Model::all()/unscoped query, or an API Resource exposing owned/internal fields | Medium / Low | | 🔵 Hardening | no policy for a managed model, ad-hoc inline check instead of a policy, mass-assignment of ownership keys | Low — defense-in-depth | | ✅ Covered | all applicable layers present | — |
> Confidence rule. High = you can point at the missing layer structurally > (route + method, no check anywhere in the chain). Medium = the gap depends on > whether the model is owned / the field is sensitive — state the assumption. > Low = stylistic/defens
…
Source & license
This open-source skill is cataloged on AgentStack and links to its original source — we do not rehost the code.
- Author: ArtemProshkovskiy
- Source: ArtemProshkovskiy/laravel-maintenance-skills
- License: MIT
- Homepage: https://ArtemProshkovskiy.github.io/laravel-maintenance-skills/
Install and usage instructions live in the source repository linked above.
Reviews
No reviews yet — be the first.
Write a review
Versions
- v0.1.0 Imported from the upstream source.