Security defaults that belong in every Spring Boot application from day one.
88
83%
Does it follow best practices?
Impact
97%
1.79xAverage score across 5 eval scenarios
Passed
No known issues
Every Spring Boot app needs these security defaults from day one -- do not wait for a security review to add them. These are not optional hardening steps; they are baseline requirements for any Spring Boot application.
When to apply this skill: Every time you create a new Spring Boot application, add endpoints to an existing one, or refactor a Spring Boot project. Even if the task only asks you to "build a REST API" or "create CRUD endpoints," you MUST include these security measures. A Spring Boot app without them is incomplete.
These seven items are as fundamental as adding spring-boot-starter-web. If your app has endpoints but lacks these, it is not ready for any environment.
Since Spring Security 5.7 / Spring Boot 3.x, WebSecurityConfigurerAdapter is deprecated and removed. Always use a SecurityFilterChain bean.
WRONG -- using deprecated WebSecurityConfigurerAdapter:
@Configuration
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter { // DEPRECATED and REMOVED
@Override
protected void configure(HttpSecurity http) throws Exception {
http.csrf().disable(); // Old lambda-less style
}
}RIGHT -- SecurityFilterChain bean with lambda DSL:
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.csrf(csrf -> csrf.disable()) // Stateless API -- see CSRF section below
.cors(cors -> cors.configurationSource(corsConfigurationSource()))
.headers(headers -> headers
.frameOptions(frame -> frame.deny())
.contentTypeOptions(content -> {})
.httpStrictTransportSecurity(hsts -> hsts
.includeSubDomains(true)
.maxAgeInSeconds(31536000)))
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/auth/**").permitAll()
.requestMatchers("/actuator/health").permitAll()
.anyRequest().authenticated())
.sessionManagement(session ->
session.sessionCreationPolicy(SessionCreationPolicy.STATELESS));
return http.build();
}
}Always use the lambda DSL (e.g., csrf(csrf -> csrf.disable())) rather than the older chaining style (e.g., .csrf().disable()). The chaining style is deprecated in Spring Security 6.x.
CORS must be configured in the SecurityFilterChain so that preflight requests are handled by Spring Security. Configuring CORS only in WebMvcConfigurer without enabling it in the security chain will cause preflight requests to be rejected.
WRONG -- CORS only in WebMvcConfigurer without security integration:
@Configuration
public class WebConfig implements WebMvcConfigurer {
@Override
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/**").allowedOrigins("*"); // Wide open AND not security-integrated
}
}WRONG -- allowing all origins:
@Bean
CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration config = new CorsConfiguration();
config.addAllowedOrigin("*"); // Allows any origin -- dangerous
config.addAllowedMethod("*");
config.addAllowedHeader("*");
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", config);
return source;
}RIGHT -- explicit origins integrated with SecurityFilterChain:
@Bean
CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration config = new CorsConfiguration();
// Explicit origins from configuration -- never use "*" with credentials
config.setAllowedOrigins(List.of(
"${app.cors.allowed-origins:http://localhost:5173}".split(",")
));
config.setAllowedMethods(List.of("GET", "POST", "PUT", "PATCH", "DELETE"));
config.setAllowedHeaders(List.of("Content-Type", "Authorization"));
config.setAllowCredentials(true);
config.setMaxAge(3600L);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/api/**", config);
return source;
}
// In SecurityFilterChain:
http.cors(cors -> cors.configurationSource(corsConfigurationSource()));Better yet, load origins from application properties:
@Value("${app.cors.allowed-origins:http://localhost:5173}")
private String allowedOrigins;
@Bean
CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration config = new CorsConfiguration();
config.setAllowedOrigins(Arrays.asList(allowedOrigins.split(",")));
config.setAllowedMethods(List.of("GET", "POST", "PUT", "PATCH", "DELETE"));
config.setAllowedHeaders(List.of("Content-Type", "Authorization"));
config.setAllowCredentials(true);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/api/**", config);
return source;
}CSRF protection is enabled by default in Spring Security. The correct choice depends on whether your API is stateless (JWT/token-based) or session-based.
For stateless APIs (JWT, no cookies): disable CSRF.
http.csrf(csrf -> csrf.disable());For session-based apps (cookies, server-side sessions): keep CSRF enabled with a cookie-based token repository so JavaScript frontends can read the token:
http.csrf(csrf -> csrf
.csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse())
.csrfTokenRequestHandler(new CsrfTokenRequestAttributeHandler()));WRONG -- disabling CSRF on a session-based app:
// App uses HttpSession and cookies, but CSRF is disabled -- vulnerable to CSRF attacks
http
.csrf(csrf -> csrf.disable())
.sessionManagement(session ->
session.sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED));WRONG -- using the old deprecated style:
http.csrf().disable(); // Deprecated chaining styleRule of thumb: If SessionCreationPolicy.STATELESS, disable CSRF. If sessions are used, keep CSRF enabled with CookieCsrfTokenRepository.
Never store passwords in plain text. Always use BCrypt via Spring Security's PasswordEncoder.
WRONG -- storing plain text passwords:
user.setPassword(request.getPassword()); // Plain text -- catastrophic if database is breachedWRONG -- using weak hashing:
user.setPassword(DigestUtils.md5DigestAsHex(password.getBytes())); // MD5 is broken
user.setPassword(DigestUtils.sha256Hex(password)); // SHA-256 without salt is insufficientRIGHT -- BCryptPasswordEncoder bean and usage:
@Configuration
public class SecurityConfig {
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
}
// In your service:
@Service
public class UserService {
private final PasswordEncoder passwordEncoder;
public UserService(PasswordEncoder passwordEncoder) {
this.passwordEncoder = passwordEncoder;
}
public User register(RegisterRequest request) {
User user = new User();
user.setEmail(request.getEmail());
user.setPassword(passwordEncoder.encode(request.getPassword()));
return userRepository.save(user);
}
public boolean authenticate(String rawPassword, String storedHash) {
return passwordEncoder.matches(rawPassword, storedHash);
}
}Always inject PasswordEncoder -- never instantiate BCryptPasswordEncoder directly in service code. This makes testing easier and allows switching algorithms later.
Spring Boot does not include rate limiting out of the box. Use Bucket4j or a servlet filter.
WRONG -- no rate limiting at all:
@PostMapping("/api/auth/login")
public ResponseEntity<?> login(@RequestBody LoginRequest request) {
// Unlimited login attempts -- brute force is trivial
}RIGHT -- rate limiting with Bucket4j:
Add the dependency:
<dependency>
<groupId>com.bucket4j</groupId>
<artifactId>bucket4j-core</artifactId>
<version>8.7.0</version>
</dependency>@Component
@Order(1)
public class RateLimitFilter extends OncePerRequestFilter {
private final Map<String, Bucket> buckets = new ConcurrentHashMap<>();
private Bucket createGeneralBucket() {
return Bucket.builder()
.addLimit(Bandwidth.classic(100, Refill.intervally(100, Duration.ofMinutes(15))))
.build();
}
private Bucket createAuthBucket() {
return Bucket.builder()
.addLimit(Bandwidth.classic(10, Refill.intervally(10, Duration.ofMinutes(15))))
.build();
}
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response, FilterChain chain)
throws ServletException, IOException {
String ip = request.getRemoteAddr();
String path = request.getRequestURI();
// Stricter limit for auth endpoints
boolean isAuthEndpoint = path.startsWith("/api/auth");
String key = isAuthEndpoint ? ip + ":auth" : ip + ":general";
Bucket bucket = buckets.computeIfAbsent(key,
k -> isAuthEndpoint ? createAuthBucket() : createGeneralBucket());
if (bucket.tryConsume(1)) {
chain.doFilter(request, response);
} else {
response.setStatus(429);
response.setContentType("application/json");
response.getWriter().write(
"{\"error\":{\"code\":\"RATE_LIMITED\",\"message\":\"Too many requests\"}}");
}
}
}Always apply a stricter rate limit on auth endpoints (login, register, password reset) and mutation endpoints (POST, PUT, DELETE) than on general read endpoints.
Configure security headers through HttpSecurity.headers() in your SecurityFilterChain -- not manually via servlet filters.
WRONG -- only setting minimal headers or no headers at all:
http.headers(headers -> headers.defaultsDisabled()); // Disables all security headersRIGHT -- comprehensive security headers:
http.headers(headers -> headers
.frameOptions(frame -> frame.deny()) // Prevents clickjacking
.contentTypeOptions(content -> {}) // X-Content-Type-Options: nosniff
.httpStrictTransportSecurity(hsts -> hsts // Forces HTTPS
.includeSubDomains(true)
.maxAgeInSeconds(31536000))
.xssProtection(xss -> xss.disable()) // X-XSS-Protection: 0 (legacy filter is harmful)
.contentSecurityPolicy(csp ->
csp.policyDirectives("default-src 'self'; frame-ancestors 'none'"))
.permissionsPolicy(permissions ->
permissions.policy("camera=(), microphone=(), geolocation=()")));For API-only servers that never serve HTML, a minimal CSP is fine:
.contentSecurityPolicy(csp ->
csp.policyDirectives("default-src 'none'; frame-ancestors 'none'"))For production, force HTTPS:
http.requiresChannel(channel -> channel.anyRequest().requiresSecure());Or use server.ssl.enabled=true in application properties and configure a redirect from HTTP to HTTPS.
Use Bean Validation (jakarta.validation) on all request bodies and path parameters.
WRONG -- no validation on request body:
@PostMapping("/api/users")
public ResponseEntity<?> createUser(@RequestBody CreateUserRequest request) {
// No validation -- accepts empty names, negative ages, SQL in fields
userService.create(request);
}RIGHT -- Bean Validation on DTOs with @Valid:
public record CreateUserRequest(
@NotBlank @Size(min = 2, max = 50) String name,
@NotBlank @Email String email,
@NotBlank @Size(min = 8, max = 128) String password
) {}
@PostMapping("/api/users")
public ResponseEntity<?> createUser(@Valid @RequestBody CreateUserRequest request) {
return ResponseEntity.status(HttpStatus.CREATED).body(userService.create(request));
}Handle validation errors with a global exception handler:
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<Map<String, Object>> handleValidation(MethodArgumentNotValidException ex) {
Map<String, String> fieldErrors = new LinkedHashMap<>();
ex.getBindingResult().getFieldErrors()
.forEach(e -> fieldErrors.put(e.getField(), e.getDefaultMessage()));
return ResponseEntity.badRequest().body(Map.of(
"error", Map.of(
"code", "VALIDATION_ERROR",
"message", "Request validation failed",
"fields", fieldErrors
)
));
}
@ExceptionHandler(Exception.class)
public ResponseEntity<Map<String, Object>> handleGeneral(Exception ex) {
// Log full error internally, never leak stack traces
return ResponseEntity.internalServerError().body(Map.of(
"error", Map.of(
"code", "INTERNAL_ERROR",
"message", "An unexpected error occurred"
)
));
}
}For fine-grained access control, use @PreAuthorize on service or controller methods.
Enable method security:
@Configuration
@EnableMethodSecurity
public class MethodSecurityConfig {
}Use on controller or service methods:
@RestController
@RequestMapping("/api/admin")
public class AdminController {
@PreAuthorize("hasRole('ADMIN')")
@GetMapping("/users")
public List<UserResponse> listUsers() { ... }
@PreAuthorize("hasAuthority('USER_DELETE')")
@DeleteMapping("/users/{id}")
public ResponseEntity<Void> deleteUser(@PathVariable Long id) { ... }
}Naming convention: Use ROLE_ prefix for roles in the database/JWT (Spring strips it automatically). Use hasRole('ADMIN') which checks for ROLE_ADMIN authority. Use hasAuthority('USER_DELETE') for fine-grained permissions without the ROLE_ prefix.
Never leak stack traces, internal paths, or Spring-generated error details to clients.
WRONG -- default Spring Boot error response leaks details:
{
"timestamp": "2024-01-15T10:30:00",
"status": 500,
"error": "Internal Server Error",
"trace": "java.lang.NullPointerException\n\tat com.example...",
"path": "/api/users"
}Disable trace exposure in application.properties:
server.error.include-stacktrace=never
server.error.include-message=never
server.error.include-binding-errors=neverRIGHT -- global exception handler returns safe responses (see section 7 above).
This is the minimum security configuration for a stateless Spring Boot REST API:
@Configuration
@EnableWebSecurity
@EnableMethodSecurity
public class SecurityConfig {
@Value("${app.cors.allowed-origins:http://localhost:5173}")
private String allowedOrigins;
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.csrf(csrf -> csrf.disable()) // Stateless API with JWT
.cors(cors -> cors.configurationSource(corsConfigurationSource()))
.headers(headers -> headers
.frameOptions(frame -> frame.deny())
.contentTypeOptions(content -> {})
.httpStrictTransportSecurity(hsts -> hsts
.includeSubDomains(true)
.maxAgeInSeconds(31536000))
.contentSecurityPolicy(csp ->
csp.policyDirectives("default-src 'none'; frame-ancestors 'none'")))
.sessionManagement(session ->
session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/auth/**").permitAll()
.requestMatchers("/actuator/health").permitAll()
.anyRequest().authenticated());
return http.build();
}
@Bean
CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration config = new CorsConfiguration();
config.setAllowedOrigins(Arrays.asList(allowedOrigins.split(",")));
config.setAllowedMethods(List.of("GET", "POST", "PUT", "PATCH", "DELETE"));
config.setAllowedHeaders(List.of("Content-Type", "Authorization"));
config.setAllowCredentials(true);
config.setMaxAge(3600L);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/api/**", config);
return source;
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
}This is not a "production checklist." These belong in every Spring Boot app from the start:
SecurityFilterChain bean defined (NOT WebSecurityConfigurerAdapter)allowedOrigins("*"))CookieCsrfTokenRepository for session-based appsBCryptPasswordEncoder bean defined and used for all password storage@Valid) on all @RequestBody parametersserver.error.include-stacktrace=never in application.properties@PreAuthorize) for role-based access controlWhen using sessions:
CookieCsrfTokenRepositoryIf the task says "build a REST API" or "create CRUD endpoints" and does not mention security, you still add all of the above. Security configuration is not a feature request -- it is part of building a Spring Boot app correctly.
evals
scenario-1
scenario-2
scenario-3
scenario-4
scenario-5
skills
springboot-security-basics
verifiers