CodeGym /Kursy /Spring Security /401 and 403: the semantics of security errors

401 and 403: the semantics of security errors

Spring Security
Poziom 17, Lekcja 1
Dostępny

1. The difference between 401 and 403 in a REST API

When you start building a “real” JSON API, you quickly realise: the status code is not “just a formality”, but part of the contract. A client application, a mobile client, a Postman collection, and even your future automated test read not only the response body. First and foremost, they read the status and draw a conclusion from it: should I log in, should I show a “no access” screen, should I repeat the request, or, on the contrary, should I urgently stop spamming the server.

The problem begins when the API responds “randomly”: sometimes 403 instead of 401, sometimes the other way around. For a developer, that is unpleasant but tolerable: they will open the logs, inspect the config, and figure it out. For the client, it turns into incorrect behaviour. For example, the app thinks the user “has no permissions”, although in reality they simply have not signed in. Or the client starts “relogging” forever, even though the login is successful — the role is just wrong.

To lock in the meaning, it helps to keep a small table in mind (this is not a “sacred truth”, just a handy cheat sheet for design):

Situation on the server Correct meaning Typical code
The user is not authenticated (we do not have a confirmed identity for the request) “Identify yourself first” 401
The user is authenticated, but the access rule is not satisfied (a role/authority/other check failed) “We know who you are, but you cannot go here” 403

Important: these codes are not about “how badly you messed up”. This is not “401 — a light fine, 403 — a permanent ban”. These are different types of errors, and they answer different questions.

2. Mental model: identification and access

It is very tempting to memorise 401 and 403 as two magic numbers, but numbers without meaning are hard to keep in your head. So a healthy mental model always starts with a human story. Imagine that our API is an office with security. At the entrance, two different things are asked of you.

First, the guard asks, “Who are you?” If you do not show a badge, show someone else’s badge, or show something that does not look like a badge at all (for example, a student ID from 2007 — it is plastic too, but no), the conversation ends quickly. This is 401: the system has no basis to believe that the request is made by a confirmed user.

And if you do show a badge, the guard recognises you, but you are trying to enter the server room or the director’s office, then the story is different. The question “Who are you?” is solved. But now the question “What are you allowed to do?” arises. And the answer may be “No”. This is 403: the identity is clear, but the permissions are insufficient.

Here is how this can be shown as a simple request-processing diagram (simplified, but useful for the brain):

flowchart TD
    A[HTTP request] --> B{"Is authentication successful?"}
    B -- no --> U[Response: 401]
    B -- yes --> C{"Is the access rule satisfied?"}
    C -- no --> F[Response: 403]
    C -- yes --> D[Controller / business logic]

If you keep this fork in your head, you will almost stop getting confused. Any argument about “is this 401 or 403?” comes down to one check: “Do we actually have a confirmed user in the request context or not?”.

3. 401 Unauthorized: when there is “no user”

401 is the situation where you access a protected resource, but for the current request successful authentication could not be obtained. Important: “could not be obtained” means not only “nothing was sent”. It also includes “was sent, but incorrectly”, “was sent expired”, “login/password were sent, but did not match”, and “the account is blocked and does not pass the authentication flow”.

Yes, the name Unauthorized is genuinely confusing. In English this is a historically established term, but in our course we will think more simply: 401 almost always means Unauthenticated. That is, “the system does not consider you logged in”.

To see this in a live project, Secure Content Platform API, let us take the private endpoint /api/me. According to our access model, it should work only for an authenticated user. Therefore, without login it must “hit” 401.

The controller might, roughly speaking, look like this (we show it not for the logic, but for clarity: the request reaches the method only if security lets it through):

import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

import java.util.Map;

@RestController
class MeController {

    // Private endpoint: only successfully authenticated users should be allowed here
    @GetMapping("/api/me")
    public Map<String, String> me() {
        // If you see this message, the security layer has let the request through
        return Map.of("message", "private zone");
    }
}

Now let us look at a request without authentication (the most honest way is curl -i, so we can see the status):

# -i is needed to see the status code and response headers
curl -i http://localhost:8080/api/me
# HTTP/1.1 401

If you use HTTP Basic (we have already covered it, and it is especially convenient for an API client), wrong credentials will also give 401, because authentication did not happen:

# Incorrect credentials => authentication failed => 401
curl -i -u anna:wrong http://localhost:8080/api/me
# HTTP/1.1 401

Notice the subtle point: if your project has a user anna, but the password is wrong, this is still “we do not have successful authentication”. That is, it is not “403, because Anna exists”. No. For the current request, the user has not proven that they are Anna.

And one more important nuance from earlier lessons: if the account is locked or disabled, then the login also does not count as successful. From the request’s perspective, that is again 401. Inside Spring Security there may be different reasons (and different exceptions), but to the external client it usually boils down to one thing: “authentication failed”.

4. 403 Forbidden: when the user exists, but cannot proceed

403 is a different kind of story. It means “we know who you are, but the access rule was not satisfied”. In Spring Security terms, this means there is an Authentication in the SecurityContext, it is considered successful (that is, this is not anonymous and not “the password failed”), but during authorisation someone said “no”.

Most often in a training project, the reason for 403 is very clear: the user has the wrong role or the wrong authority. For example, a regular USER tries to access the admin zone. But it is useful to remember that 403 can be returned not only because of a role. In a session-based branch, for example, the absence of a CSRF token on a state-changing request often also manifests as 403. We are not going deep into CSRF today, but as a debugging fact it is worth remembering so that you do not think “403 = definitely a role”.

For our project, the ideal example is the endpoint /api/admin/users. It should be accessible only to the ADMIN role. Therefore, a regular user after a successful login should receive 403.

Controller example:

import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

import java.util.Map;

@RestController
class AdminController {

    // Admin endpoint: only users with the ADMIN role should reach here
    @GetMapping("/api/admin/users")
    public Map<String, String> users() {
        // If you see this response, the role check has already passed
        return Map.of("message", "admin zone");
    }
}

And now — the request. Suppose anna successfully authenticates, but her role is USER. Then:

# Credentials are correct => authentication exists, but permissions are insufficient => 403
curl -i -u anna:pass http://localhost:8080/api/admin/users
# HTTP/1.1 403

This is exactly 403, because the question “Who are you?” is already closed. We know the request is made by anna, she just is not an admin.

If authentication is not sent at all, then this same endpoint will return 401, because the question “Who are you?” has not even started properly:

# No authentication => 401 (not 403)
curl -i http://localhost:8080/api/admin/users
# HTTP/1.1 401

This is the key pattern: the same URL can return 401 or 403 depending on whether the client is authenticated.

5. One endpoint: three outcomes

It is especially hard for beginners to get used to the idea that the status depends not only on the URL, but also on the state of the security context. That is why it is very useful to see the “three fates of one endpoint” once. Let us take /api/admin/users and see what happens in three states: anonymous, regular user, admin.

In table form, it looks like this:

Who makes the request What the server has Expected result
Anonymous (sent nothing) No successful authentication 401
USER (logged in successfully) There is an authenticated user, but no ROLE_ADMIN 403
ADMIN (logged in successfully) There is an authenticated user + the required role 200

And manually, via curl, you can go through this in literally a minute:

# 1) Anonymous => no authentication => 401
curl -i http://localhost:8080/api/admin/users
# HTTP/1.1 401

# 2) USER => authentication exists, but the role is insufficient => 403
curl -i -u anna:pass http://localhost:8080/api/admin/users
# HTTP/1.1 403

# 3) ADMIN => everything is fine => 200
curl -i -u root:pass http://localhost:8080/api/admin/users
# HTTP/1.1 200

(And yes, if you are thinking right now, “can I just always return 403 so I do not have to think?” — you can, but then the client will think for you. And it will think incorrectly.)

This example reinforces the meaning once again: 401 means “not logged in”, 403 means “logged in, but not allowed”.

6. How Spring Security makes the decision: two “barriers”

If you think of Spring Security as “one big guard”, you get confused faster. It is more useful to see the system as two consecutive barriers that work in filters before the controller is entered. The first barrier is responsible for obtaining successful authentication and putting it into the SecurityContext. The second barrier checks the access rules for a specific request and decides whether to let it proceed.

In practice, this is very easy to read right from SecurityFilterChain. Look at a typical configuration for our zones:

import org.springframework.context.annotation.Bean;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.web.SecurityFilterChain;

@Bean
SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
    return http
        .authorizeHttpRequests(auth -> auth
            // Rule #1: everything under /api/admin/** requires the ADMIN role (this is the authorisation barrier)
            .requestMatchers("/api/admin/**").hasRole("ADMIN")
            // Rule #2: any other request requires at least authentication (this is the authentication barrier)
            .anyRequest().authenticated()
        )
        // From here Spring Security decides what to return: 401 (no authentication) or 403 (insufficient permissions)
        .build();
}

This hides the entire logic of the codes:

If the request comes to anything except /api/admin/**, then authenticated() is required. If there is no authentication, that is the first barrier, and there is no point checking permissions any further. Therefore, the logical result is 401.

If the request comes to /api/admin/**, then the ADMIN role is required. And here the fork appears: if there is no authentication, again first 401 (the first barrier). If authentication exists, but the role does not, that is already the second barrier, and it ends with 403.

A useful thought for the brain: the rules authenticated(), hasRole("ADMIN"), hasAuthority("ADMIN") are not “three different ways to return a code”. They are different conditions for passage. And the code (401 or 403) is chosen depending on where you stumbled: at the entrance (no user) or at admission (there is a user, but they do not fit).

7. How a mistake in choosing 401/403 breaks the client

It seems that “well, one digit is different, so what”. But in the client world, these digits are commands. Many clients build behaviour like this: if 401, show the login screen or refresh credentials. If 403, then login will not help, you need to show a “no access” message and not try to “fix” the request by repeating it.

If you return 403 where the user simply is not logged in, the client may produce a very strange UX. Imagine a mobile app: a person installs the app, opens the profile section, and immediately gets: “Access denied”. They have not even seen the login screen yet. The chance that they will think “oh, I need to press the sign-in button somewhere else” is about the same as the chance that a cat will configure your SecurityFilterChain for you by itself (that is, almost zero).

If you return 401 where the user has logged in but lacks permissions, the client may fall into an endless “re-authorise and try again” loop. Sometimes this looks like the app constantly jumping back to the login screen after every request to the admin zone, even though the login is correct. The person enters the password again and again, starts hating your app, and you start hating life.

So the correct semantics of 401/403 are not about aesthetics. They are about the real resilience of client-server behaviour.

8. Debugging cheat sheet: how to read 401 and 403

When you see 401, it helps not to panic with “everything is broken”, but to ask yourself the first basic question: “Does the server even see the user?”. And then think very concretely: was there an Authorization header in the request (if this is Basic), was there a session cookie (if this is a session-based branch), is the account blocked, did we miss the login/password.

When you see 403, the question is different: “The user exists, but which rule did they fail?”. And that is where you start reading the access matrix. If the config is written neatly, you will find the matching matcher fairly quickly. If you use roles, you compare the expected role (ADMIN) with the user’s actual roles. If you have already started using authorities, you check that GrantedAuthorities really contains the needed string (and that you did not mix up hasRole("ADMIN") with hasAuthority("ADMIN"), because those are different worlds).

At the level of thinking, keep this order in mind: first prove that authentication exists (otherwise any conversation about roles is pointless), and only then sort out permissions. This saves a lot of time, because most beginner “403” cases are actually “I did not send a cookie/Authorization header” and should have been 401, but browser semantics got involved somewhere or the client expected different behaviour.

This fork also directly affects the response mechanism itself: lack of authentication should go into one security point, while denying an already known user should go into another. When these two branches are separated properly, they can then be reduced to a single JSON contract.

9. Typical mistakes when distinguishing 401 and 403

When you are just starting out, it is very tempting to have a “universal explanation” like “401 means the password is wrong, 403 means the role is wrong”. And that is almost true, but it is precisely the “almost” that causes headaches. These mistakes repeat project after project, so it is better to get acquainted with them now, while we have a training project, not a burning production system.

Mistake #1: treating 401 as “the wrong role”.
This is the most common confusion because of the word Unauthorized. In reality, the role is checked only after the user has been successfully authenticated. If the login/password failed or were not sent at all, this is not “the role is wrong”, this is “there is no user”. So the basic check is: before thinking about roles, make sure the request was authenticated at all.

Mistake #2: returning 403 for an anonymous request to a private endpoint.
This often happens when a developer returns statuses “by hand” from the controller, or when the settings are made so that the browser flow masks the real meaning of the error. For /api/me, anonymous should receive 401. If it receives 403, the client starts thinking that “access is denied forever”, although in reality the user just needs to log in.

Mistake #3: returning 401 when the user is already logged in but lacks permissions.
For example, USER goes to /api/admin/users, receives 401, the client decides “the token/session has expired” and forces the user to log in again. The user logs in honestly again, and gets 401 again. It looks very bad and turns the system into an endless corridor with a door that is always closed, but every time you are told “please show your badge again”.

Mistake #4: trying to “fix” 401/403 with an if in the controller.
The controller is too late a point. The correct distinction between 401 and 403 happens in the security layer before Spring MVC selects the controller method. When you start writing “if the user is not the right one, return a status” in the controller, you are duplicating security logic, making it decentralised and fragile. You also risk accidentally opening access where it should not be, because you missed one of the entry points.

Mistake #5: “one text for everything”: the same message for 401 and 403.
Even if the JSON format is the same (and that is good), the meaning of the messages must differ. For 401, the message should say “authentication required”; for 403, it should say “authentication exists, but access is denied”. Otherwise, clients and developers will have to guess from context what happened, and guessing is not the skill we want to develop in backend development.

1
Zadanie
Spring Security, poziom 17, lekcja 1
Niedostępne
`401` dla prywatnego endpointu bez uwierzytelnienia
`401` dla prywatnego endpointu bez uwierzytelnienia
1
Zadanie
Spring Security, poziom 17, lekcja 1
Niedostępne
Jeden endpoint z trzema wynikami: `401`, `403` i `200`
Jeden endpoint z trzema wynikami: `401`, `403` i `200`
Komentarze
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION