Spring Security 6.x best practices. Covers SecurityFilterChain, JWT auth, OAuth2, CORS, CSRF, method-level security, password encoding, and rate limiting for Spring Boot 3.x applications.
Install
npx skillscat add peopleforrester/claude-dotfiles/springboot-security Install via the SkillsCat registry.
SKILL.md
Spring Boot Security Patterns
Security configuration for Spring Boot 3.x with Spring Security 6.x.
SecurityFilterChain Configuration
@Configuration
@EnableWebSecurity
@EnableMethodSecurity
@RequiredArgsConstructor
public class SecurityConfig {
private final JwtAuthFilter jwtAuthFilter;
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
return http
.csrf(csrf -> csrf.disable()) // Disable for stateless API
.cors(cors -> cors.configurationSource(corsConfig()))
.sessionManagement(session ->
session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/auth/**").permitAll()
.requestMatchers("/actuator/health").permitAll()
.requestMatchers("/api/admin/**").hasRole("ADMIN")
.anyRequest().authenticated())
.addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class)
.build();
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder(12);
}
private CorsConfigurationSource corsConfig() {
CorsConfiguration config = new CorsConfiguration();
config.setAllowedOrigins(List.of("https://example.com"));
config.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE"));
config.setAllowedHeaders(List.of("Authorization", "Content-Type"));
config.setMaxAge(3600L);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/api/**", config);
return source;
}
}JWT Authentication Filter
@Component
@RequiredArgsConstructor
public class JwtAuthFilter extends OncePerRequestFilter {
private final JwtService jwtService;
private final UserDetailsService userDetailsService;
@Override
protected void doFilterInternal(
HttpServletRequest request,
HttpServletResponse response,
FilterChain chain) throws ServletException, IOException {
String header = request.getHeader("Authorization");
if (header == null || !header.startsWith("Bearer ")) {
chain.doFilter(request, response);
return;
}
String token = header.substring(7);
String username = jwtService.extractUsername(token);
if (username != null && SecurityContextHolder.getContext().getAuthentication() == null) {
UserDetails userDetails = userDetailsService.loadUserByUsername(username);
if (jwtService.isValid(token, userDetails)) {
UsernamePasswordAuthenticationToken auth =
new UsernamePasswordAuthenticationToken(
userDetails, null, userDetails.getAuthorities());
auth.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
SecurityContextHolder.getContext().setAuthentication(auth);
}
}
chain.doFilter(request, response);
}
}JWT Service
@Service
public class JwtService {
@Value("${jwt.secret}")
private String secretKey;
@Value("${jwt.expiration:3600000}") // 1 hour default
private long expiration;
public String generateToken(UserDetails userDetails) {
return Jwts.builder()
.subject(userDetails.getUsername())
.issuedAt(new Date())
.expiration(new Date(System.currentTimeMillis() + expiration))
.signWith(getSigningKey())
.compact();
}
public String extractUsername(String token) {
return extractClaim(token, Claims::getSubject);
}
public boolean isValid(String token, UserDetails userDetails) {
String username = extractUsername(token);
return username.equals(userDetails.getUsername()) && !isExpired(token);
}
private boolean isExpired(String token) {
return extractClaim(token, Claims::getExpiration).before(new Date());
}
private <T> T extractClaim(String token, Function<Claims, T> resolver) {
Claims claims = Jwts.parser()
.verifyWith(getSigningKey())
.build()
.parseSignedClaims(token)
.getPayload();
return resolver.apply(claims);
}
private SecretKey getSigningKey() {
return Keys.hmacShaKeyFor(Decoders.BASE64.decode(secretKey));
}
}Method-Level Security
@Service
@RequiredArgsConstructor
public class UserServiceImpl implements UserService {
@PreAuthorize("hasRole('ADMIN')")
public void deleteUser(UUID id) {
userRepository.deleteById(id);
}
@PreAuthorize("#userId == authentication.principal.id or hasRole('ADMIN')")
public UserResponse getUser(UUID userId) {
return userRepository.findById(userId)
.map(UserResponse::from)
.orElseThrow(() -> new ResourceNotFoundException("User", userId));
}
@PostAuthorize("returnObject.email == authentication.principal.username")
public UserResponse getCurrentUser() {
// Only return if result matches authenticated user
return findCurrentUser();
}
}Input Validation
// Request DTOs with validation constraints
public record LoginRequest(
@NotBlank @Email String email,
@NotBlank @Size(min = 8, max = 128) String password
) {}
public record RegisterRequest(
@NotBlank @Size(min = 2, max = 50) String name,
@NotBlank @Email String email,
@NotBlank @Size(min = 12, max = 128)
@Pattern(regexp = "^(?=.*[a-z])(?=.*[A-Z])(?=.*\\d).*$",
message = "Must contain uppercase, lowercase, and digit")
String password
) {}Rate Limiting
// Using Bucket4j for rate limiting
@Component
@RequiredArgsConstructor
public class RateLimitFilter extends OncePerRequestFilter {
private final Map<String, Bucket> buckets = new ConcurrentHashMap<>();
@Override
protected void doFilterInternal(
HttpServletRequest request,
HttpServletResponse response,
FilterChain chain) throws ServletException, IOException {
String clientIp = request.getRemoteAddr();
Bucket bucket = buckets.computeIfAbsent(clientIp, this::createBucket);
if (bucket.tryConsume(1)) {
chain.doFilter(request, response);
} else {
response.setStatus(HttpStatus.TOO_MANY_REQUESTS.value());
response.getWriter().write("{\"error\":\"Rate limit exceeded\"}");
}
}
private Bucket createBucket(String key) {
return Bucket.builder()
.addLimit(Bandwidth.classic(100, Refill.greedy(100, Duration.ofMinutes(1))))
.build();
}
}Security Headers
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
return http
.headers(headers -> headers
.contentTypeOptions(Customizer.withDefaults())
.frameOptions(frame -> frame.deny())
.httpStrictTransportSecurity(hsts -> hsts
.includeSubDomains(true)
.maxAgeInSeconds(31536000))
.contentSecurityPolicy(csp -> csp
.policyDirectives("default-src 'self'; frame-ancestors 'none'")))
// ... rest of config
.build();
}Secrets Configuration
# application.yml - reference environment variables
spring:
datasource:
url: ${DATABASE_URL}
username: ${DB_USERNAME}
password: ${DB_PASSWORD}
jwt:
secret: ${JWT_SECRET_KEY}
expiration: ${JWT_EXPIRATION:3600000}Checklist
- SecurityFilterChain configured (not deprecated WebSecurityConfigurerAdapter)
- Stateless session management for API applications
- BCrypt password encoder with cost factor >= 12
- JWT secrets loaded from environment, never hardcoded
- Method-level security with
@PreAuthorizefor fine-grained access - CORS restricted to specific origins
- Rate limiting on authentication endpoints
- Input validation on all request DTOs
- Security headers configured (HSTS, CSP, X-Frame-Options)
- Actuator endpoints secured (health only public)