A01: Broken Access Control (Blog)
What is broken access control?
Broken Access Control happens when an application lets a user perform an action or access data they should not be able to.
In a blog app this often looks like: "the user is authenticated" is treated as sufficient, while author-only actions and per-record ownership are not enforced consistently.
Typical Laravel failure (blog)
- You require
auth()but forget role/permission rules (any logged-in user can create posts). - You allow users to update/delete posts or comments they do not own.
- You rely on route middleware alone and skip record-level checks.
- You forget policy checks in Actions, controllers, and API endpoints.
Impact
- Horizontal privilege escalation (read/update/delete other users' records).
- Private/draft content leakage.
- ID enumeration (guessing sequential IDs to discover hidden resources).
Laravel 12 remediation (blog rules)
Laravel 12 officially documents Policies and provides denyAsNotFound() as a first-class pattern.
- Public read: keep read endpoints unauthenticated.
- Writing posts: restrict
create/updateto authorized users (e.g.is_authoror a permission). - Writing comments: allow any authenticated user to
create. - Editing comments: restrict to owner (or admin).
- Use Gates for cross-cutting permissions (admin-only features, feature flags, global roles).
- Prefer returning
404instead of403for hidden resources when enumeration is a risk, usingdenyAsNotFound(). - Scope queries by user/ownership before fetching records.
- In Actions, make authorization the first invariant, not an afterthought.
Concrete fix
Stop trusting route middleware as "authorization". Fix access control at the record level.
- Only authorized users can create posts:
use App\Example\Models\Post;use App\Models\User;use Illuminate\Support\Facades\Gate; public function handle(User $user, array $data): Post{ Gate::authorize('create', Post::class); return Post::query()->create([ 'user_id' => $user->id, 'title' => $data['title'], 'body' => $data['body'] ?? null, 'status' => 'draft', ]);}
- Public read: keep read routes unauthenticated (everyone can read posts/comments):
use Illuminate\Support\Facades\Route; Route::get('/posts', [PostController::class, 'index']);Route::get('/posts/{post}', [PostController::class, 'show']);
- Only owners/admins can update posts (deny as not found):
use App\Example\Models\Post;use App\Models\User;use Illuminate\Auth\Access\Response; public function update(User $user, Post $post): Response{ $isAdmin = (bool) $user->getAttribute('is_admin'); $isAuthor = (bool) $user->getAttribute('is_author'); return ($isAdmin || ($isAuthor && $user->id === $post->user_id)) ? Response::allow() : Response::denyAsNotFound();}
- For comments: any authenticated user can create, but never trust client
user_id:
use App\Example\Actions\CreateCommentAction;use App\Example\Models\Post; public function store(StoreCommentRequest $request, Post $post, CreateCommentAction $action){ $action->handle($request->user(), $post, $request->string('body')->toString()); return back();}
- Put comment auth in a Form Request:
// StoreCommentRequest.php public function authorize(): bool{ return $this->user() !== null;}
This keeps "who can do this" adjacent to "what is valid input" and prevents accidental bypasses.
Design pattern angle
If your codebase uses "Actions" heavily, make authorization the first invariant. The goal is: one entry point, same invariants, every time (authorize, ownership, state constraints).
use Illuminate\Support\Facades\Gate; final class Authorize{ public static function for(string $ability, mixed $resource, callable $next): mixed { Gate::authorize($ability, $resource); return $next(); }} // Example (Action)final class UpdatePostAction{ public function handle(User $user, Post $post, array $data): void { $post->fill($data)->save(); }} // In a controller / endpoint$action = app(UpdatePostAction::class); return Authorize::for('update', $post, function () use ($action, $user, $post, $data) { $action->handle($user, $post, $data); return redirect()->back();});
Refactor away scattered ownership checks:
// Bad// if ($user->id !== $post->user_id) abort(403); // GoodGate::authorize('update', $post);