# Laravel Authorization Review

> >-

- **Type:** Skill
- **Install:** `agentstack add skill-artemproshkovskiy-laravel-maintenance-skills-laravel-authorization-review`
- **Verified:** Yes — security-reviewed for prompt injection and unsafe behavior
- **Seller:** [ArtemProshkovskiy](https://agentstack.voostack.com/s/artemproshkovskiy)
- **Installs:** 0
- **Category:** [Agent Skills](https://agentstack.voostack.com/c/agent-skills)
- **Latest version:** 0.1.0
- **License:** MIT
- **Upstream author:** [ArtemProshkovskiy](https://github.com/ArtemProshkovskiy)
- **Source:** https://github.com/ArtemProshkovskiy/laravel-maintenance-skills/tree/main/skills/laravel-authorization-review
- **Website:** https://ArtemProshkovskiy.github.io/laravel-maintenance-skills/

## Install

```sh
agentstack add skill-artemproshkovskiy-laravel-maintenance-skills-laravel-authorization-review
```

Requires the [AgentStack CLI](https://agentstack.voostack.com/docs/cli). Works with Claude Code, Cursor, and any MCP-compatible agent.

## 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)

1. **Confirm it's a Laravel project.** Look for `artisan` at the repo root and
   `app/`/`routes/`. If absent, stop and say this isn't a Laravel project.
2. **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.
3. **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).
4. **Fallback when the app won't boot:** parse `routes/*.php` statically + `Grep` for
   `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 true`s 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()`/unscoped `get()` 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()` returning `true`** is 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](https://github.com/ArtemProshkovskiy)
- **Source:** [ArtemProshkovskiy/laravel-maintenance-skills](https://github.com/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.

## Pricing

- **Free** — Free

## Security capabilities

Automated source analysis of v0.1.0 — what this tool can access:

- **Network access:** no
- **Filesystem access:** no
- **Shell / process execution:** no
- **Environment & secrets:** yes
- **Dynamic code execution:** no

*"Yes" means the capability is present in the source — more access means more to trust, not that it is unsafe.*


## Versions

- **0.1.0** — security scan: passed — Imported from the upstream source.

## Links

- Listing page: https://agentstack.voostack.com/l/skill-artemproshkovskiy-laravel-maintenance-skills-laravel-authorization-review
- Seller: https://agentstack.voostack.com/s/artemproshkovskiy
- Browse the marketplace: https://agentstack.voostack.com/browse

---
Listed on AgentStack — the marketplace for AI agent skills and MCP servers. Every listing is security-reviewed. Creators keep 70%.
