dubet.fr
Back to list

On Disclosing HTTP Status Codes from a REST API

Friday, July 3rd, 2026

When you’re building a REST API, security should be top priority.

You want to protect the data you’re exposing through your endpoints. To pull this off, you set up two mechanisms: authentication and authorization. These let you control and limit who gets to access what.

  • Authentication is the mechanism that verifies whether a principal claiming to have a given identity is actually who they claim to be.

  • Authorization kicks in after authentication and checks whether the principal has the required scopes, permissions, privileges, roles, groups, or attributes needed to access specific resources.

As a rule, most API endpoints shouldn’t be accessible to unauthenticated principals. Most public APIs require users (whether human or machine) to sign up and get an API key; you need to use that key when hitting the API. This registration lets public APIs control access, monitor usage patterns, and potentially revoke access from bad actors.

Even if a principal has registered and has a key, certain parts of the API might still be off-limits to them because they don’t have the necessary authorization.

Those parts are reserved for specific, more privileged principals who have the required discriminating properties: groups, roles, permissions, privileges, scopes, attributes… basically, anything attached to them beyond their raw identity.

Consider the HTTP endpoint GET /admin, which is meant for users with an admin role: regular users won’t be able to access it because they’re not in the admin group—even if they’re authenticated.

The HTTP spec defines the status codes HTTP 401 UNAUTHORIZED [1] (when authentication is missing) and HTTP 403 FORBIDDEN [2] (when authorization is missing) to signal that the requested endpoint won’t be accessible until those conditions are met.

These status codes are explicit and meaningful, but that’s exactly why they leak interesting information: the fact that the requested endpoint is actually defined (otherwise, an HTTP 404 NOT FOUND would’ve been returned instead) and actionnable; in other words, the endpoint exists and is expected to return something once the access conditions are satisfied.

So here’s the thing: these HTTP status codes are crucial for troubleshooting during development and in production. But they can also inadvertently reveal exploitable info about your API’s behavior to third parties (like threat actors or malicious individuals) looking for vulnerabilities. These status codes inadvertently highlight valuable targets and areas of interest within the API.

If an endpoint returns HTTP 401 UNAUTHORIZED instead of the generic HTTP 404 NOT FOUND, you can reasonably assume that endpoint is defined; it returns a response and might even potentially leak sensitive information that wouldn’t be visible without prior authentication.

If that same endpoint returns HTTP 403 FORBIDDEN instead, you can assume it contains even more sensitive info; accessing it doesn’t just require the principal to be authenticated. They also need the right privileges to get a response (otherwise, why would they need authorization?).

The question is to know if it is really worth doing something about this: should we somehow “hide” these status codes to avoid leaking hints about how the API behaves?

For the two approaches we’ll cover next, we’ll take into account three things:

  • Whether the principal is authenticated
  • Whether the endpoint is defined
  • Whether the principal is authorized

Solution #1: The Generic 404

What if we decided to leak nothing and just return a generic status code, no matter who’s trying to hit one of our endpoints?

Say both GET /whatever/we/type and GET /admin/invoices endpoints would return the generic HTTP 404 NOT FOUND, whether the users are authenticated or not, and whether they are authorized or not.

By returning this generic status code, nobody can really tell if the resource exists; everyone just gets this status code all the time if they’re not authenticated. Once users are authenticated but do not have the right permissions, protected endpoints that require authorization wouldn’t return a 403 either: they would return the generic 404.

In practice, this approach would only ever return HTTP 200 OK if authentication and authorization constraints are satisfied; otherwise HTTP 404 NOT FOUND will always be returned.

This approach means deliberately ignoring the HTTP standard, which could trash the developer experience during development and testing. Status codes are incredibly valuable for diagnosing and troubleshooting problems.

Worse is that, even in production when you are monitoring, you won’t be able to figure out whether the logs are meaningful. All responses to denied requests get logged as HTTP 404 NOT FOUND, so monitoring unauthorized access becomes a fool’s errand.

Not using the right status codes masks the real reason why an endpoint returned HTTP 404 NOT FOUND, and that directly impacts security:

  • Was it because the endpoint simply doesn’t exist and was never defined?
  • Was it because the application tried to call an endpoint without valid permissions?
  • Was it a bad actor probing a bunch of endpoints to figure out which ones could be exploited?

At this point, with such a generic status code, all bets are off.

More broadly, this is basically just sweeping dust under the rug. What we end up with is actually the worst kind of security: security through obscurity. It gives you a false sense of safety, a comfortable illusion of protection when it’s really just a pathetic attempt to defend against attackers.

So in the end, this approach doesn’t deliver real benefits for development, operations, or security. Monitoring becomes harder, troubleshooting is worse, it makes auditing tricky… and the developer experience is a pain as well.

The HTTP standard defines precise, meaningful status codes. We should use them correctly and follow the best practices that have evolved over time.

Additionally, more knowledgeable attackers may not observe only status codes: they can measure other things too.

Among sophisticated techniques, there’s one that involves probing a large range of plausible endpoints and measuring their response times over a period.

This kind of scan is a side-channel attack. Here we try to measure something which is not explicitely given by the API. It tries to distinguish real endpoints that are actually defined in the API code from those that do not exist, by measuring their individual response time. Undefined endpoints will respond fast because the routing mechanism behind is optimized to bail out quickly, whereas endpoints that are actually defined take a bit longer to process on average because they handle extra logic around authentication and authorization (things like processing the principal’s claims, validating tokens, etc…).

With this technique, it’s pretty easy to pick out genuinely exploitable endpoints: they’re the ones with slightly slower average response times.

Probing techniques are very effective, partly because we generally try to design APIs with meaningful endpoint names.

Nobody wants to remember that GET /zxw/qwux endpoint returns the last 10 users created by the system. We would so much prefer to have a GET /users/last-10-created. API designers want semantically meaningful, memorable routes that the development team can remember. Naturally, these routes get identified very fast by attackers during brute-force attacks: they will probably guess the whole API layout within the first few thousand attempts.

Finding all the real endpoints takes way less time than you would think, especially for determined actors who usually have the best tools and are well-prepared.

A few defenses exist against this kind of attack, but we won’t get into them here: these aren, among others, rate limiting (or throttling), constant-time operations, and jitter (or noise injection).

Solution #2: 401 By Default

The first question to ask is: can we authenticate and authorize principals before checking whether the endpoint is even defined?

If the answer is yes, we can still use HTTP 404 NOT FOUND, but more sparingly.

Most of the time, we would want to use a combination of HTTP 401 UNAUTHORIZED and HTTP 403 FORBIDDEN instead, depending on where the principal is in the security flow and whether the endpoint is defined.

Fundamentally, this means always returning HTTP 401 UNAUTHORIZED for unauthenticated principals. Even basic or undefined endpoints should return this status code, because authentication becomes a fundamental prerequisite before anyone gets access to anything.


NOTE: “Technical” endpoints (like GET /health) that query the system’s health metrics obviously should be protected by authentication and authorization too, because they leak internal info that attackers could turn against the system.

Health check data can actually be considered just as sensitive as customer data, for what it’s worth.


Once authentication passes but a principal lacks the privileges for a defined endpoint, we should always return HTTP 403 FORBIDDEN.

If the endpoint is undefined, we’ve got two scenarios based on the authorization policy:

  • If a default authorization scheme is in place (like a partial route-matching mechanism), we could return HTTP 403 FORBIDDEN even if the endpoint isn’t defined, because all auth checks run before we check whether the endpoint exists in the code. If auth checks pass, we might even return HTTP 404 NOT FOUND since the principal would have enough privileges to know the truth.
  • If no default authorization scheme exists, it means authorization rules are baked directly into endpoints. We can figure the principal would’ve been authorized to access these endpoints if they existed. Therefore, we should return HTTP 404 NOT FOUND because if the endpoint had existed, it might not have been protected by an authorization mechanism by default: it would have been globally available to any authenticated principal without extra config.

Ultimately, here’s a table showing what status code gets returned based on the current situation and whether the endpoint is defined or not:

SituationEndpointStatus Code
Principal not authenticated❌ Not defined❌ 401 UNAUTHORIZED
Principal not authenticated✅ Defined❌ 401 UNAUTHORIZED
Principal authenticated❌ Not defined🔒 403 FORBIDDEN
Principal not authorized✅ Defined🔒 403 FORBIDDEN
Principal authorized❌ Not defined❓ 404 NOT FOUND
Principal authorized✅ Defined✅ 200 OK

This tradeoff feels reasonable: we can legitimately hide the truth about endpoint definitions from all unauthenticated principals and from all unauthorized principals without sufficient privileges. Only the most privileged principals get to know whether an endpoint has been defined or not: by getting back HTTP 404 NOT FOUND when it has not been defined.


  • HTTP 401 UNAUTHORIZED is a misnomer. It could have been named UNAUTHENTICATED ou NOT AUTHENTICATED instead, to convey lack of authentication. ↩

  • HTTP 403 FORBIDDEN is a misnomer, too (yes). It could have been named UNAUTHORIZED ou PERMISSION DENIED instead, to convey lack of authorization. ↩

Copyright 2023 - 2026 Cyril Dubet. All rights reserved.