October 15, 2026
401 vs 403 in Spring Security: Why Your API Returns the Wrong Status Code
A valid password, a POST request, and a 401. Here is what is really going on

By Manjunath Dudam
6 min read
A valid password, a POST request, and a 401. Here is what is really going on
Introduction
Here is a request that confuses almost everyone the first time. You send a POST with a perfectly valid username and password, and Spring Security answers 401 Unauthorized. The password is right, so what exactly is unauthorized?
It is not a bug, but the reason is not obvious. It is also not the only status code in Spring Security that looks wrong at first glance. In this post we will go through 401 and 403 properly: what each one means, which part of Spring Security produces it, and the handful of situations where the answer surprises you.
Everything below was run on Spring Boot 3.5.6 (Spring Security 6.5.5) and Spring Boot 4.1.1 (Spring Security 7.1.1). The results were the same on both.
401 and 403, the Plain Version
Both codes are about access, but they answer different questions.
- 401 means the server doesn't know who you are. You sent no credentials, or the wrong ones. The name says "Unauthorized", but it is really about authentication.
- 403 means the server knows exactly who you are, and the answer is no. That is authorization.
In Spring Security, two small interfaces produce these responses. An AuthenticationEntryPoint handles the "who are you?" case and usually produces the 401, or a redirect to a login page. An AccessDeniedHandler handles the "you're not allowed" case and produces the 403.
Who Decides: ExceptionTranslationFilter
The decision happens in ExceptionTranslationFilter. It sits just before AuthorizationFilter in the chain and wraps everything after it in a try/catch. (If the chain itself is new to you, the previous post walks through it.) It catches two kinds of exceptions:
- An
AuthenticationExceptiongoes to theAuthenticationEntryPoint. - An
AccessDeniedExceptionis checked first. If the current user is anonymous, it goes to theAuthenticationEntryPointtoo, because the real problem is that nobody is logged in. If the user is authenticated, it goes to theAccessDeniedHandlerand you get a 403.
That second rule explains something that trips people up. An anonymous request to an admin-only endpoint gets a 401, not a 403.
The Test Setup
To check all this I wrote a small app with two users, user (ROLE_USER) and admin (ROLE_ADMIN), and this chain:
http
.authorizeHttpRequests(auth -> auth
.requestMatchers("/public/**").permitAll()
.requestMatchers("/api/admin/**").hasRole("ADMIN")
.anyRequest().authenticated())
.httpBasic(Customizer.withDefaults());http
.authorizeHttpRequests(auth -> auth
.requestMatchers("/public/**").permitAll()
.requestMatchers("/api/admin/**").hasRole("ADMIN")
.anyRequest().authenticated())
.httpBasic(Customizer.withDefaults());There is also a controller with /api/me, /api/admin/panel, /api/reports (protected with @PreAuthorize("hasRole('ADMIN')")) and a POST /api/orders. These are the results:
The two amber rows are the interesting ones. The first is the anonymous-user rule from above. The second is the request from the introduction, and it deserves its own section.
Why a Valid POST Returned 401
That row is a POST with correct credentials and no CSRF token. I expected a 403. I got a 401 with a WWW-Authenticate header, which makes no sense at first. The debug log made it clear (I ran this one on port 8082):
Securing POST /api/orders
Invalid CSRF token found for http://localhost:8082/api/orders
Securing GET /error
Set SecurityContextHolder to anonymous SecurityContextSecuring POST /api/orders
Invalid CSRF token found for http://localhost:8082/api/orders
Securing GET /error
Set SecurityContextHolder to anonymous SecurityContextStep by step, this is what happened:
- The request arrives with valid Basic credentials. But
CsrfFiltercomes beforeBasicAuthenticationFilterin the chain, so the CSRF check fails before anyone has been authenticated. - The 403 is sent through the servlet container's error mechanism, and Spring Boot forwards the request to
/error. /errorgoes through the security chain again, as a fresh GET. The request is anonymous at that point, andanyRequest().authenticated()applies to/erroras well.AuthorizationFilterdenies it,ExceptionTranslationFiltersees an anonymous user, and the entry point answers with a 401. That replaces the original 403.
There are three ways to deal with it, and I tested all of them:
- Let
/errorthrough. Add.requestMatchers("/error").permitAll()and the same request returns a proper 403 with Spring Boot's error JSON. - Write the response yourself. A custom
AccessDeniedHandlerthat sets the status and body directly never goes through/error. The same request returns your 403. - Fix the cause. Send the CSRF token from your client, or switch CSRF protection off if your API doesn't need it. With CSRF off, the same POST returned 200.
About that last one: don't switch CSRF off just to make the error go away. CSRF protection matters when the browser attaches credentials on its own, like session cookies. The Spring Security reference has a section on when it is safe to disable it.
Roles and Authorities
Another quiet source of 403s. This builds a user with the role ADMIN:
User.withUsername("admin")
.password("{noop}password")
.roles("ADMIN")
.build()User.withUsername("admin")
.password("{noop}password")
.roles("ADMIN")
.build()(The {noop} prefix means no password encoding. It is fine for a demo and never for a real system.)
roles("ADMIN") stores the authority ROLE_ADMIN. hasRole("ADMIN") looks for ROLE_ADMIN, so it matches. hasAuthority("ADMIN") looks for the literal string ADMIN, so it doesn't. I added an endpoint protected with hasAuthority("ADMIN") and called it as the admin user. It returned 403.
The opposite mistake is caught early. hasRole("ROLE_ADMIN") stops the application from starting at all:
IllegalArgumentException: ROLE_ADMIN should not start with ROLE_
since ROLE_ is automatically prepended when using hasAnyRole.
Consider using hasAnyAuthority instead.IllegalArgumentException: ROLE_ADMIN should not start with ROLE_
since ROLE_ is automatically prepended when using hasAnyRole.
Consider using hasAnyAuthority instead.The rule of thumb: roles and hasRole add the ROLE_ prefix for you. Authorities are compared exactly as written.
When @PreAuthorize Returns 500
Method security has a trap of its own. Plenty of projects have a catch-all handler like this:
@RestControllerAdvice
class CatchAllAdvice {
@ExceptionHandler(Exception.class)
ResponseEntity<String> handle(Exception e) {
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body("caught: " + e.getClass().getSimpleName());
}
}@RestControllerAdvice
class CatchAllAdvice {
@ExceptionHandler(Exception.class)
ResponseEntity<String> handle(Exception e) {
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body("caught: " + e.getClass().getSimpleName());
}
}With it in place, calling /api/reports as a normal user returned 500 with caught: AuthorizationDeniedException. Without it, the same call returned 403.
The reason is where the exception is thrown. A @PreAuthorize check runs inside the controller call, so your @RestControllerAdvice sees the exception first. A rule in authorizeHttpRequests is checked earlier, in AuthorizationFilter, before the controller is involved. That is why /api/admin/panel kept returning 403 even with the catch-all handler in place.
The fix is a more specific handler that lets security exceptions through:
@ExceptionHandler(AccessDeniedException.class)
void rethrow(AccessDeniedException e) {
throw e;
}@ExceptionHandler(AccessDeniedException.class)
void rethrow(AccessDeniedException e) {
throw e;
}AuthorizationDeniedException is a subclass of AccessDeniedException, which is why this handler catches it. With it added, /api/reports returned 403 again.
A Redirect Instead of a 401
Two situations, both common with a single-page app.
Form login and Basic together. If you configure formLogin() and httpBasic() on one chain, Spring chooses the response by looking at the request. The same unauthenticated call gave me:
Accept: text/html(a browser page load): 302 redirect to/loginAccept: application/json: 401- The default curl request (
Accept: */*): 401 X-Requested-With: XMLHttpRequest: 401
So a browser navigation gets the login page and API clients get a 401. Usually that is what you want. If your frontend is getting a redirect, check the Accept header it sends.
Two chains. In the previous post I mentioned a catch with multiple chains. With an /api/** chain using Basic and a second chain using form login, an unauthenticated call to /api/me returned a 302 to /login instead of a 401. It is the /error problem again. The 401 is sent through /error, /error doesn't match /api/**, so the second chain treats it as an anonymous request and redirects to the login page. Permitting /error in the second chain fixed it, and the API call returned 401 as expected.
Returning JSON Instead of the Defaults
The defaults are fine for curl, but a frontend usually wants JSON. Both interfaces are one-method lambdas:
http.exceptionHandling(ex -> ex
.authenticationEntryPoint((req, res, e) -> {
res.setStatus(401);
res.setContentType("application/json");
res.getWriter().write("{\"error\":\"unauthenticated\"}");
})
.accessDeniedHandler((req, res, e) -> {
res.setStatus(403);
res.setContentType("application/json");
res.getWriter().write("{\"error\":\"forbidden\"}");
}));http.exceptionHandling(ex -> ex
.authenticationEntryPoint((req, res, e) -> {
res.setStatus(401);
res.setContentType("application/json");
res.getWriter().write("{\"error\":\"unauthenticated\"}");
})
.accessDeniedHandler((req, res, e) -> {
res.setStatus(403);
res.setContentType("application/json");
res.getWriter().write("{\"error\":\"forbidden\"}");
}));With that, the same requests returned {"error":"unauthenticated"} with a 401 and {"error":"forbidden"} with a 403, including the POST without a CSRF token. This is a minimal version. In a real project you would serialise a proper error object and reuse it everywhere.
A Cheat Sheet
Save the image. Most "wrong status code" problems in Spring Security turn out to be one of these six.
Key Takeaways
- 401 means unauthenticated and 403 means unauthorized. In Spring Security,
ExceptionTranslationFilterdecides which, and anonymous users always take the 401 path. - An anonymous request to a protected admin endpoint returns 401, not 403.
- A failed CSRF check can show up as a 401, because the 403 is forwarded through
/erroras an anonymous request. Permit/erroror write the response yourself. roles()andhasRole()addROLE_for you.hasAuthority()does not.- A catch-all
@ExceptionHandlerturns@PreAuthorizedenials into 500s. RethrowAccessDeniedException. - A 302 to
/logininstead of a 401 usually means a form login entry point or a second chain.
Final Thoughts
Almost every confusing status code in Spring Security comes down to one question: where in the chain was the request stopped, and who answered? Once you can answer that, the status code stops being a mystery and becomes a clue.
A status code is a clue about which part of the chain stopped the request. Read it that way and most of the confusion goes away.