You had a working Spring Boot app. REST endpoints returning JSON. Everything great. You wrote controllers, tested them with Postman, life was good. Then someone on your team said, "We should add authentication." Sure. Makes sense. You added spring-boot-starter-security to your pom.xml. You restarted the app. Every single endpoint now returns 401 or 403. You didn't change any code. You didn't write a single line of security configuration. You just added a dependency.
Welcome to Spring Security.
There's a login page now, too. You didn't ask for a login page. You're building a REST API. But Spring Security decided you needed one. The default username is "user" and the password is a UUID printed in the console output that you have to scroll up to find. This is your first five minutes with Spring Security, and you're already confused.
I've introduced Spring Security to three different projects over the years. Every single time, the first hour looks exactly like this. It's a rite of passage.
The Filter Chain Confusion
The core concept in Spring Security is the SecurityFilterChain. Every HTTP request passes through a chain of filters before it reaches your controller. These filters handle authentication, authorization, CSRF protection, session management, and a bunch of other things. You configure which filters apply and how they behave.
If you've been writing Spring Boot apps by just creating controllers and services, this is a mental shift. You're not just writing endpoints anymore. You're configuring a security pipeline that sits in front of your endpoints. The pipeline decides whether a request even gets to your code.
Here's what a typical SecurityFilterChain looks like:
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
return http
.csrf(csrf -> csrf.disable())
.sessionManagement(session ->
session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/auth/**").permitAll()
.requestMatchers("/api/admin/**").hasRole("ADMIN")
.requestMatchers("/api/**").authenticated()
.anyRequest().permitAll()
)
.build();
}This looks reasonable. But it hides a trap that catches almost everyone at some point: the order of matchers matters. The first matching rule wins. Spring Security doesn't pick the most specific rule. It picks the first one that matches the request path.
Now look at this version. Spot the bug:
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
return http
.csrf(csrf -> csrf.disable())
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/**").authenticated()
.requestMatchers("/api/auth/**").permitAll() // This never matches!
.requestMatchers("/api/admin/**").hasRole("ADMIN") // Neither does this.
.anyRequest().permitAll()
)
.build();
}The /api/** rule is first. It matches everything under /api/, including /api/auth/login and /api/admin/users. The permitAll() on the auth endpoints never kicks in because the broader rule already caught the request. Your login endpoint now requires authentication. Which makes it useless.
This isn't a weird edge case. I've seen this exact bug in production code, in tutorials, and in Stack Overflow answers marked as accepted. The fix is simple: put specific rules before general ones. But the framework doesn't warn you. It just silently applies the first match and moves on.
The difference between permitAll(), authenticated(), and hasRole() seems obvious when you have three endpoints. It gets messy when you have 40 endpoints spread across 15 controllers, some public, some requiring authentication, some restricted to admins, some restricted to specific roles. At that point, your SecurityFilterChain becomes a long list of matchers that nobody wants to touch because they're afraid of breaking something.
The WebSecurityConfigurerAdapter Migration
For years, the standard way to configure Spring Security was to extend WebSecurityConfigurerAdapter and override methods. Every tutorial showed this. Every Stack Overflow answer used it. Every blog post, including mine from 2021, showed how to configure security by extending this class.
// The old way (deprecated since Spring Security 5.7)
@Configuration
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Override
protected void configure(HttpSecurity http) throws Exception {
http
.csrf().disable()
.authorizeRequests()
.antMatchers("/api/auth/**").permitAll()
.antMatchers("/api/admin/**").hasRole("ADMIN")
.anyRequest().authenticated();
}
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth.userDetailsService(userDetailsService)
.passwordEncoder(passwordEncoder());
}
}Then Spring Security 5.7 came along and deprecated the whole thing. The new approach uses component-based configuration with @Bean methods:
// The new way
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
return http
.csrf(csrf -> csrf.disable())
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/auth/**").permitAll()
.requestMatchers("/api/admin/**").hasRole("ADMIN")
.anyRequest().authenticated()
)
.build();
}
@Bean
public AuthenticationManager authenticationManager(
AuthenticationConfiguration config) throws Exception {
return config.getAuthenticationManager();
}
}The new way is actually better. No inheritance, no method overriding, just beans. It's more explicit and easier to reason about. But the transition was painful. You finally learned how to configure Spring Security, built muscle memory around the patterns, and then the framework told you it was all wrong now.
The real problem isn't the deprecation itself. It's the aftermath. Search for "Spring Security configuration" today and half the results still show the old WebSecurityConfigurerAdapter approach. Beginners copy-paste from a 2020 tutorial, get deprecation warnings, Google the warnings, find a 2022 migration guide, and spend an hour rewriting code they didn't fully understand in the first place. It's a frustrating loop.
The Spring team has been improving the documentation around this, and the migration guides are solid. But the internet has a long memory, and outdated tutorials rank well on Google.
The 403 Mystery
If I had to pick one thing that defines the Spring Security experience for most developers, it's the 403 Forbidden response. No helpful error message. No explanation. Just "Access Denied." That's it. Good luck.
You stare at the response. Back to the code. Back to the response. Nothing makes sense. Your endpoint is configured with permitAll(). You're sure of it. Three rechecks. But the 403 keeps coming back.
Here are the usual suspects, in order of how often they burn people:
CSRF Is Enabled by Default
Spring Security enables CSRF protection by default. This makes sense for server-rendered web applications with HTML forms. It makes no sense for a stateless REST API. But Spring Security doesn't know you're building a REST API. It assumes you might have forms, so it requires a CSRF token on every POST, PUT, and DELETE request.
Your GET requests work fine. Your POST requests return 403. You didn't change anything between GET and POST except the HTTP method. It feels like a bug, but it's not. It's CSRF protection doing its job.
The fix:
http.csrf(csrf -> csrf.disable())One line. That's all it takes. But if you don't know that CSRF is the problem, you can spend hours looking in the wrong places.
CORS Configuration
You're calling your API from a React frontend on localhost:3000. The API runs on localhost:8080. Different ports mean different origins, which means CORS applies. Without explicit CORS configuration, the browser's preflight OPTIONS request gets rejected, and your actual request never fires.
@Bean
public CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration config = new CorsConfiguration();
config.setAllowedOrigins(List.of("http://localhost:3000"));
config.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE"));
config.setAllowedHeaders(List.of("*"));
config.setAllowCredentials(true);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", config);
return source;
}And then in your security chain:
http.cors(cors -> cors.configurationSource(corsConfigurationSource()))Wrong Matcher Order
I already covered this above, but it's worth repeating because it's the third most common cause of mysterious 403s. A broad rule catches the request before a specific permitAll() rule has a chance to match. No warnings, no logs, just a silent mismatch.
The Debugging Ritual
When nothing else works, there's a ritual. You enable debug logging for Spring Security:
logging.level.org.springframework.security=DEBUGThis dumps the entire filter chain execution into your console. Every filter, every decision point, every check. It's 200+ lines of output for a single request. Somewhere in those 200 lines is the one line that tells you what went wrong. Finding it is like reading server logs in a language you half-remember from college. But it works. Eventually.
The lesson I keep relearning: Spring Security's defaults are designed for traditional server-rendered applications with session-based authentication and HTML forms. If you're building a stateless REST API with token-based auth, you're working against the defaults. Once you know that, the behavior makes sense. You just need to turn off the things you don't need.
What the Docs Get Wrong
The Spring Security documentation is thorough. It covers every component, every configuration option, every filter. It's a reference manual, and as a reference manual, it's good.
But it's not a great learning resource. The docs explain what each component does without explaining why you'd use it or when. If you already understand the security model, the docs fill in the details. If you don't understand the model, the docs overwhelm you with details you can't contextualize.
Here's the core problem: a beginner who just got a 403 on their POST request doesn't know to search for "CSRF." They don't know CSRF is a thing. They search for "Spring Security 403 POST" and get twenty different answers about twenty different causes. The docs have a page on CSRF, but it starts by explaining what CSRF attacks are and how the CsrfFilter works internally. What the beginner actually needs is one sentence: "If you're building a REST API, disable CSRF with http.csrf(csrf -> csrf.disable())."
The architecture documentation shows a diagram of 15 filters in a chain. It's accurate. It's also overwhelming on first read. You look at that diagram and think, "I need to understand all of this before I can add a login endpoint?" You don't, but the docs don't tell you which parts to skip.
What would actually help is a "common scenarios" section at the top. Building a REST API with JWT? Here's the minimal config you need. Building a web app with form login? Here's yours. Building an OAuth2 resource server? Here's the setup. Start with the 80% case, not the complete architecture.
Credit where it's due: the migration guides for moving away from WebSecurityConfigurerAdapter are well-written. And the docs have been getting better over the past couple of years. The Spring team clearly listens to feedback. But there's still a gap between "I added the dependency" and "I understand how this works" that the docs don't bridge well.
When It Clicks
After the struggle, something shifts. It usually happens a few weeks in, after you've debugged your fifth 403 and configured your third project from scratch. The model starts making sense.
Every request goes through a pipeline of security checks. You configure which checks apply to which paths. Authentication answers "who are you?" Authorization answers "are you allowed to do this?" They're separate concerns, handled by separate components. That split is clean, and keeping them separate is the right design decision.
The SecurityFilterChain stops feeling like a mystery box and starts feeling like a configuration file. You know the patterns. CSRF off for APIs. Stateless sessions for token-based auth. Specific matchers before general ones. It becomes mechanical.
Then you discover method-level security, and things get even better:
@RestController
@RequestMapping("/api/posts")
public class PostController {
@GetMapping
public List<PostDto> getAllPosts() {
// Public, no annotation needed
return postService.findAll();
}
@PostMapping
@PreAuthorize("hasRole('AUTHOR')")
public PostDto createPost(@RequestBody CreatePostRequest request) {
return postService.create(request);
}
@DeleteMapping("/{id}")
@PreAuthorize("hasRole('ADMIN') or @postService.isOwner(#id, authentication.name)")
public void deletePost(@PathVariable Long id) {
postService.delete(id);
}
}Instead of configuring every endpoint in one central filter chain, you put the security rule right on the method. @PreAuthorize with SpEL expressions lets you write rules like "admins can delete any post, but authors can only delete their own." That's readable. That's maintainable. That's the kind of code where the security intent is obvious at a glance.
The flexibility that frustrated you early on becomes an asset once you need it. Custom authentication for API keys, multi-tenant security with different auth providers per tenant, OAuth2 resource servers validating external JWTs. Spring Security handles all of these. The framework handles all of these because the design favors configurability over simplicity. That's a deliberate trade-off, and in hindsight, it's the right one.
The Friction Is the Point
Spring Security is hard. Steep learning curve. Unhelpful error messages. Defaults that confuse you if you're building a REST API. None of that is fun.
But security should be hard to get wrong. A framework that makes security configuration effortless probably makes it easy to misconfigure, too. The friction forces you to think about what you're allowing and what you're blocking. Every permitAll() is an explicit decision. Every hasRole() is an explicit decision. You can't accidentally leave an admin endpoint open because you forgot to add security. The default is locked down. You have to consciously open things up.
That's annoying on day one. It's reassuring on day 100 when you're running in production and handling real user data.
If you're in the middle of the struggle right now, it gets better. Disable CSRF if you're building an API. Set session management to stateless. Enable debug logging when you're stuck. Read the filter chain output line by line. Put your specific matchers before the general ones. And know that every Spring developer, including the ones who write confidently about it in blog posts, has spent time staring at a 403 wondering what they did wrong.
You'll get past it. And then you'll add security to your next project and wonder why you ever found it confusing. That's how it always goes.
Comments (0)