Skip Content

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 / update to authorized users (e.g. is_author or 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 404 instead of 403 for hidden resources when enumeration is a risk, using denyAsNotFound().
  • 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.

  1. 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',
]);
}
  1. 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']);
  1. 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();
}
  1. 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();
}
  1. 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);
 
// Good
Gate::authorize('update', $post);

References