Authentication & Authorization Review
Most authn / authz bugs aren’t in the checks that exist — those tend to get reviewed. They’re in the checks that don’t exist: handlers that assume the caller is the right user, admin pages reachable without a role check, “user_id” pulled from the request rather than the session, new endpoints copy-pasted from non-admin templates that forgot the gate.
This is a meta-checklist that sits on top of the other PHP review notes.
Why
The four canonical bug shapes:
- Missing authentication — public endpoint that should be auth’d
- Missing authorization — auth’d endpoint that should be role-gated
- IDOR (Insecure Direct Object Reference) — endpoint actions on an object without verifying the current user owns it
- Privilege escalation — endpoint that lets the user write to a
field they shouldn’t (
role=admin,is_admin=1,tenant_id=…)
The lower-frequency ones to also check:
- Mass assignment —
User::create($request->all())fills a fillablerolecolumn - State-changing GET —
GET /users/delete?id=42works via CSRF - Inconsistent gating between methods —
editPostchecks ownership;deletePostforgets
Search patterns
# Role / permission decisions
rg -n 'isAdmin|is_admin|->role|->permissions?|->can\(|->cannot\(|->hasRole\(|->hasPermission\(|->ability\(' --type=php
# Auth gates and middleware
rg -n 'auth\(\)|->guest\(\)|->check\(\)|Auth::user|Auth::id|Auth::check|middleware\([''"](auth|role:|permission:|can:)' --type=php
# Session / token reads
rg -n '\$_SESSION\[|\$_COOKIE\[' --type=php
# IDOR-prone shapes — user_id / account_id taken from REQUEST
rg -n '\$_(GET|POST|REQUEST)\[[''"](user_?id|account_?id|tenant_?id|owner_?id|customer_?id)[''"]\]' --type=php
# Mass assignment
rg -n '->fill\(|->update\(\$request|->create\(\$request|->merge\(\$request' --type=php
rg -n 'protected\s+\$fillable\s*=' --type=php
rg -n 'protected\s+\$guarded\s*=' --type=php
# State-changing GET routes (Laravel)
rg -n 'Route::get\(' --type=php | rg -i 'delete|remove|destroy|create|update|store'How to use this
For each controller / route handler, fill a row in this table:
| Route | Authn? | Role / scope? | Ownership check? | Mass-assign safe? | State-changing method? |
|---|---|---|---|---|---|
POST /orders/{id}/refund | ✅ user | ✅ admin OR seller | ✅ order.seller_id == session.user_id | n/a | ✅ POST |
GET /users/{id}/avatar | ❌ public | — | — (public asset) | n/a | ✅ GET (read) |
POST /users/{id} | ✅ user | — | ❌ MISSING — IDOR | ❌ — role is fillable | ✅ POST |
GET /admin/delete?id=… | ✅ user | ✅ admin | ✅ | n/a | ❌ — GET mutates state |
The columns where you write ❌ are the bugs to prove out.
Audit focus
Where do user IDs come from?
The only safe answer is the session. Anything else is IDOR-prone:
// ❌ Bad
$user_id = $_POST['user_id'];
$user = User::find($user_id);
// ✅ Good
$user_id = $_SESSION['user_id'];
$user = User::find($user_id);
// ✅ Also good — explicit ownership check
$user_id = $_SESSION['user_id'];
$post = Post::findOrFail($_POST['post_id']);
if ($post->author_id !== $user_id) abort(403);Are role checks present on every protected endpoint?
Map the route file. Every entry should either:
- Have an
authmiddleware applied at the route-group level, and an explicit role middleware where needed, or - Be intentionally public (and documented as such).
Default-deny: a single route group with auth + role middleware, opt-in for public routes.
Mass assignment
For each model:
$fillableshould list the minimum set of attacker-writable columns.$guarded = []is a footgun — it means “everything is fillable.”role,is_admin,tenant_id,email_verified_at,password_hash,remember_token,api_tokenshould not be in$fillable.
State-changing GETs
Any GET route that mutates server state:
- Vulnerable to CSRF via
<img src="…"> - Cacheable by intermediate proxies → unwanted retries
- Violates HTTP semantics
Convert to POST/DELETE with CSRF protection.
Ownership “same shape, different action”
When you find a check, search the file for sibling actions:
grep -n 'function (edit|update|destroy|delete|show|download|export)' Controller.phpIf edit and update check ownership but destroy and export don’t,
that’s two bugs.
Soft-deleted / archived
Eloquent softDeletes, Doctrine SoftDeleteable — queries that don’t
apply the scope return rows the user shouldn’t see. Search for
withTrashed(), ->ignoreSoftDeletes(), findWithDeleted() and audit
whether they leak archived data.
File-download endpoints
Storage::download($_GET['file']) is the canonical bug — any path under
storage/ is downloadable regardless of owner. Lookup by ID + ownership
check, never by user-supplied path.
API vs web middleware drift
In frameworks with separate route files (routes/web.php,
routes/api.php), the middleware stacks differ. New API routes inherit a
different default stack than web routes — often without CSRF, sometimes
without auth.
Common gaps to flag
- Admin endpoints copied from non-admin templates that miss the role check
- “Same shape, different action” —
editPostchecks ownership,deletePostforgets - Soft-deleted / archived rows returned because the query skipped the scope
- File-download endpoints serving any path under
storage/ - API routes with a different middleware stack than web routes
- “Internal” endpoints assumed unreachable but exposed via reverse-proxy mis-config
- Webhook endpoints accepting an arbitrary
tenant_idwithout verifying the signature is for that tenant
When in doubt, write down the answer to “who is allowed to do this, and what is the code that enforces that?” If you can’t point at the line, there’s a gap.
Fix patterns
- Centralise authorization. Laravel: Policies. Symfony: Voters. Custom:
a single
authorize($user, $action, $object)helper called at the top of every handler. - Take identity from the session, period:
$userId = $_SESSION['user_id']; // NOT $_POST['user_id'] - Default-deny on routes: auth + role middleware at the group level, opt-in for public routes.
- Explicit
$fillable— never$guarded = []. - Ownership check before acting:
$post = Post::findOrFail($id); $this->authorize('update', $post); $post->update($validated);
Related
- All other PHP bug classes graduate to privilege escalation when
authorization is absent — type juggling on an
is_adminflag, IDOR via intval on a user_id, extract overwriting$_SESSION['role'], etc. - Type Juggling
- intval Bypass
- extract() Variable Injection