Spring Boot Security Checklist for Heroku

Spring Boot provides robust security features through Spring Security - configure them properly for production on Heroku.

Expedited WAF for Spring Boot

Expedited WAF adds network level request filtering, preventing malicious attacks from ever touching your application. This enables true defense in depth for your Spring Boot app—even if your framework has a vulnerability or is improperly configured, Expedited WAF dynamically blocks threats before they reach your code.

From experience, we’ve seen many applications where attacks technically didn’t succeed, but burned through so many server resources that the application went down anyway. By filtering malicious requests at the network edge, Expedited WAF protects both your security and your availability.

Expedited WAF protects your Spring Boot app on Heroku

New Security Features for Your Spring Boot App

These capabilities aren’t available out of the box with Spring Boot, but Expedited WAF adds them instantly:

Spring Security Configuration

  • Add Spring Security dependency

    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-security</artifactId>
    </dependency>
    
  • Configure HTTP Security

    @Configuration
    @EnableWebSecurity
    public class SecurityConfig {
    
        @Bean
        public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
            http
                .csrf().csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse())
                .and()
                .authorizeHttpRequests(auth -> auth
                    .requestMatchers("/", "/public/**").permitAll()
                    .anyRequest().authenticated()
                )
                .formLogin()
                .and()
                .logout().logoutSuccessUrl("/");
    
            return http.build();
        }
    }
    
  • Enable HTTPS enforcement

    @Configuration
    public class WebSecurityConfig {
        @Bean
        public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
            http.requiresChannel()
                .anyRequest()
                .requiresSecure();
            return http.build();
        }
    }
    
  • Configure security headers

    http
        .headers(headers -> headers
            .frameOptions().deny()
            .xssProtection().and()
            .contentSecurityPolicy("default-src 'self'")
        );
    

Authentication & Password Security

  • Use BCrypt for password encoding

    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder(12); // strength 12
    }
    
    // Encoding passwords
    String encoded = passwordEncoder.encode(rawPassword);
    
  • Implement UserDetailsService

    @Service
    public class CustomUserDetailsService implements UserDetailsService {
    
        @Autowired
        private UserRepository userRepository;
    
        @Override
        public UserDetails loadUserByUsername(String username)
                throws UsernameNotFoundException {
            User user = userRepository.findByUsername(username)
                .orElseThrow(() -> new UsernameNotFoundException("User not found"));
    
            return org.springframework.security.core.userdetails.User
                .withUsername(user.getUsername())
                .password(user.getPassword())
                .roles(user.getRoles().toArray(new String[0]))
                .build();
        }
    }
    
  • Implement account locking

    @Entity
    public class User {
        private boolean accountNonLocked = true;
        private int failedAttempts = 0;
        private Date lockTime;
    
        // Lock account after 5 failed attempts
        public void incrementFailedAttempts() {
            this.failedAttempts++;
            if (this.failedAttempts >= 5) {
                this.accountNonLocked = false;
                this.lockTime = new Date();
            }
        }
    }
    

JWT Authentication (for APIs)

  • Implement JWT authentication

    @Component
    public class JwtTokenProvider {
    
        @Value("${jwt.secret}")
        private String secret;
    
        @Value("${jwt.expiration}")
        private long expiration;
    
        public String generateToken(Authentication authentication) {
            UserDetails userDetails = (UserDetails) authentication.getPrincipal();
            Date now = new Date();
            Date expiryDate = new Date(now.getTime() + expiration);
    
            return Jwts.builder()
                .setSubject(userDetails.getUsername())
                .setIssuedAt(now)
                .setExpiration(expiryDate)
                .signWith(SignatureAlgorithm.HS512, secret)
                .compact();
        }
    
        public boolean validateToken(String token) {
            try {
                Jwts.parser().setSigningKey(secret).parseClaimsJws(token);
                return true;
            } catch (JwtException | IllegalArgumentException e) {
                return false;
            }
        }
    }
    
  • Create JWT authentication filter

    public class JwtAuthenticationFilter extends OncePerRequestFilter {
    
        @Autowired
        private JwtTokenProvider tokenProvider;
    
        @Override
        protected void doFilterInternal(HttpServletRequest request,
                                        HttpServletResponse response,
                                        FilterChain filterChain)
                throws ServletException, IOException {
    
            String jwt = getJwtFromRequest(request);
    
            if (StringUtils.hasText(jwt) && tokenProvider.validateToken(jwt)) {
                String username = tokenProvider.getUsernameFromToken(jwt);
                UserDetails userDetails = userDetailsService.loadUserByUsername(username);
                UsernamePasswordAuthenticationToken authentication =
                    new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities());
                SecurityContextHolder.getContext().setAuthentication(authentication);
            }
    
            filterChain.doFilter(request, response);
        }
    }
    

Input Validation

  • Use Bean Validation

    public class UserDTO {
        @NotBlank(message = "Username is required")
        @Size(min = 3, max = 50)
        private String username;
    
        @NotBlank(message = "Email is required")
        @Email(message = "Invalid email format")
        private String email;
    
        @NotBlank(message = "Password is required")
        @Size(min = 12, message = "Password must be at least 12 characters")
        private String password;
    }
    
    @PostMapping("/register")
    public ResponseEntity<?> register(@Valid @RequestBody UserDTO userDTO) {
        // Process validated data
    }
    
  • Sanitize input

    import org.springframework.web.util.HtmlUtils;
    
    String sanitized = HtmlUtils.htmlEscape(userInput);
    

SQL Injection Prevention

  • Use JPA/Hibernate parameterized queries

    // Good - parameterized
    @Query("SELECT u FROM User u WHERE u.email = :email")
    User findByEmail(@Param("email") String email);
    
    // Bad - SQL injection risk
    @Query(value = "SELECT * FROM users WHERE email = '" + email + "'", nativeQuery = true)
    
  • Use Criteria API for dynamic queries

    CriteriaBuilder cb = entityManager.getCriteriaBuilder();
    CriteriaQuery<User> query = cb.createQuery(User.class);
    Root<User> user = query.from(User.class);
    query.select(user).where(cb.equal(user.get("email"), email));
    

Database Security

  • Configure Heroku Postgres

    # application.properties
    spring.datasource.url=${DATABASE_URL}
    spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.PostgreSQLDialect
    spring.datasource.hikari.maximum-pool-size=5
    
  • Enable SSL for database

    spring.datasource.url=${DATABASE_URL}?sslmode=require
    
  • Don’t log SQL queries in production

    spring.jpa.show-sql=false
    logging.level.org.hibernate.SQL=WARN
    

CORS Configuration

  • Configure CORS properly
    @Configuration
    public class CorsConfig {
    
        @Bean
        public WebMvcConfigurer corsConfigurer() {
            return new WebMvcConfigurer() {
                @Override
                public void addCorsMappings(CorsRegistry registry) {
                    registry.addMapping("/api/**")
                        .allowedOrigins("https://yourdomain.com")
                        .allowedMethods("GET", "POST", "PUT", "DELETE")
                        .allowedHeaders("*")
                        .allowCredentials(true)
                        .maxAge(3600);
                }
            };
        }
    }
    

Environment & Secrets

  • Use application.properties for configuration

    # application.properties
    spring.datasource.url=${DATABASE_URL}
    jwt.secret=${JWT_SECRET}
    jwt.expiration=86400000
    
  • Store secrets in Heroku config

    heroku config:set DATABASE_URL='...'
    heroku config:set JWT_SECRET='...'
    
  • Use different profiles for environments

    # application-prod.properties
    spring.profiles.active=prod
    logging.level.root=WARN
    

Error Handling

  • Don’t expose stack traces

    @ControllerAdvice
    public class GlobalExceptionHandler {
    
        @ExceptionHandler(Exception.class)
        public ResponseEntity<?> handleGlobalException(Exception ex) {
            // Don't expose internal details
            return ResponseEntity
                .status(HttpStatus.INTERNAL_SERVER_ERROR)
                .body("An error occurred");
        }
    
        @ExceptionHandler(MethodArgumentNotValidException.class)
        public ResponseEntity<?> handleValidationExceptions(
                MethodArgumentNotValidException ex) {
            Map<String, String> errors = new HashMap<>();
            ex.getBindingResult().getAllErrors().forEach(error -> {
                String fieldName = ((FieldError) error).getField();
                String errorMessage = error.getDefaultMessage();
                errors.put(fieldName, errorMessage);
            });
            return ResponseEntity.badRequest().body(errors);
        }
    }
    
  • Configure error pages

    server.error.include-stacktrace=never
    server.error.include-message=never
    

Logging

  • Configure logging

    logging.level.root=INFO
    logging.level.org.springframework.security=INFO
    logging.file.name=/var/log/application.log
    
  • Don’t log sensitive data

    • Passwords, tokens, API keys
    • Personal information
    • Credit card data

Actuator Security

  • Secure Spring Boot Actuator endpoints

    management.endpoints.web.exposure.include=health,info
    management.endpoint.health.show-details=when-authorized
    
    http.authorizeHttpRequests(auth -> auth
        .requestMatchers("/actuator/**").hasRole("ADMIN")
    );
    

File Upload Security

  • Validate file uploads

    @PostMapping("/upload")
    public ResponseEntity<?> uploadFile(@RequestParam("file") MultipartFile file) {
        // Validate file type
        String contentType = file.getContentType();
        if (!Arrays.asList("image/jpeg", "image/png").contains(contentType)) {
            return ResponseEntity.badRequest().body("Invalid file type");
        }
    
        // Validate file size (5MB max)
        if (file.getSize() > 5 * 1024 * 1024) {
            return ResponseEntity.badRequest().body("File too large");
        }
    
        // Store to S3, not local filesystem
    }
    
  • Configure upload limits

    spring.servlet.multipart.max-file-size=5MB
    spring.servlet.multipart.max-request-size=5MB
    

Heroku Deployment

  • Create Procfile

    web: java -Dserver.port=$PORT $JAVA_OPTS -jar target/*.jar
    
  • Specify Java version

    # system.properties
    java.runtime.version=17
    
  • Configure Maven/Gradle build

    <plugin>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-maven-plugin</artifactId>
    </plugin>
    
  • Set production profile

    heroku config:set SPRING_PROFILES_ACTIVE=prod
    

Dependencies & Updates

  • Keep dependencies updated

    mvn versions:display-dependency-updates
    
  • Scan for vulnerabilities

    mvn dependency-check:check
    

Testing

  • Write security tests
    @SpringBootTest
    @AutoConfigureMockMvc
    public class SecurityTests {
    
        @Autowired
        private MockMvc mockMvc;
    
        @Test
        public void whenUnauthenticated_thenUnauthorized() throws Exception {
            mockMvc.perform(get("/api/users"))
                .andExpect(status().isUnauthorized());
        }
    
        @Test
        public void whenInvalidCSRF_thenForbidden() throws Exception {
            mockMvc.perform(post("/api/users"))
                .andExpect(status().isForbidden());
        }
    }
    

Additional Resources