Skip to content

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 Found

Invoice 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-200

Two 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-200

update() 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       404

Gate::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...