Skip to content

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-web is 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:

logging:
  level:
    org.springframework.security: TRACE

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:

.addFilterBefore(new TestFilter(), AuthorizationFilter.class)

Every filter must either call filterChain.doFilter() to continue the chain, or write a response and return to 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 @Data or @Getter from Lombok here — they would generate getPassword() and getUsername() which conflicts with your @Override methods. Use @Setter only.

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

user.setPassword(passwordEncoder.encode(rawPassword));
userRepository.save(user);

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 BCryptPasswordEncoder bean is defined in SecurityConfig — Spring injects it here automatically.

Test flow

  1. Start the app and go to http://localhost:8080/user → redirected to /login
  2. Log in as user / user123Hello, User!
  3. Try http://localhost:8080/admin → 403 Forbidden (wrong role)
  4. Log out, log in as admin / admin123Hello, Admin!
  5. 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 useruser 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