Spring Security Fundamentals: Filter Chain, Authentication, and Authorization
Spring Security is powerful but famously hard to understand. This article demystifies the core: the filter chain, how requests are processed, and how authentication and authorization work before writing a line of security config.
Setup
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
The moment you add this dependency, Spring Boot’s auto-configuration secures all endpoints with HTTP Basic authentication. A random password is printed at startup. This is the starting point — you’ll replace the defaults.
The Security Filter Chain
Spring Security is a chain of servlet filters that runs before your controller:
HTTP Request arrives
│
▼
SecurityContextPersistenceFilter ← loads SecurityContext from session
│
▼
UsernamePasswordAuthenticationFilter ← processes login form submissions
│
▼
BearerTokenAuthenticationFilter ← processes JWT Bearer tokens
│
▼
ExceptionTranslationFilter ← converts security exceptions to HTTP responses
│
▼
AuthorizationFilter ← checks if the request is authorized
│
├─ Authorized → your @Controller handles the request
│
└─ Not authorized → 401/403 response
Each filter can either:
- Pass the request to the next filter
- Short-circuit (return a response immediately)
- Throw an exception (caught by ExceptionTranslationFilter)
You configure the filter chain through a SecurityFilterChain bean.
Authentication vs Authorization
Authentication: Who are you? (identity verification)
Authorization: What are you allowed to do? (permission check)
Spring Security handles both:
- Authentication: verifies credentials (username+password, JWT, API key, OAuth token)
- Authorization: checks roles, permissions, or custom rules against the authenticated identity
The SecurityContext
After successful authentication, Spring stores the identity in SecurityContextHolder:
// Get the current user anywhere in your application
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
String username = auth.getName();
Collection<? extends GrantedAuthority> roles = auth.getAuthorities();
Object principal = auth.getPrincipal(); // usually UserDetails or a custom object
// In a controller
@GetMapping("/api/me")
public UserResponse getCurrentUser(Principal principal) {
return userService.findByUsername(principal.getName());
}
// Or with @AuthenticationPrincipal
@GetMapping("/api/me")
public UserResponse getCurrentUser(@AuthenticationPrincipal UserDetails user) {
return UserResponse.from(user);
}
The SecurityContext is stored in a ThreadLocal by default — bound to the current request thread.
Configuring the SecurityFilterChain
Replace the auto-configured security with your own:
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
// Disable CSRF for REST APIs (stateless — no session cookies)
.csrf(csrf -> csrf.disable())
// Session management: stateless (JWT-based, no server-side sessions)
.sessionManagement(session ->
session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
// Authorization rules
.authorizeHttpRequests(auth -> auth
// Public endpoints — no authentication required
.requestMatchers("/api/auth/**").permitAll()
.requestMatchers(HttpMethod.GET, "/api/products/**").permitAll()
.requestMatchers("/actuator/health", "/actuator/info").permitAll()
.requestMatchers("/swagger-ui/**", "/v3/api-docs/**").permitAll()
// Require ADMIN role
.requestMatchers("/api/admin/**").hasRole("ADMIN")
// Require MANAGER or ADMIN for certain operations
.requestMatchers(HttpMethod.DELETE, "/api/orders/**")
.hasAnyRole("ADMIN", "MANAGER")
// Everything else requires authentication
.anyRequest().authenticated()
)
// Error handling
.exceptionHandling(ex -> ex
.authenticationEntryPoint(new HttpStatusEntryPoint(HttpStatus.UNAUTHORIZED))
.accessDeniedHandler(
(req, res, e) -> res.sendError(HttpServletResponse.SC_FORBIDDEN))
);
return http.build();
}
}
UserDetails — The Core Authentication Contract
Spring Security’s authentication is built around UserDetails:
public interface UserDetails {
String getUsername();
String getPassword();
Collection<? extends GrantedAuthority> getAuthorities(); // roles/permissions
boolean isAccountNonExpired();
boolean isAccountNonLocked();
boolean isCredentialsNonExpired();
boolean isEnabled();
}
Implement it with your User entity:
@Entity
@Table(name = "users")
public class User implements UserDetails {
@Id
@GeneratedValue(strategy = GenerationType.UUID)
private UUID id;
@Column(unique = true, nullable = false)
private String username;
@Column(nullable = false)
private String password; // hashed
@ElementCollection(fetch = FetchType.EAGER)
@CollectionTable(name = "user_roles")
@Column(name = "role")
private Set<String> roles = new HashSet<>();
private boolean enabled = true;
private boolean accountNonLocked = true;
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return roles.stream()
.map(role -> new SimpleGrantedAuthority("ROLE_" + role))
.toList();
}
@Override public boolean isAccountNonExpired() { return true; }
@Override public boolean isAccountNonLocked() { return accountNonLocked; }
@Override public boolean isCredentialsNonExpired() { return true; }
@Override public boolean isEnabled() { return enabled; }
// other getters
}
UserDetailsService — Loading Users
Spring Security calls UserDetailsService.loadUserByUsername() during authentication:
@Service
@RequiredArgsConstructor
public class UserDetailsServiceImpl implements UserDetailsService {
private final UserRepository userRepository;
@Override
public UserDetails loadUserByUsername(String username)
throws UsernameNotFoundException {
return userRepository.findByUsername(username)
.orElseThrow(() -> new UsernameNotFoundException(
"User not found: " + username));
}
}
Register it in the security config:
@Bean
public AuthenticationManager authenticationManager(
UserDetailsService userDetailsService,
PasswordEncoder passwordEncoder) {
DaoAuthenticationProvider provider = new DaoAuthenticationProvider();
provider.setUserDetailsService(userDetailsService);
provider.setPasswordEncoder(passwordEncoder);
return new ProviderManager(provider);
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder(12); // cost factor 12
}
Authorization: Roles and Authorities
Spring Security distinguishes roles and authorities:
- Authority: a permission string (e.g.,
READ_ORDERS,WRITE_PRODUCTS) - Role: a special authority with the
ROLE_prefix (e.g.,ROLE_ADMIN)
// hasRole("ADMIN") checks for GrantedAuthority "ROLE_ADMIN"
.requestMatchers("/api/admin/**").hasRole("ADMIN")
// hasAuthority("READ_ORDERS") checks for GrantedAuthority "READ_ORDERS" exactly
.requestMatchers(HttpMethod.GET, "/api/orders/**").hasAuthority("READ_ORDERS")
// access() with SpEL for complex rules
.requestMatchers("/api/orders/{id}").access(
"@orderSecurityService.canAccess(authentication, #id)")
Method-Level Security
Protect service methods directly with annotations:
@Configuration
@EnableMethodSecurity // enables @PreAuthorize, @PostAuthorize, @Secured
public class MethodSecurityConfig {}
@Service
public class OrderService {
@PreAuthorize("hasRole('ADMIN') or @orderSecurity.isOwner(authentication, #id)")
public Order findById(UUID id) { ... }
@PreAuthorize("hasRole('ADMIN')")
public void deleteOrder(UUID id) { ... }
@PostAuthorize("returnObject.customerId == authentication.principal.id")
public Order getOrder(UUID id) { ... }
@Secured({"ROLE_ADMIN", "ROLE_MANAGER"})
public List<Order> findAll() { ... }
}
@Component("orderSecurity")
public class OrderSecurityService {
private final OrderRepository orderRepository;
public boolean isOwner(Authentication auth, UUID orderId) {
UserDetails user = (UserDetails) auth.getPrincipal();
return orderRepository.findById(orderId)
.map(order -> order.getCustomerUsername().equals(user.getUsername()))
.orElse(false);
}
}
What Happens When Security Fails
When authentication or authorization fails, ExceptionTranslationFilter handles it:
AuthenticationException→ callsAuthenticationEntryPoint→ typically 401AccessDeniedException→ callsAccessDeniedHandler→ typically 403
Configure them:
.exceptionHandling(ex -> ex
.authenticationEntryPoint((request, response, authException) -> {
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
response.getWriter().write("""
{
"type": "https://devopsmonk.com/errors/unauthorized",
"title": "Unauthorized",
"status": 401,
"detail": "%s"
}
""".formatted(authException.getMessage()));
})
.accessDeniedHandler((request, response, accessDeniedException) -> {
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
response.setStatus(HttpServletResponse.SC_FORBIDDEN);
response.getWriter().write("""
{
"type": "https://devopsmonk.com/errors/forbidden",
"title": "Forbidden",
"status": 403,
"detail": "You don't have permission to access this resource"
}
""");
})
)
Multiple Security Filter Chains
For APIs with different security requirements (public API vs admin API):
@Configuration
@EnableWebSecurity
public class SecurityConfig {
// Admin API — higher security
@Bean
@Order(1)
public SecurityFilterChain adminChain(HttpSecurity http) throws Exception {
http
.securityMatcher("/api/admin/**")
.authorizeHttpRequests(auth -> auth.anyRequest().hasRole("ADMIN"))
.httpBasic(Customizer.withDefaults());
return http.build();
}
// Public API — JWT-based
@Bean
@Order(2)
public SecurityFilterChain apiChain(HttpSecurity http,
JwtAuthenticationFilter jwtFilter) throws Exception {
http
.csrf(csrf -> csrf.disable())
.sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class)
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/auth/**").permitAll()
.anyRequest().authenticated()
);
return http.build();
}
}
What You’ve Learned
- Spring Security runs as a chain of servlet filters before your controllers
SecurityContextHolderholds the authenticated identity for the current thread- Configure security with a
SecurityFilterChainbean — disable CSRF for REST APIs, use STATELESS sessions UserDetails+UserDetailsServiceare the core authentication contractsGrantedAuthorityrepresents permissions; roles areROLE_prefixed authorities@PreAuthorize/@PostAuthorizeenable method-level securityAuthenticationEntryPointhandles 401;AccessDeniedHandlerhandles 403
Next: Article 24 — Password Encoding and User Authentication — BCrypt, registering users, and the login endpoint.