CWE-862: Missing Authorization - PHP
Overview
In Laravel, Missing Authorization typically appears as a controller method that never calls $this->authorize(), Gate::allows(), or a Policy method before performing a sensitive action, reachable through auth middleware that confirms login and nothing more. It also appears as a new route in routes/web.php or routes/api.php that omits the authorization check its sibling routes carry. Symfony applications show the same gap as a controller action missing a denyAccessUnlessGranted() call or a Voter that was never wired up. The fix is a Policy (Laravel) or Voter (Symfony) that encodes the role/ownership rule once, invoked explicitly at the start of the action or attached to the route.
Common Vulnerable Patterns
Auth Middleware With No Authorization Check
// VULNERABLE - `auth` middleware confirms login, nothing checks role or ownership
class OrderController extends Controller
{
public function refund(Request $request, Order $order)
{
$order->refund(); // any authenticated user can call this
return response()->json($order);
}
}
// routes/api.php
Route::post('/orders/{order}/refund', [OrderController::class, 'refund'])
->middleware('auth');
// Attack: any authenticated user calls POST /orders/500/refund directly
// Result: the refund executes with no role or ownership check
Why this is vulnerable: The auth middleware confirms the request carries a valid session or token, but the controller method never checks whether this specific user is allowed to refund this specific order.
Symfony Controller With No Voter Check
// VULNERABLE - IsGranted attribute is absent, no denyAccessUnlessGranted() call
#[Route('/orders/{id}/cancel', methods: ['POST'])]
public function cancel(Order $order): Response
{
$order->cancel(); // any authenticated user can call this
return $this->json(['status' => 'cancelled']);
}
// Attack: any authenticated user sends POST /orders/500/cancel
// Result: the cancellation executes with no authorization check
Why this is vulnerable: Symfony's security firewall confirms the request is authenticated, but nothing in the controller calls denyAccessUnlessGranted() or uses #[IsGranted(...)] to check whether the caller may cancel this specific order.
Secure Patterns
Laravel Policy With authorize()
// SECURE - OrderPolicy defines the authorization rule for this model
use Illuminate\Auth\Access\Response;
class OrderPolicy
{
public function refund(User $user, Order $order): Response
{
if ($user->id === $order->user_id || $user->hasRole('admin')) {
return Response::allow();
}
// Same answer as an order that does not exist - see below
return Response::denyAsNotFound();
}
}
// SECURE - controller calls authorize() before performing the action
class OrderController extends Controller
{
public function refund(Request $request, Order $order)
{
$this->authorize('refund', $order);
$order->refund();
return response()->json($order);
}
}
Why this works: $this->authorize('refund', $order) invokes OrderPolicy::refund() with the specific $order instance and the authenticated user, throwing AuthorizationException if the check fails. Because the policy compares $order->user_id to the caller, an attacker who is authenticated but doesn't own the order - and isn't an admin - is rejected regardless of the order ID they request.
Returning Response::denyAsNotFound() rather than false is what makes the two denials answer alike. Route-model binding already answers 404 for an ID with no row behind it, so a policy that denies with the default 403 splits one decision across two response paths: 403 confirms the order exists and 404 confirms it does not, and an attacker walks the ID space reading which is which. It costs the legitimate caller nothing to close that - a user who cannot see the order gains nothing from learning it is there.
denyAsNotFound() matches the status and not the exception. It throws Illuminate\Auth\Access\AuthorizationException carrying a 404 status, which is a different class from the ModelNotFoundException the binding raises, and the two render different bodies. Normalizing that is the second half of the fix, below.
The not-found denial applies where the caller names a resource. A Gate check that does not depend on one, like the manage-orders gate below, should still answer 403, because refusing it discloses nothing about what exists.
Route-Level Authorization With the can Middleware
// SECURE - authorization requirement is visible directly in the route definition
Route::post('/orders/{order}/refund', [OrderController::class, 'refund'])
->middleware(['auth', 'can:refund,order']);
Why this works: The can:refund,order middleware resolves the route-model-bound $order and calls the same OrderPolicy::refund() method before the controller action runs, so the check cannot be skipped by adding a new controller method that forgets to call authorize() - the requirement lives in the route file where it is easy to audit against sibling routes. Because it is the same policy method, the denyAsNotFound() response above applies here too rather than needing to be repeated in the route.
Normalizing the 404 Body
<?php
// SECURE - every 404 leaves by the same door, whatever raised it
// bootstrap/app.php
use Illuminate\Foundation\Application;
use Illuminate\Foundation\Configuration\Exceptions;
use Illuminate\Http\Request;
use Symfony\Component\HttpKernel\Exception\HttpExceptionInterface;
return Application::configure(basePath: dirname(__DIR__))
->withExceptions(function (Exceptions $exceptions) {
$exceptions->render(function (HttpExceptionInterface $e, Request $request) {
if ($e->getStatusCode() === 404 && $request->expectsJson()) {
return response()->json(['message' => 'Not Found'], 404);
}
return null;
});
})
->create();
Why this works: Matching the status is not the same as matching the response. With APP_DEBUG=false, a policy denying through denyAsNotFound() renders {"message": "Not Found"}, while route-model binding failing to find a row renders {"message": "No query results for model [App\Models\Order] 999999"}. Both are 404, so a test that asserts only the status passes - and a caller who reads the body still learns which IDs are real, now with the model class and the ID quoted back. Rendering every 404 through one callback removes the difference for every route at once, rather than leaving each policy to remember it.
Assert the body, not only the status. This is the case where "both are 404" is true and the endpoint is still an oracle.
Symfony Voter
// SECURE - a Voter encodes the ownership rule once, reusable across the app
use Symfony\Bundle\SecurityBundle\Security;
class OrderVoter extends Voter
{
public function __construct(private readonly Security $security)
{
}
protected function supports(string $attribute, mixed $subject): bool
{
return $attribute === 'CANCEL' && $subject instanceof Order;
}
protected function voteOnAttribute(string $attribute, mixed $subject, TokenInterface $token): bool
{
$user = $token->getUser();
if (!$user instanceof User) {
return false;
}
/** @var Order $order */
$order = $subject;
return $order->getOwnerId() === $user->getId() || $this->security->isGranted('ROLE_ADMIN');
}
}
// SECURE - the controller answers 404 for an order this caller cannot see
class OrderController extends AbstractController
{
#[Route('/orders/{id}/cancel', methods: ['POST'])]
public function cancel(Order $order): Response
{
if (!$this->isGranted('CANCEL', $order)) {
throw $this->createNotFoundException();
}
$order->cancel();
return $this->json(['status' => 'cancelled']);
}
}
Why this works: isGranted('CANCEL', $order) routes the decision through every registered Voter that supports the CANCEL attribute for an Order subject, so the rule is defined once and reused wherever the same check is needed - including via #[IsGranted('CANCEL', 'order', statusCode: 404)] as a controller attribute instead of an inline call. Security is constructor-injected because Voter provides no such property; autowiring supplies it, and the role check for ROLE_ADMIN goes through the same authorization system as everything else rather than reading the user's roles array directly.
The controller throws a not-found exception rather than calling denyAccessUnlessGranted(), which would answer 403. The {id} in the route is resolved to an Order by the entity value resolver, which already answers 404 for an ID with no row behind it - so a 403 from the voter tells the caller that this one is real, and the pair becomes an existence oracle to walk the ID space with. Both denials have to leave by the same door. A voter guarding an action that does not name a resource can still answer 403, because that refusal discloses nothing about what exists.
Framework-Specific Guidance
Laravel Gates for Non-Model Actions
// SECURE - Gate::define for an action not tied to a specific model instance
Gate::define('manage-orders', function (User $user) {
return $user->hasRole('admin') || $user->hasRole('order-manager');
});
// SECURE - check the gate before a bulk or non-entity-specific action
if (Gate::denies('manage-orders')) {
abort(403);
}
Why this works: Gates are the right tool when the check does not depend on a specific model instance - such as an action that manages orders in aggregate rather than one order at a time - keeping the rule centralized rather than duplicated inline.
Symfony #[IsGranted] Attribute
// SECURE - attribute-based check, resolved before the method body executes
#[Route('/orders/{id}/cancel', methods: ['POST'])]
#[IsGranted('CANCEL', 'order', statusCode: 404)]
public function cancel(Order $order): Response
{
$order->cancel();
return $this->json(['status' => 'cancelled']);
}
Why this works: #[IsGranted(...)] runs the same Voter logic as an inline isGranted() call but declaratively, so the requirement is visible directly above the method signature and cannot be accidentally skipped by a code path that reaches the method body without the explicit call. statusCode: 404 (Symfony 6.2 and later) makes the attribute throw an HttpException with that status instead of the default AccessDeniedException, which is how the declarative form matches the missing-order answer the value resolver already gives. Omit it on an action that does not name a resource, where 403 is the right answer.
Testing
- Normal: call the route as a user who owns the resource or holds the required role; confirm success.
- Boundary: request a resource owned by someone else, then request an ID that does not exist, and confirm the two responses are identical - same status and same body. Under the patterns above both are 404, but Laravel's default bodies differ until the 404 rendering is normalized, so assert
assertContent()or comparegetContent()between the two rather than asserting the status alone. A 403 for one and a 404 for the other is an existence oracle whichever way round they are; so is a matching status over two different bodies. - Malicious: call the route directly with a feature test client, bypassing any UI, as an authenticated user with no assigned role or ownership; confirm the request is refused, not 200.
- Use Laravel's
actingAs($user)withassertNotFound()for the ownership path andassertForbidden()for a role-only gate, or Symfony'sWebTestCasewith a logged-in test client, to assert the two paths independently. - Re-run any SAST/DAST scan that reported the finding to confirm it no longer triggers.
Common Pitfalls
- Catching and suppressing
AuthorizationException: Wrapping$this->authorize(...)in a try/catch that swallows the exception instead of letting it propagate to Laravel's default 403 handling - this silently allows the action to proceed after the failed check. - Defining a Policy but never calling it: Creating
OrderPolicyand registering it, but the controller method never calls$this->authorize()or uses thecanmiddleware - the policy exists but enforces nothing. - Role check with no ownership comparison: A Policy or Voter method that only checks
$user->hasRole('customer')without comparing the resource's owner - any customer can then act on any other customer's record. - A policy denying with 403 behind route-model binding: The binding answers
404for an ID with no row, so a plainreturn falsefrom the policy answers403for one that exists - the pair tells a caller which IDs are real. Deny withResponse::denyAsNotFound()in Laravel, orstatusCode: 404on Symfony's#[IsGranted], wherever the caller names a resource. - Matching the status and stopping there:
denyAsNotFound()and a missing row both answer 404, but Laravel renders them from different exceptions and the default bodies differ - one of them names the model class and the ID. Normalize the 404 response as well, and assert the body in the test. - Confusing the
authmiddleware alias with authorization: Assumingmiddleware('auth')is sufficient protection for a sensitive route, when it only confirms authentication - it must be paired with acan:middleware or an in-methodauthorize()/denyAccessUnlessGranted()call.