Introduction
When building a web application, you typically add authorisation checks to ensure that users can only access resources they are permitted to. For example, on a blogging platform, you'd want to ensure that users can only edit or delete their own posts, and not the posts of other users.
If a user tries to access a resource they aren't authorised to, you'd typically return an HTTP 403 response, which pretty much means "Go away! You're not allowed to do that!".
But in this article, we're going to discuss the idea of sometimes returning an HTTP 404 response in these situations instead. We'll also look at how to implement this in a Laravel application, and some of the things you should consider before doing so.
Before we delve any deeper into this article, I also just want to point out that I'm not advocating for completely replacing HTTP 403 responses in your applications with HTTP 404 responses. Instead, I want to discuss the idea of returning 404s in situations where it makes sense and is suitable for the feature you're building.
Returning 404 for Unauthorised Access
Typically, when you return an HTTP 403 response, you're indicating that the resource exists, but the user isn't authorised to access it. And in most cases, this is exactly what you want to do. However, the downside to this is that if someone is maliciously trying to probe your application for resources, returning a 403 can inadvertently confirm the existence of that resource.
From here, the attacker will know that the resource exists and can build out a list of valid resources to target. Your application should be locked down anyway, but if the attacker finds a vulnerability, this list of valid resources can be used by the attacker as a set of targets.
So, as we've mentioned, in some situations (we'll look at an example later) you might want to consider returning an HTTP 404 response instead of an HTTP 403 for unauthorised access. By doing this, you're not confirming whether the resource exists or not. This will lead to 3 possible outcomes if someone tries to access a resource and receives an HTTP 404 response:
- The route itself in your application doesn't exist.
- The resource exists, but the user isn't authorised to access it.
- The resource doesn't exist at all.
As a result, it makes it much harder (but not impossible, but we'll get to that later) for an attacker to build out a list of valid resources in your application. When they receive a 404, they'll have no idea whether the resource exists or not.
Laravel Example
For any Laravel developers reading this article, let's look at an example of how you might implement this in your application.
Imagine we have an App\Models\Post model that belongs to a user. The user can only update the post if they are the owner of it. Your controller method might look something like this:
declare(strict_types=1);
namespace App\Http\Controllers;
use App\Models\Post;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Gate;
final class PostController extends Controller
{
/**
* Update the given post.
*/
public function update(Request $request, Post $post): RedirectResponse
{
// Authorise whether the user can update the post.
Gate::authorize('update', $post);
// Update the post here...
return redirect('/posts');
}
}
Then your policy might look something like this:
declare(strict_types=1);
namespace App\Policies;
use App\Models\Post;
use App\Models\User;
final readonly class PostPolicy
{
/**
* Determine if the given post can be updated by the user.
*/
public function update(User $user, Post $post): bool
{
return $user->id === $post->user_id;
}
}
If a user tries to update a Post they don't own, the update policy method will return false. This will then result in the Gate::authorize method causing Laravel to return an HTTP 403 response.
However, if you want to return an HTTP 404 instead, you can update your policy like so:
declare(strict_types=1);
namespace App\Policies;
use App\Models\Post;
use App\Models\User;
use Illuminate\Auth\Access\Response;
final readonly class PostPolicy
{
/**
* Determine if the given post can be updated by the user.
*/
public function update(User $user, Post $post): Response
{
return $user->id === $post->user_id
? Response::allow()
: Response::denyAsNotFound();
}
}
Now, if a user tries to update a post they don't own, Laravel will return a 404 response instead of a 403.
Things to Consider
Although returning a 404 instead of a 403 can help to obscure the existence of resources in your application, there are some things to consider:
Not Always Needed
Let's imagine you have a web application which has public profiles for users. Each user has a unique username, and their profile can be accessed at a URL like /users/{username}, and updated at /users/{username}/edit.
We'll assume that we know a user exists with the username johndoe because they are an active user on the platform, and we can see their profile at /users/johndoe.
If we navigated to /users/johndoe/edit, and we weren't logged in as johndoe, then it would make sense to return an HTTP 403 response. After all, we know the user exists, and we know that they have a profile that can be edited, but we're simply not authorised to edit it. We don't need to obscure the existence of the resource here, because it's already public knowledge.
Of course, if you want to be extra cautious, maybe you'd still want to return a 404 response to reduce the likelihood of user enumeration attacks. But in most cases, it's not strictly necessary.
For this reason, I think HTTP 404 responses really shine when you're dealing with resources that aren't public knowledge. For example, if you're building an application where users can only access resources that belong to their own account, then returning a 404 response for unauthorised access makes sense. A user should never have knowledge of resources that belong to other users.
A great example to highlight this is GitHub private repositories. If you attempt to visit a private repository which you don't have authorisation to view, you'll receive an HTTP 404 response. This is the same response you'd also receive if the repository didn't exist at all. As a result, you can't determine whether the repository exists or not (at least, not based on the response status code anyway). However, if GitHub were to return a 403 response if the repository did exist, then you'd be able to use that as proof that it exists
Mixing HTTP 404 and 403 Responses
There may also be times when you want to use a mixture of HTTP 403 and 404 responses in your application. For example, imagine you're building a multi-tenant application and that users can belong to a team and are assigned roles.
Let's say a user in Team A tries to access a resource that belongs to Team B. In this case, it would make sense to return a 404 response because the team shouldn't have knowledge of resources that belong to other teams.
However, if a user in Team A tries to access a resource that belongs to Team A, but they don't have the correct role to access it, then it would make sense to return a 403 response. After all, they know the resource exists because it belongs to their team, but they simply aren't authorised to access it.
Harder Debugging
When you're building the application or debugging issues, returning a 404 for both non-existent resources and unauthorised access can make it harder to identify the root cause of an issue. After all, we typically use HTTP status codes to help us understand what went wrong.
User Experience
Although in most cases, you'd never present the user with a link that returns a 403 response, if you do, then returning an HTTP 404 error page instead of an HTTP 403 error page might confuse the user. But this is a minor concern in most cases, and something the user is unlikely to encounter.
Timing Attacks
Returning an HTTP 404 status code instead of a 403 might help to obscure the existence of resources, but it doesn't completely hide them. A timing attack could still be used to determine whether a resource exists or not.
If you've not heard of timing attacks before, I'd highly recommend checking out Stephen Rees-Carter's "In Depth: Timing Attacks" article that covers them. But the general idea is that an attacker can measure the time it takes for your application to respond to a request to determine whether a resource exists or not.
At a high level, think about the steps your application might take to process a request when attempting to access a resource. It needs to make a database query to fetch the resource. If the resource exists, it will then hydrate the row into a model (assuming you're using model classes), and then check whether the user is authorised to access it. If the resource doesn't exist, it will be able to skip the hydration and authorisation check steps. This means if the resource exists, the request will take slightly longer to process than if the resource doesn't exist.
With enough requests to build up a baseline, an attacker could potentially determine the average response time for a request for an existing resource versus a non-existent resource. This could allow them to infer whether a resource exists or not, even if you're returning a 404 for both cases.
There are things you can do to mitigate timing attacks, such as adding random delays to your responses, but this is a complex topic and beyond the scope of this article.
But the main takeaway is that returning a 404 instead of a 403 can help to obscure the existence of resources, but it doesn't completely hide them.
Use Alongside Other Security Measures
As we've touched on in the article, you shouldn't rely solely on the status code of your response as a security measure to hide the existence of a resource. Instead, you should treat it as one of many tools in your arsenal.
For example, to reduce the effectiveness of an enumeration attack (where an attacker might be looping through example.com/users/1, example.com/users/2, etc., to find which users exist), you might want to use UUIDs, ULIDs, or some other kind of obfuscated ID rather than auto-incrementing IDs. This could change your URL structure to something more like example.com/users/dc3dd10c-24ac-46f2-a603-583cfd8b36e2. Using a less predictable pattern, such as this, will make it much harder to guess the next resource's ID.
You can also implement rate limiting to make it more difficult for an attacker to send a large number of requests within a short period. This will make it significantly harder to use a brute force technique of trying lots of possible URLs.
By partnering HTTP 404 responses, obfuscated IDs, rate limiting, a technique for mitigating timing attacks, and other security measures, you can establish a robust foundation for reducing enumeration attacks.
However, due to some of the drawbacks we've mentioned, you should remember to use HTTP 404 responses only where it makes sense. If you won't gain much value from using the 404 response, then I'd recommend sticking with the general convention and returning a 403 response.
Conclusion
Hopefully, this article has given you some food for thought about whether you should sometimes return an HTTP 404 response instead of a 403 for unauthorised access in your application.
If you enjoyed reading this post, you might be interested in checking out my 220+ page ebook "Battle Ready Laravel" which covers similar topics in more depth.
Or, you might want to check out my other 440+ page ebook "Consuming APIs in Laravel" which teaches you how to use Laravel to consume APIs from other services.
If you're interested in getting updated each time I publish a new post, feel free to sign up for my newsletter.
Keep on building awesome stuff! ๐
Top comments (13)
You are not fixing anything by changing the http status. The only thing is that you are creating more problems for yourself and your website users.
You are never going to stop logged in users to share authenticated urls, so even if you use a 404 status there are always other means to find out urls.
This is just bad advise.
Hey! One of the things I mentioned towards the end of the article is that it can make it harder to debug errors and that it can affect user experience. I also mentioned that your application is likely still susceptible to timing attacks, too.
The article isn't related to users sharing authenticated URLs, but rather as a way to reduce the likelihood of malicious attackers trying to find resources. Of course, this is one of many approaches that can be used alongside other techniques to improve your web app's security. It shouldn't be relied upon solely, as more sophisticated bots can circumvent this ๐
You mention the solution is easy to circumvent, so why bother with the extra trouble it brings for you? It is like hiding a house key under a flowerpot near the door. It is the first place thieves will look.
As I mentioned in the original post and my comment above, it's not something you'd rely on solely.
For example, let's take a private GitHub repository (we'll say it's github.com/example/super-secret-repo). If we tried to visit that URL as a user who doesn't have authorisation to view it, a 403 response could be returned. But by doing that, we've just confirmed the existence of that repository.
So, in this particular use case, we might want to opt for a 404 response instead. Now, a malicious user won't know whether the repo exists (purely based on the status code).
That will be helpful to stop people who are manually trying to probe for that resource in their own browser (by typing the URL in the address bar). And it will also stop basic bots that only check things like the status code to determine if the repo exists. However, if the bot is using a timing attack, it'll be able to make a pretty good guess as to whether the repo exists.
So at this point, you can add some code to your application (for example, the
Timeboxclass in Laravel: laravel.com/docs/12.x/helpers#timebox), to reduce the likelihood of a successful timing attack. This can then also be paired up with rate limiting too.Now, through a combination of different approaches, it makes it much more difficult to determine whether the repository exists or not.
So, it's acting as a belt-and-braces approach. And of course, there are many times when it's just not needed, and in most cases, you likely will want to just return a 403. So it's a case of weighing up the pros and cons for each particular use case ๐
You may not use this technique, it's your choice. So do I. However, people may do.
For example, GitHub returns a 404 response when you try to access a private repository that you are not the owner or a participant.
Yeah, as you say, it's a technique that's available. But it doesn't mean you have to use it. And there are definitely places where it wouldn't make sense to use the 404. So it's all down to the use case and feature, and deciding which status code would be most suitable.
I think your GitHub example is a perfect use case. By returning 403 for a private repository, we'd be confirming the existence of the repo. Whereas, with a 404, you wouldn't know whether it exists or not ๐
It is not because a big website is doing it, it is a good practice.
The http statuses are one of the foundations of a website, so I don't understand why people want to mess with it. You are not going to build your house on mud.
Did I say "it's good practice"?
Again, I said "it's your choice".
You needn't to use it. However, others may do for their reason.
I completely agree : You're much better off using obfuscated ids and a rate limiter so that enumeration becomes practically impossible, rather than returning 404 codes instead of 403 (which as pointed in the article are still vulnerable to timing attacks anyway unless you go to great length to avoid that).
That said, sometimes you're not doing things to actually improve security, you're doing things to get approval from whoever is auditing your product, and some security auditors consider this is "good practice". So yeah... I just make everything a 404 and then read the logs when I need to know why I can't access a page.
Absolutely, using obfuscated IDs and rate limiting are also huge help too. As I mentioned in the article (and some other comments), you wouldn't rely just on changing the status code because that wouldn't provide much value; instead, this is just an extra approach you can use alongside other security features.
And, of course, as I mentioned in the article, it doesn't make sense to return 404s everywhere for failed authorisation checks. In most cases, you would return a 403, but the 404 can be useful in certain places (especially when partnered up with some code to prevent timing attacks).
For instance, using my private GitHub repository example. The repo names are human-readable (e.g., github.com/example/super-secret-repo) so ID obfuscation can't be used. But if someone tried to visit this URL, returning a 403 would prove the existence of this repo, whereas a 404 wouldn't.
So there's a time and a place for using it. And I'd never suggest using it by default; just if the feature or use case would benefit from it ๐
Super puas, estetiknya bikin kagum ๐JO777 bikin ketagihan
And what is about using 401 (Unauthorized) for any request that needs authorization. Returning 401 when you don't authenticate OR when you authenticate, but don't have the rights to access that ressource. And only return 404 when you would have the right to access it, but it doesn't exist.
That way you can't really see if you are allowed to acces, can't prove you are allowed to acces, or if something isn't there. While on the other hand, if you are allowed to acces and it's not there, you will know (404)
Super puas, estetiknya bikin kagum ๐JO777 bikin ketagihan