1. Diagnostics: status codes and symptoms
When Spring Security "breaks," it almost never breaks gracefully. Usually it looks like this: "it worked yesterday," "today it is 403," "why?" — and then there is chaotic yanking of levers. To stop playing "guessing games," let's start with a simple rule: before touching the code, you need to be able to name the problem so that another person can understand it (or you can, a week later).
I like to think in terms of a small "incident card" — not for bureaucracy, but for the brain. It should contain the things without which troubleshooting turns into magic: which endpoint, which HTTP method, which client, which status, and most importantly — which authentication model you expected (session? Basic? Bearer/JWT?). When you record that, half of the "weirdness" suddenly stops being weird.
Here is an example of such a card in table form:
| Field | Example |
|---|---|
| Endpoint | GET /api/me |
| Client | Postman |
| Expected model | Bearer token (JWT branch) |
| What was sent | Authorization is missing |
| Fact | 401 |
See? The diagnosis is almost complete even before the logs: we expected JWT, but we did not send a token at all. And that is not "Spring Security is annoying again" — it is correct behavior.
A useful beginner tip: if you cannot answer in 10 seconds "which auth source is supposed to populate SecurityContext for this request," then you are not diagnosing the problem yet — you are diagnosing your own fatigue.
401, 403, 404: distinguishing the status codes
Many people start debugging with the phrase "it returned an access error." That is roughly like telling a doctor, "I feel bad." Yes, we sympathize, but treating based on that description can only be done with hugs. So in the security world, we start by separating status codes: 401 and 403 are different stories. And sometimes a 404 unexpectedly appears in security troubleshooting too, and it can also be "about security," not about the absence of a controller.
To orient yourself quickly, keep this table in mind:
| Code | In plain English | Where to usually look for the cause |
|---|---|---|
| 401 | "Who are you? I don't recognize you / you have not proved your identity" | Authentication: session/Basic/JWT/login |
| 403 | "I recognize you, but you are not allowed" | Authorization: roles, authorities, owner-check, CSRF |
| 404 | "I do not know such an endpoint" | Sometimes really 404, and sometimes chain/matcher/securityMatcher |
One important nuance about 404: if you are playing with securityMatcher(...) and multiple chains, you can accidentally end up in a situation where an endpoint that is normally "provided" by a filter (for example /login in a formLogin model) does not exist at all, because the request does not match any SecurityFilterChain. Spring Security explicitly warns that endpoints provided by filters depend on whether the request matches the selected securityMatcher.
And now the key diagnostic pivot: until you answer for yourself whether it is 401, 403, or 404, you have not started troubleshooting yet — you are just being sad in front of the screen.
After config, proxy settings, and hardening surfaces, the problems tend to show up in exactly this form: 401, 403, an empty context, or the feeling that "the filter did not run at all." So next we will break down the diagnostic route — from the expected authentication source to the point in the chain where the decision was actually made.
2. SecurityContext: source and empty states
The word SecurityContext sounds scary, but in essence it is a "pocket in the current execution thread" where Spring Security stores information about the current user while handling a request. If that pocket contains the correct Authentication, your code (controllers, services, method security) can understand "who is making the request" and "what rights they have."
When you hear "empty SecurityContext," it is tempting to picture null. But in reality there are often three states:
1) the context exists, but authentication == null (rare in application code, more common in early stages),
2) the context is filled with AnonymousAuthenticationToken (and that is normal for public requests),
3) the context is filled with your real Authentication (username/password, basic, jwt, etc.).
If you want to quickly inspect what is inside, a minimal diagnostic point looks like this:
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
// Diagnostics: look at what currently fills the SecurityContext
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
// In logs/console this is often AnonymousAuthenticationToken for public requests
System.out.println(auth); // for example: AnonymousAuthenticationToken [...]
A word of caution. This is a good "thermometer," but a bad "pill." That is, looking is fine, but "if it is empty, let me put something there myself" — that is the beginning of a homemade mini-security system, which we have been running away from throughout the course.
And there is a nuance here that especially breaks a beginner's brain. If an endpoint is marked as permitAll, Spring Security may even not pull authentication from the session, because that makes no sense for access decisions. The documentation says this directly: for permitAll and denyAll, Authentication is "never retrieved from the session." The practical and annoying consequence: you open a public endpoint, expect to see "I am logged in," and see anonymous — then you make the wrong conclusion that "the session is broken." In reality, you are just looking in the wrong place. It is more logical to diagnose the current user on an endpoint that requires authenticated() or a role/authority.
To connect this to our project: it is better to check "who am I" on /api/me, not on /api/public/articles. A public zone is not required by definition to extract and display your authentication.
For clarity, here is a small diagram of where a SecurityContext could come from in our course:
flowchart TD
%% Authentication sources that can populate SecurityContext
R[HTTP request] --> FC[SecurityFilterChain]
FC -->|session| S[SecurityContext retrieved from HttpSession]
FC -->|Basic| B[Read Authorization: Basic ...]
FC -->|JWT custom| J[Read Authorization: Bearer ...]
FC -->|resource server| RS["JwtDecoder + built-in bearer pipeline"]
FC --> A[Authentication in SecurityContext]
A --> MVC["Controller/Service/@PreAuthorize"]
And now the main troubleshooting question: which arrow was supposed to fire in your scenario?
3. SecurityFilterChain: matcher and order
Sometimes you are sure: "I added the filter, it should be called," but in practice it behaves like a cat: "I am not obligated to show up when you call me." In Spring Security this usually means not that the filter is broken, but that the request went through the wrong chain, or did not reach the security chain you expected at all.
The first cause is confusion between securityMatcher(...) and requestMatchers(...). securityMatcher chooses which SecurityFilterChain applies at all, while requestMatchers inside the selected chain define authorization rules. The documentation emphasizes that each requestMatchers(...) works only within the HttpSecurity whose securityMatcher also matched.
This also includes another surprise: if the SecurityFilterChain matches only /api/**, and you expect it to "provide" the /login endpoint (the formLogin branch), then /login may not appear and you will get a 404. This seems "very strange" right up until you remember: the endpoint is also served by filters, and the filters did not apply.
The second cause is the order of authorization rules. The classic anti-example looks like this:
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 {
http.authorizeHttpRequests(auth -> auth
// IMPORTANT: this broad rule will "eat" all more specific rules below
.requestMatchers("/api/**").permitAll() // "ate" everything under /api
// This rule may simply not be "reached" by the matcher logic
.requestMatchers("/api/admin/**").hasRole("ADMIN"));
return http.build();
}
In this fragment, the rule "allow everything under /api/**" comes before "protect /api/admin/**." The result is that the admin zone suddenly becomes public. This is not a Spring Security bug — it is the rule order. (Spring Security, like the compiler, reads top to bottom, not "as you intended.")
In the correct version, you write the specific/sensitive rules first, and the broad ones later:
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 {
http.authorizeHttpRequests(auth -> auth
// First protect the most sensitive endpoints
.requestMatchers("/api/admin/**").hasRole("ADMIN")
// Then define the general rule for the rest of the API
.requestMatchers("/api/**").authenticated());
return http.build();
}
The third cause is multiple chains and @Order. At the fundamentals level we tried not to create chains unnecessarily, but even one extra chain can create a "why is the request going somewhere else?" situation. The official example shows that multiple SecurityFilterChain instances are selected by @Order and by securityMatcher, and the request is matched first against the higher-priority chain. The practical symptom: you change one configuration, but the request uses another one — and you think "the settings were not applied."
The fourth cause is adding a custom JWT filter, but not in the right order. For a custom filter, you most often need addFilterBefore(...) so that your JWT has time to fill the SecurityContext before authorization:
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
// jwtAuthenticationFilter — your OncePerRequestFilter bean
// IMPORTANT: place the filter before UsernamePasswordAuthenticationFilter so the context has time to be populated
http.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
If the filter is placed too late, AuthorizationFilter will already have decided "anonymous" and you will get a 401/403 before your code runs.
4. 401: authentication failure
401 is the story of a user not becoming "recognized" by the system. Important: "not recognized" does not always mean "does not exist." Sometimes the user exists, but you did not send credentials, or sent them to the wrong place, or sent them in a format that "seems right to me."
Start with a simple question: which auth source do you expect?
If you expect a session-based model, then your main state carrier is the JSESSIONID cookie (or equivalent), and a 401 usually means the cookie is missing, or wrong, or the session expired. This is where it is easy to fall into the Postman/curl trap: you log in once, but do not save the cookie for the next request — and the application very honestly says "I do not know you." There is no "Spring magic" here, just plain HTTP reality.
If you expect HTTP Basic, then a 401 almost always means the Authorization header is missing or does not match the format. Basic is "credentials in every request," and if they are absent, the current user will not appear.
If you expect JWT, then the first thing to check is the Authorization header. In a custom JWT filter, you often see logic like "if the header is missing, just pass through without authenticating." This is not an error — it is a normal strategy: the filter should not fail on every public request. But for a protected endpoint, this leads to a predictable 401.
A mini-fragment that is useful to keep in mind (and worth reading during debugging) looks like this:
// Read the header where the JWT is expected (standard convention)
String header = request.getHeader("Authorization");
// If the header is missing or does not start with the Bearer prefix, assume there is no token
if (header == null || !header.startsWith("Bearer ")) {
// IMPORTANT: just pass the request through without authentication (we remain anonymous)
filterChain.doFilter(request, response);
return;
}
// Next usually comes token parsing/verification and SecurityContext population
From a troubleshooting point of view, this means: the absence of the Bearer prefix is almost equivalent to the absence of a token. Not "almost works" — "does not work at all."
And one more thing that many people miss: sometimes "I sent a token" does not mean "I sent the token to where it is read." For example, you put the token in a query parameter ?token=..., but the filter only looks for it in the header. Result: 401, empty SecurityContext, and you start suspecting a conspiracy.
By the way, about the "empty SecurityContext." If you see 401, and in the controller or logs SecurityContextHolder.getContext().getAuthentication() shows AnonymousAuthenticationToken, that almost always means authentication simply did not happen. Not because "the user has no role," but because we have not even reached roles yet.
5. 403: authorization failure
403 is much trickier, because it often looks like "wrong role," although the real cause may be something completely different. In 403 we are already in a world where the user has been recognized (or at least the request has gone through mechanisms that made a decision), and now the system says "not allowed."
To begin with, it is useful to separate three common "subtypes" of 403 in our project.
The first is a request-level denial. This is when your SecurityFilterChain said "only ADMIN may access /api/admin/**," and you came in as USER. The blame lies with authorizeHttpRequests(...) rules.
The second is a method-level denial. You may have allowed access at the URL level, but inside the service layer you were stopped by @PreAuthorize. From the outside this looks like "but I got into the controller," yet the decision was already made by another layer.
The third is an owner-based denial. This is especially common for the Secure Content Platform API: you have the USER role, you are authenticated, but you are trying to read someone else's draft or update someone else's profile. From a security standpoint, this is not "the wrong role" but "not your object." In logs and tests, this should be a separate scenario.
And now the main catch: 403 may be not about roles/owner at all, but about CSRF. In the session-based branch, a state-changing request without a CSRF token often ends with 403. And in a multipart-upload scenario it gets even more fun, because the file is the body, and the CSRF token has to be somewhere too.
Spring Security separately discusses the multipart + CSRF problem as a "chicken and egg" situation: to extract the CSRF token from the body, you need to read the body, which means the file has already reached the server. That is why for browser applications with JavaScript, it is recommended to send the CSRF token in a header — so you do not force the server to read the body before checking CSRF. In practical troubleshooting this looks like: you try POST /api/me/avatar, get a 403, and start checking roles, while the correct question is "was there a CSRF token at all?"
Another nuance that breaks expectations: if you see 403 in an admin/editor zone, make sure you are comparing role and authority in the correct form. hasRole("ADMIN") expects ROLE_ADMIN to be present, while hasAuthority("ADMIN") expects an exact string match. If you mixed naming conventions somewhere, 403 will look like "same role, but why not?" This is the case where the problem is not in the access logic, but in strings (the most annoying kind of error, because it looks like philosophy, but in reality it is a typo).
6. Spring Security logs: reading them without tears
Spring Security logs often scare beginners in the same way that git rebase --interactive scares someone who just wanted to "rename a commit." But logs have good news: you do not need to understand everything. You need to extract answers to two questions from them: "which chain ran?" and "what decision was made?".
The easiest way to do this on a local machine is to temporarily enable DEBUG level:
logging:
level:
# Enable DEBUG only during diagnostics: there will be a lot of logs
org.springframework.security: DEBUG
After that you start seeing how the request goes through the filter chain, which filters are applied, and exactly where the decision about 401/403 is made.
Use this carefully. DEBUG is like an X-ray: useful when there is suspicion, and harmful when you do it every morning "just in case." Keeping DEBUG on all the time means drowning in noise and stopping seeing the signal.
Now, how to read these logs "like an adult." First, you check whether the request actually entered the security chain. Then you check whether Authentication appeared (or stayed anonymous). Then you check where the denial decision was made. If the denial is AccessDenied, you are in 403 territory and you check roles/authorities/CSRF/owner-check. If the denial is about the absence of authentication, you are in 401 territory and you check why the auth source did not populate the context.
And a small psychological win: do not try to "read all the filters." You are not required to memorize the entire Spring Security catalog. You only need to learn how to answer the question "did my custom JWT filter even run?" or "was the CSRF check there?" That is enough for 80% of problems to stop being mysticism.
7. Common mistakes when troubleshooting security
Mistake #1: treating every denial as a 403, then looking for roles.
When a developer calls any denial "forbidden," they break their own diagnostics. 401 is the absence of authentication, and roles have nothing to do with it. As a result, someone spends hours checking hasRole(...), while in reality they did not send the Authorization header.
Mistake #2: diagnosing SecurityContext on a permitAll endpoint and concluding "there is no session."
This is the classic trap. For permitAll, Spring Security may not extract Authentication from the session. The documentation explicitly notes this behavior. If you inspect the context on a public route, you may see anonymous even though you are logged in — and that is not a bug, but an optimization/semantic choice.
Mistake #3: "the filter did not run" → immediately rewriting the filter.
Most often the filter "did not run" not because the filter code is bad, but because the request did not reach the right SecurityFilterChain (a securityMatcher mistake) or the filter is placed in the wrong order. Prove that the chain and order are correct first, and only then touch the code.
Mistake #4: the wrong matcher order that accidentally opens or closes everything.
A broad rule like /api/** can easily "eat" a more specific /api/admin/** if it appears above it. As a result, you either unexpectedly open the admin area or unexpectedly close everything. In both cases, the symptom looks like "Spring Security behaves strangely," while the cause is just the logic of rule order.
Mistake #5: confusing 403 caused by CSRF with 403 caused by roles.
In the session-based branch, the absence of a CSRF token on POST/PATCH/DELETE often looks like "no permissions," even though in fact it is "the request did not pass the CSRF check." In a multipart-upload scenario this is even worse: the file is sent, and you are looking for where the role was lost. Spring Security separately describes multipart+CSRF as a special case and recommends header-based token delivery when there is a JavaScript client.
GO TO FULL VERSION