CtrlK
BlogDocsLog inGet started
Tessl Logo

tessl-labs/springboot-security-basics

Security defaults that belong in every Spring Boot application from day one.

88

1.79x
Quality

83%

Does it follow best practices?

Impact

97%

1.79x

Average score across 5 eval scenarios

SecuritybySnyk

Passed

No known issues

Overview
Quality
Evals
Security
Files
name:
springboot-security-basics
description:
Security defaults that belong in every Spring Boot application from day one. SecurityFilterChain, CORS, CSRF handling, BCrypt passwords, rate limiting, security headers, method-level security, and input validation. Apply these whenever you create or modify any Spring Boot app -- do not wait for a security review or explicit request.
keywords:
spring boot, spring security, SecurityFilterChain, cors, csrf, bcrypt, rate limiting, security headers, input validation, spring boot production, method security, PreAuthorize, password encoder, HSTS, CSP
license:
MIT

Spring Boot Security Basics

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.


The 7 Things Every Spring Boot App Needs

  1. SecurityFilterChain bean -- not the deprecated WebSecurityConfigurerAdapter
  2. CORS in SecurityFilterChain -- configured with explicit origins, not wide open
  3. CSRF handling -- disabled for stateless APIs, enabled for session-based apps
  4. BCryptPasswordEncoder -- for any password storage
  5. Rate limiting -- on all API endpoints, stricter on auth and mutation endpoints
  6. Security headers -- HSTS, CSP, X-Frame-Options, X-Content-Type-Options via HttpSecurity
  7. Input validation -- Bean Validation on all request bodies and parameters

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.


1. SecurityFilterChain Bean (Not WebSecurityConfigurerAdapter)

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.


2. CORS -- Configured in SecurityFilterChain, Not Wide Open

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;
}

3. CSRF Handling

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 style

Rule of thumb: If SessionCreationPolicy.STATELESS, disable CSRF. If sessions are used, keep CSRF enabled with CookieCsrfTokenRepository.


4. BCryptPasswordEncoder

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 breached

WRONG -- using weak hashing:

user.setPassword(DigestUtils.md5DigestAsHex(password.getBytes())); // MD5 is broken
user.setPassword(DigestUtils.sha256Hex(password)); // SHA-256 without salt is insufficient

RIGHT -- 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.


5. Rate Limiting

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.


6. Security Headers

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 headers

RIGHT -- 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'"))

HTTPS Redirect

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.


7. Input Validation

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"
            )
        ));
    }
}

8. Method-Level Security (@PreAuthorize)

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.


9. Secure Error Handling

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=never

RIGHT -- global exception handler returns safe responses (see section 7 above).


Complete SecurityFilterChain Template

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();
    }
}

Checklist -- Apply to Every Spring Boot App

This is not a "production checklist." These belong in every Spring Boot app from the start:

  • SecurityFilterChain bean defined (NOT WebSecurityConfigurerAdapter)
  • Lambda DSL used throughout (NOT deprecated chaining style)
  • CORS configured in SecurityFilterChain with explicit origins (never allowedOrigins("*"))
  • CSRF disabled for stateless APIs, enabled with CookieCsrfTokenRepository for session-based apps
  • BCryptPasswordEncoder bean defined and used for all password storage
  • Rate limiting filter on all API endpoints, stricter on auth endpoints
  • Security headers configured: X-Frame-Options DENY, HSTS, Content-Type-Options, CSP
  • Bean validation (@Valid) on all @RequestBody parameters
  • Global exception handler that never leaks stack traces
  • server.error.include-stacktrace=never in application.properties
  • Method-level security (@PreAuthorize) for role-based access control

When using sessions:

  • CSRF enabled with CookieCsrfTokenRepository
  • Session cookies have secure, httpOnly, sameSite flags set

If 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.

Verifiers

  • security-filter-chain -- SecurityFilterChain bean on every Spring Boot app
  • cors-configured -- CORS with explicit origins integrated in SecurityFilterChain
  • csrf-handling -- Correct CSRF handling based on session strategy
  • password-encoder -- BCryptPasswordEncoder for password storage
  • rate-limiting -- Rate limiting on API endpoints
  • security-headers -- Security headers via HttpSecurity
  • input-validation -- Bean Validation on request bodies
Workspace
tessl-labs
Visibility
Public
Created
Last updated
Publish Source
CLI
Badge
tessl-labs/springboot-security-basics badge