These notes are public, opinionated, and evolving — read abdelkader.ma for the long-form posts.
PHP SecurityAuth & Authz Review

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:

  1. Missing authentication — public endpoint that should be auth’d
  2. Missing authorization — auth’d endpoint that should be role-gated
  3. IDOR (Insecure Direct Object Reference) — endpoint actions on an object without verifying the current user owns it
  4. 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:

  1. Mass assignmentUser::create($request->all()) fills a fillable role column
  2. State-changing GETGET /users/delete?id=42 works via CSRF
  3. Inconsistent gating between methodseditPost checks ownership; deletePost forgets

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:

RouteAuthn?Role / scope?Ownership check?Mass-assign safe?State-changing method?
POST /orders/{id}/refund✅ user✅ admin OR sellerorder.seller_id == session.user_idn/a✅ POST
GET /users/{id}/avatar❌ public— (public asset)n/a✅ GET (read)
POST /users/{id}✅ userMISSING — IDOR❌ — role is fillable✅ POST
GET /admin/delete?id=…✅ user✅ adminn/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 auth middleware 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:

  • $fillable should 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_token should 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.php

If 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” — editPost checks ownership, deletePost forgets
  • 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_id without 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);