A 403 Tells Them It Exists
September 26, 2026 • 4 min read
Log in as a customer, open one of your invoices, then 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. You weren’t allowed to see either of them, and the app still told you which was which.
That’s Laravel’s default. When a policy method returns false, the response is a 403, and a record that isn’t there is a 404.
The numbers below come from a real run: 137 invoices and a logged-in customer who owns one of them.
Counting someone else’s invoices
The policy is the one most apps have:
public function view(User $user, Invoice $invoice): bool
{
return $invoice->user()->is($user) || $invoice->accountant()->is($user);
}user() and accountant() are belongsTo relations on the invoice. Calling is() on the relation compares the invoice’s foreign key with the user’s key without loading anything, using whichever keys the relation is defined with, so it doesn’t care what your primary key is called. $user->is($invoice->user) works too, but it has to load the user first, which is a query every time the policy runs.
The customer owns invoice 12. They request ids 1 to 200:
1 x 200 id 12, their own
136 x 403 ids 1-137
63 x 404 ids 138-200Two hundred requests, and they know you’ve issued 137 invoices and which ids are real. Run the same loop next month and the difference is your invoice volume. Swap invoices for users or projects and it’s your customer count.
Switching to UUIDs makes that loop useless, since there’s nothing to count. But it only covers routes that look records up by id. Slugs and usernames are readable on purpose, so they’re easy to guess, and a 403 on /projects/acme-rebrand confirms that project exists just the same.
Deny it as not found
A policy can say which status to use when it refuses. Response::denyAsNotFound() arrived in Laravel 9.20 following a pull request from @timacdonald:
final class InvoicePolicy
{
public function view(User $user, Invoice $invoice): Response
{
return $invoice->user()->is($user) || $invoice->accountant()->is($user)
? Response::allow()
: Response::denyAsNotFound();
}
public function update(User $user, Invoice $invoice): Response
{
if ($invoice->user()->is($user)) {
return Response::allow();
}
return $invoice->accountant()->is($user)
? Response::deny('You can view this invoice but not change it.')
: Response::denyAsNotFound();
}
}The same 200 requests:
1 x 200 id 12, their own
199 x 404 ids 1-200update() still has a 403 in it, and that’s deliberate. The accountant can open the invoice, so telling them they can’t edit it gives nothing away. The rule is 404 when the person shouldn’t know the record exists, and 403 when they already do.
HTTP allows this. RFC 9110 says: “An origin server that wishes to ‘hide’ the current existence of a forbidden target resource MAY instead respond with a status code of 404 (Not Found).” GitHub works the same way: “GitHub uses a 404 Not Found response instead of a 403 Forbidden response to avoid confirming the existence of private repositories.”
The Form Request puts the 403 back
That policy is only half the fix, because it depends on where you ask it. Here’s the same policy checked four ways, against invoice 1 and an id that doesn’t exist:
stranger accountant owner no such id
GET Gate::authorize('view') 404 200 200 404
GET can:view,invoice 404 200 200 404
PUT Form Request, authorize(): bool 403 403 204 404
PUT Form Request, Gate::inspect() 404 403 204 404Gate::authorize() and the can middleware both pass the policy’s status through. The Form Request written the usual way doesn’t:
public function authorize(): bool
{
return $this->user()?->can('update', $this->route('invoice')) ?? false;
}can() turns the policy’s response into true or false, and a Form Request that gets false throws a plain AuthorizationException, which is always a 403. The stranger is back to learning which invoices exist, this time on every write. The accountant’s message is gone too, replaced by “This action is unauthorized.”
Return the policy’s response instead of a boolean, and the Form Request passes both the status and the message through:
public function authorize(): Response
{
return Gate::inspect('update', $this->route('invoice'));
}Same status, different message
With the status codes fixed, an API still gives it away. These are the two JSON 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 from route model binding, and Laravel passes the model-not-found message straight through. The HTML error pages were byte for byte identical, so this only affects JSON.
A render callback that returns response()->json(['message' => 'Not Found'], 404) looks like the fix, but I tried it and the bodies still differed. Laravel pretty-prints its own error JSON and response()->json() doesn’t, so one was 30 bytes and the other 23. Mapping the exception instead sends both through the same code:
->withExceptions(function (Exceptions $exceptions): void {
$exceptions->map(
ModelNotFoundException::class,
fn (ModelNotFoundException $e) => new NotFoundHttpException('Not Found', $e),
);
})Both responses are now identical, down to the byte:
GET /invoices/1 404 30 bytes sha1 625da04927e7...
GET /invoices/138 404 30 bytes sha1 625da04927e7...