Hide Private Records with denyAsNotFound() in Laravel
September 26, 2026 • 3 min read
If your app has invoices, orders or anything else that belongs to a user, you’ve probably written a policy like this one:
public function view(User $user, Invoice $invoice): bool
{
return $invoice->user()->is($user);
}Calling is() on the relation compares the invoice’s user_id with the user’s key, so it doesn’t need to load the user first.
Anyone who isn’t the owner gets a 403 Forbidden, so the invoice stays private. The problem is what the 403 itself tells them. Log in as a customer who owns invoice 12 and change the id in the URL:
GET /invoices/12 200 OK
GET /invoices/1 403 Forbidden
GET /invoices/138 404 Not FoundInvoice 1 exists and belongs to someone else. Invoice 138 doesn’t exist at all. The customer wasn’t allowed to see either of them, and the app still told them which was which.
Let’s look at how much that gives away, and how to make both answers the same.
How much a 403 gives away
I requested every id from 1 to 200 as that customer:
| Response | Requests | Ids |
|---|---|---|
200 | 1 | 12, the customer’s own |
403 | 136 | every other id from 1 to 137 |
404 | 63 | 138 to 200, which don’t exist |
The customer now knows you’ve issued 137 invoices. Run the same loop next month and the difference is your invoice volume.
UUIDs would make this loop useless, but slugs and usernames are easy to guess, and a 403 confirms those just the same.
Returning a 404 with denyAsNotFound()
Laravel 9.20 added Response::denyAsNotFound() in a pull request from @timacdonald. It lets a policy deny with a 404 instead:
use Illuminate\Auth\Access\Response;
public function view(User $user, Invoice $invoice): Response
{
return $invoice->user()->is($user)
? Response::allow()
: Response::denyAsNotFound();
}With that in place, the same loop gives:
| Response | Requests | Ids |
|---|---|---|
200 | 1 | 12, the customer’s own |
404 | 199 | every other id from 1 to 200 |
Every invoice the customer doesn’t own now looks like one that doesn’t exist. RFC 9110 allows a server to do this, and GitHub does the same for private repositories.
A 403 is still fine when the person already knows the record exists, like a colleague who can view an invoice but not edit it. Telling them they can’t edit it gives nothing away.
Form Requests that return a boolean
Say update() in the same policy also denies with denyAsNotFound(). A Form Request written the usual way still turns that into a 403:
public function authorize(): bool
{
return $this->user()?->can('update', $this->route('invoice')) ?? false;
}can() reduces the policy’s 404 to false, and a Form Request that gets false throws a plain AuthorizationException, which is always a 403. Return the policy’s response instead, and its status comes through:
use Illuminate\Auth\Access\Response;
use Illuminate\Support\Facades\Gate;
public function authorize(): Response
{
return Gate::inspect('update', $this->route('invoice'));
}Gate::authorize() in a controller and the can middleware already pass the status through, so this only affects Form Requests.
The JSON message still differs
With the status codes fixed, an API still gives it away. These are the two responses with APP_DEBUG=false:
GET /invoices/1 404
{
"message": "Not Found"
}
GET /invoices/138 404
{
"message": "No query results for model [App\\Models\\Invoice] 138"
}The first comes from the policy. The second comes from route model binding, and Laravel passes its message straight through. The HTML error pages were already identical, so this only matters for JSON.
Mapping that exception to a plain NotFoundHttpException in bootstrap/app.php makes the two responses identical, byte for byte:
use Illuminate\Database\Eloquent\ModelNotFoundException;
use Illuminate\Foundation\Configuration\Exceptions;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
->withExceptions(function (Exceptions $exceptions): void {
$exceptions->map(
ModelNotFoundException::class,
fn (ModelNotFoundException $e) => new NotFoundHttpException('Not Found', $e),
);
})So for any record that should stay private:
- deny with
denyAsNotFound()in the policy - return
Gate::inspect()from Form Requests instead of a boolean - map
ModelNotFoundExceptionso every404has the same message