Security — SecurityFilterChain
Getting Started
The SecurityFilterChain intercepts HTTP requests, so it is only relevant for web apps and web APIs. Other Spring Boot project types (batch jobs, Kafka consumers, scheduled tasks) have no HTTP layer and don't use it — security there is handled at the infrastructure level.
For a web API or web app, add these dependencies to your pom.xml:
<!-- HTTP server + @RestController, @RequestMapping, etc. -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- SecurityFilterChain, @Bean, BCrypt, etc. -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<!-- JPA + repository layer -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<!-- In-memory database for testing, swap for PostgreSQL/MySQL in production -->
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<scope>runtime</scope>
</dependency>
spring-boot-starter-webis what starts the embedded Tomcat server. Without it, your app has no HTTP server and exits immediately on startup.
If you declare no SecurityFilterChain bean, Spring Security activates a default one: every request is blocked and redirected to an auto-generated /login page. A random password is printed in the console at startup. The moment you declare your own @Bean SecurityFilterChain, it completely replaces that default.
Every incoming request passes through the SecurityFilterChain before reaching a controller. Spring Security already bundles a set of default filters (CSRF protection, XSS headers, session management, etc.). You configure the chain by declaring a SecurityFilterChain bean, and you can insert your own filters anywhere in it.
To see every filter being triggered on each request, add this to application.yml:
SecurityFilterChain
The entry point for your security configuration. You define which URLs require which roles, set up authentication, and register custom filters.
Create a SecurityConfig.java file in config/, annotated with @Configuration:
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
return http
.authorizeHttpRequests(auth -> {
auth.requestMatchers("/admin").hasRole("ADMIN");
auth.requestMatchers("/user").hasRole("USER");
auth.anyRequest().authenticated();
})
.formLogin(Customizer.withDefaults()) // auto-generated login form at /login
.build();
}
hasRole("ADMIN")internally looks for the authority"ROLE_ADMIN". Your role strings must carry that prefix.
Custom Filters
Extend OncePerRequestFilter to run logic on every request. Use addFilterBefore / addFilterAfter to position it relative to a built-in filter.
Create each filter as its own file in filter/ (e.g. filter/TestFilter.java):
public class TestFilter extends OncePerRequestFilter {
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
throws ServletException, IOException {
if (Objects.equals(request.getHeader("x-trigger-error"), "true")) {
response.setStatus(HttpStatus.FORBIDDEN.value());
response.getWriter().write("NOT ALLOWED");
return; // stop the chain — no further filters or controller
}
filterChain.doFilter(request, response); // pass request to the next filter
}
}
Register it in your SecurityFilterChain:
Every filter must either call
filterChain.doFilter()to continue the chain, or write a response andreturnto stop it. Forgetting one or the other will either skip your logic or swallow the request entirely.
Username / Password Authentication
Spring handles the comparison and session — you only need to teach it how to load a user from your database. The flow on POST /login:
UsernamePasswordAuthenticationFilter
→ AuthenticationManager
→ UserDetailsService.loadUserByUsername(username) ← you implement this
→ returns UserDetails object
→ BCrypt.verify(submittedPassword, UserDetails.getPassword())
→ stores Authentication in SecurityContextHolder
1. User entity — implement UserDetails
Your existing model/User.java — just make it implement UserDetails:
@Entity
@Table(name = "users") // "user" is a reserved SQL keyword in H2 and most DBs — always rename
@Setter // Lombok — generates setters only; avoids conflict with @Override getters below
public class User implements UserDetails {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String username;
private String password; // must be stored BCrypt-hashed
private String role; // e.g. "ROLE_USER" or "ROLE_ADMIN"
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return List.of(new SimpleGrantedAuthority(role));
}
@Override public String getPassword() { return password; }
@Override public String getUsername() { return username; }
// isAccountNonExpired / isAccountNonLocked / isCredentialsNonExpired / isEnabled
// default to true in Spring Security 6+ — only override if you need custom logic
}
Don't use
@Dataor@Getterfrom Lombok here — they would generategetPassword()andgetUsername()which conflicts with your@Overridemethods. Use@Setteronly.
2. UserDetailsService — load user from DB
Create service/CustomUserDetailsService.java:
@Service
public class CustomUserDetailsService implements UserDetailsService {
private final UserRepository userRepository;
public CustomUserDetailsService(UserRepository userRepository) {
this.userRepository = userRepository;
}
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
return userRepository.findByUsername(username)
.orElseThrow(() -> new UsernameNotFoundException("User not found: " + username));
}
}
3. Security beans
Back in config/SecurityConfig.java, add these beans alongside your SecurityFilterChain:
private final CustomUserDetailsService customUserDetailsService;
public SecurityConfig(CustomUserDetailsService customUserDetailsService) {
this.customUserDetailsService = customUserDetailsService;
}
// ...SecurityFilterChain...
@Bean
public BCryptPasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
@Bean
public AuthenticationManager authenticationManager(HttpSecurity http, BCryptPasswordEncoder passwordEncoder) throws Exception {
AuthenticationManagerBuilder builder = http.getSharedObject(AuthenticationManagerBuilder.class);
builder.userDetailsService(customUserDetailsService)
.passwordEncoder(passwordEncoder);
return builder.build();
}
4. Always hash passwords before saving
Minimal MVP — Testing Auth Locally
Everything above sets up the security layer. To actually run and test it end-to-end, you also need a controller, seed data, and H2 console access.
Controller
@RestController
public class TestController {
@GetMapping("/admin")
public String admin() { return "Hello, Admin!"; }
@GetMapping("/user")
public String user() { return "Hello, User!"; }
}
H2 console — SecurityConfig additions
H2 console uses iframes and needs CSRF disabled for its path. Add these to your SecurityFilterChain:
.authorizeHttpRequests(auth -> {
auth.requestMatchers("/h2-console/**").permitAll(); // global rules must be first (before role based rules under)
auth.requestMatchers("/admin").hasRole("ADMIN");
auth.requestMatchers("/user").hasRole("USER");
auth.anyRequest().authenticated();
})
.csrf(csrf -> csrf.ignoringRequestMatchers("/h2-console/**"))
.headers(h -> h.frameOptions(HeadersConfigurer.FrameOptionsConfig::sameOrigin))
.formLogin(Customizer.withDefaults())
.build();
application.yaml
spring:
application:
name: your-app-name
h2:
console:
enabled: true
datasource: # datasource is at spring level, NOT nested under h2
url: jdbc:h2:mem:testdb
logging:
level:
org.springframework.security: TRACE # remove in production
Seed data
Add a CommandLineRunner bean in your main application class to create test users on startup:
@Bean
CommandLineRunner seedUsers(UserRepository repo, BCryptPasswordEncoder encoder) {
return args -> {
if (repo.count() == 0) {
User admin = new User();
admin.setUsername("admin");
admin.setPassword(encoder.encode("admin123"));
admin.setRole("ROLE_ADMIN");
repo.save(admin);
User user = new User();
user.setUsername("user");
user.setPassword(encoder.encode("user123"));
user.setRole("ROLE_USER");
repo.save(user);
}
};
}
The
BCryptPasswordEncoderbean is defined inSecurityConfig— Spring injects it here automatically.
Test flow
- Start the app and go to
http://localhost:8080/user→ redirected to/login - Log in as
user/user123→Hello, User! - Try
http://localhost:8080/admin→ 403 Forbidden (wrong role) - Log out, log in as
admin/admin123→Hello, Admin! - H2 console at
http://localhost:8080/h2-console(JDBC URL:jdbc:h2:mem:testdb)
Common Mistakes
| Mistake | Symptom |
|---|---|
getAuthorities() returns empty list |
Role checks always fail silently |
Role stored as "USER" instead of "ROLE_USER" |
hasRole("USER") never matches |
| Password saved as plain text | BCrypt comparison always fails |
loadUserByUsername returns null |
NPE instead of a clean auth error |
Custom filter doesn't call filterChain.doFilter() |
Request is swallowed, never reaches controller |
Entity class named User without @Table(name = "users") |
H2 rejects select ... from user — user is a reserved SQL keyword |
Using @Data or @Getter with UserDetails |
Duplicate getPassword()/getUsername() methods — compile error |
datasource.url nested under h2: in yaml |
URL override silently ignored; default DB used instead |
Missing .build() at end of SecurityFilterChain |
Compile error: method returns HttpSecurity not SecurityFilterChain |
/h2-console/** not in permitAll() or CSRF not disabled for it |
H2 console returns 403 or broken page |