Spring Boot 16 min read

Spring Security Part 6: Production Security Best Practices & Hardening

Deploy secure Spring Boot applications with production-grade security configurations, monitoring, rate limiting, and comprehensive security hardening techniques.

MR

Moshiour Rahman

Advertisement

This is Part 6 of our comprehensive Spring Security series. We’ll cover everything you need to deploy secure, production-ready Spring Boot applications.

Series Navigation


Password Security

BCrypt Configuration

Use strong password encoding with appropriate cost factor:

@Configuration
public class PasswordConfig {

    @Bean
    public PasswordEncoder passwordEncoder() {
        // Strength 12 = 2^12 iterations (~250ms on modern hardware)
        return new BCryptPasswordEncoder(12);
    }
}

Argon2 for Maximum Security

For applications requiring the highest security:

@Bean
public PasswordEncoder passwordEncoder() {
    return new Argon2PasswordEncoder(
        16,    // Salt length
        32,    // Hash length
        1,     // Parallelism
        65536, // Memory (64MB)
        3      // Iterations
    );
}

Password Validation Rules

@Component
public class PasswordValidator {

    private static final int MIN_LENGTH = 12;
    private static final Pattern UPPERCASE = Pattern.compile("[A-Z]");
    private static final Pattern LOWERCASE = Pattern.compile("[a-z]");
    private static final Pattern DIGIT = Pattern.compile("[0-9]");
    private static final Pattern SPECIAL = Pattern.compile("[!@#$%^&*(),.?\":{}|<>]");

    public List<String> validate(String password) {
        List<String> errors = new ArrayList<>();

        if (password == null || password.length() < MIN_LENGTH) {
            errors.add("Password must be at least " + MIN_LENGTH + " characters");
        }
        if (!UPPERCASE.matcher(password).find()) {
            errors.add("Password must contain uppercase letter");
        }
        if (!LOWERCASE.matcher(password).find()) {
            errors.add("Password must contain lowercase letter");
        }
        if (!DIGIT.matcher(password).find()) {
            errors.add("Password must contain a digit");
        }
        if (!SPECIAL.matcher(password).find()) {
            errors.add("Password must contain special character");
        }

        // Check against common passwords
        if (CommonPasswordChecker.isCommon(password)) {
            errors.add("Password is too common");
        }

        return errors;
    }
}

Password History Prevention

@Entity
@Table(name = "password_history")
public class PasswordHistory {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "user_id", nullable = false)
    private User user;

    @Column(nullable = false)
    private String passwordHash;

    @Column(nullable = false)
    private Instant createdAt;
}

@Service
public class PasswordHistoryService {

    private static final int HISTORY_SIZE = 5;

    @Autowired
    private PasswordHistoryRepository historyRepository;

    @Autowired
    private PasswordEncoder passwordEncoder;

    public boolean isPasswordReused(User user, String newPassword) {
        List<PasswordHistory> history = historyRepository
            .findTopNByUserOrderByCreatedAtDesc(user, HISTORY_SIZE);

        return history.stream()
            .anyMatch(h -> passwordEncoder.matches(newPassword, h.getPasswordHash()));
    }

    @Transactional
    public void recordPassword(User user, String passwordHash) {
        PasswordHistory history = new PasswordHistory();
        history.setUser(user);
        history.setPasswordHash(passwordHash);
        history.setCreatedAt(Instant.now());
        historyRepository.save(history);

        // Clean old entries
        historyRepository.deleteOldEntries(user, HISTORY_SIZE);
    }
}

Rate Limiting

Bucket4j Rate Limiter

<dependency>
    <groupId>com.bucket4j</groupId>
    <artifactId>bucket4j-core</artifactId>
    <version>8.7.0</version>
</dependency>
@Component
public class RateLimitFilter extends OncePerRequestFilter {

    private final Map<String, Bucket> buckets = new ConcurrentHashMap<>();

    @Override
    protected void doFilterInternal(HttpServletRequest request,
                                    HttpServletResponse response,
                                    FilterChain filterChain)
            throws ServletException, IOException {

        String clientId = getClientIdentifier(request);
        Bucket bucket = buckets.computeIfAbsent(clientId, this::createBucket);

        ConsumptionProbe probe = bucket.tryConsumeAndReturnRemaining(1);

        if (probe.isConsumed()) {
            response.setHeader("X-Rate-Limit-Remaining",
                String.valueOf(probe.getRemainingTokens()));
            filterChain.doFilter(request, response);
        } else {
            response.setStatus(HttpStatus.TOO_MANY_REQUESTS.value());
            response.setHeader("X-Rate-Limit-Retry-After-Seconds",
                String.valueOf(probe.getNanosToWaitForRefill() / 1_000_000_000));
            response.getWriter().write("Rate limit exceeded");
        }
    }

    private Bucket createBucket(String clientId) {
        // 100 requests per minute
        Bandwidth limit = Bandwidth.classic(100, Refill.greedy(100, Duration.ofMinutes(1)));
        return Bucket.builder().addLimit(limit).build();
    }

    private String getClientIdentifier(HttpServletRequest request) {
        // Use authenticated user or IP address
        Authentication auth = SecurityContextHolder.getContext().getAuthentication();
        if (auth != null && auth.isAuthenticated() &&
            !(auth instanceof AnonymousAuthenticationToken)) {
            return "user:" + auth.getName();
        }
        return "ip:" + getClientIP(request);
    }

    private String getClientIP(HttpServletRequest request) {
        String xForwardedFor = request.getHeader("X-Forwarded-For");
        if (xForwardedFor != null && !xForwardedFor.isEmpty()) {
            return xForwardedFor.split(",")[0].trim();
        }
        return request.getRemoteAddr();
    }
}

Endpoint-Specific Rate Limits

@Component
public class EndpointRateLimiter {

    private final Map<String, Map<String, Bucket>> endpointBuckets = new ConcurrentHashMap<>();

    private final Map<String, BucketConfiguration> endpointConfigs = Map.of(
        "/api/auth/login", createConfig(5, Duration.ofMinutes(1)),      // 5 per minute
        "/api/auth/register", createConfig(3, Duration.ofHours(1)),    // 3 per hour
        "/api/auth/forgot-password", createConfig(3, Duration.ofHours(1)),
        "/api/auth/reset-password", createConfig(5, Duration.ofHours(1)),
        "default", createConfig(100, Duration.ofMinutes(1))            // Default
    );

    private BucketConfiguration createConfig(int capacity, Duration period) {
        return BucketConfiguration.builder()
            .addLimit(Bandwidth.classic(capacity, Refill.intervally(capacity, period)))
            .build();
    }

    public boolean tryConsume(String endpoint, String clientId) {
        String normalizedEndpoint = normalizeEndpoint(endpoint);
        BucketConfiguration config = endpointConfigs.getOrDefault(
            normalizedEndpoint, endpointConfigs.get("default"));

        Map<String, Bucket> clientBuckets = endpointBuckets
            .computeIfAbsent(normalizedEndpoint, k -> new ConcurrentHashMap<>());

        Bucket bucket = clientBuckets.computeIfAbsent(clientId,
            k -> Bucket.builder().addLimit(config.getBandwidths().get(0)).build());

        return bucket.tryConsume(1);
    }

    private String normalizeEndpoint(String path) {
        // Remove path variables
        return path.replaceAll("/\\d+", "/{id}");
    }
}

Login-Specific Rate Limiting

@Service
public class LoginAttemptService {

    private static final int MAX_ATTEMPTS = 5;
    private static final Duration LOCK_DURATION = Duration.ofMinutes(15);

    private final LoadingCache<String, Integer> attemptsCache;

    public LoginAttemptService() {
        attemptsCache = CacheBuilder.newBuilder()
            .expireAfterWrite(LOCK_DURATION.toMinutes(), TimeUnit.MINUTES)
            .build(new CacheLoader<>() {
                @Override
                public Integer load(String key) {
                    return 0;
                }
            });
    }

    public void loginSucceeded(String key) {
        attemptsCache.invalidate(key);
    }

    public void loginFailed(String key) {
        int attempts = attemptsCache.getUnchecked(key);
        attemptsCache.put(key, attempts + 1);
    }

    public boolean isBlocked(String key) {
        return attemptsCache.getUnchecked(key) >= MAX_ATTEMPTS;
    }

    public int getRemainingAttempts(String key) {
        return Math.max(0, MAX_ATTEMPTS - attemptsCache.getUnchecked(key));
    }
}

Session Management

Secure Session Configuration

@Configuration
@EnableWebSecurity
public class SessionSecurityConfig {

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http
            .sessionManagement(session -> session
                .sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED)
                .maximumSessions(1)                           // One session per user
                .maxSessionsPreventsLogin(false)              // New login kicks old session
                .expiredUrl("/login?expired")
                .sessionRegistry(sessionRegistry())
            )
            .sessionManagement(session -> session
                .sessionFixation().changeSessionId()          // Prevent session fixation
                .invalidSessionUrl("/login?invalid")
            );

        return http.build();
    }

    @Bean
    public SessionRegistry sessionRegistry() {
        return new SessionRegistryImpl();
    }

    @Bean
    public HttpSessionEventPublisher httpSessionEventPublisher() {
        return new HttpSessionEventPublisher();
    }
}

Session Properties

# application.yml
server:
  servlet:
    session:
      timeout: 30m                    # Session timeout
      cookie:
        name: SESSIONID
        http-only: true               # Prevent XSS access
        secure: true                  # HTTPS only
        same-site: strict             # CSRF protection
        max-age: 1800                 # Cookie lifetime in seconds
        path: /

Redis Session Store for Distributed Systems

<dependency>
    <groupId>org.springframework.session</groupId>
    <artifactId>spring-session-data-redis</artifactId>
</dependency>
@Configuration
@EnableRedisHttpSession(maxInactiveIntervalInSeconds = 1800)
public class RedisSessionConfig {

    @Bean
    public RedisConnectionFactory redisConnectionFactory() {
        RedisStandaloneConfiguration config = new RedisStandaloneConfiguration();
        config.setHostName("localhost");
        config.setPort(6379);
        config.setPassword("your-redis-password");
        return new LettuceConnectionFactory(config);
    }

    @Bean
    public RedisSerializer<Object> springSessionDefaultRedisSerializer() {
        return new GenericJackson2JsonRedisSerializer();
    }
}

Active Session Management

@RestController
@RequestMapping("/api/sessions")
@PreAuthorize("isAuthenticated()")
public class SessionController {

    @Autowired
    private SessionRegistry sessionRegistry;

    @GetMapping("/active")
    public List<SessionInfo> getActiveSessions(Authentication auth) {
        return sessionRegistry.getAllSessions(auth.getPrincipal(), false)
            .stream()
            .map(this::toSessionInfo)
            .collect(Collectors.toList());
    }

    @DeleteMapping("/{sessionId}")
    public ResponseEntity<?> terminateSession(@PathVariable String sessionId,
                                               Authentication auth) {
        SessionInformation session = sessionRegistry.getSessionInformation(sessionId);

        if (session == null) {
            return ResponseEntity.notFound().build();
        }

        // Verify session belongs to current user
        if (!session.getPrincipal().equals(auth.getPrincipal())) {
            return ResponseEntity.status(HttpStatus.FORBIDDEN).build();
        }

        session.expireNow();
        return ResponseEntity.ok().build();
    }

    @DeleteMapping("/all-except-current")
    public ResponseEntity<?> terminateOtherSessions(HttpServletRequest request,
                                                     Authentication auth) {
        String currentSessionId = request.getSession().getId();

        sessionRegistry.getAllSessions(auth.getPrincipal(), false)
            .stream()
            .filter(s -> !s.getSessionId().equals(currentSessionId))
            .forEach(SessionInformation::expireNow);

        return ResponseEntity.ok().build();
    }

    private SessionInfo toSessionInfo(SessionInformation session) {
        return new SessionInfo(
            session.getSessionId(),
            session.getLastRequest(),
            session.isExpired()
        );
    }
}

Security Logging & Monitoring

Comprehensive Security Event Logging

@Component
public class SecurityEventLogger implements
        AuthenticationSuccessHandler,
        AuthenticationFailureHandler,
        AccessDeniedHandler,
        LogoutSuccessHandler {

    private static final Logger securityLog = LoggerFactory.getLogger("SECURITY");

    @Override
    public void onAuthenticationSuccess(HttpServletRequest request,
                                        HttpServletResponse response,
                                        Authentication authentication) {
        SecurityEvent event = SecurityEvent.builder()
            .type("AUTH_SUCCESS")
            .username(authentication.getName())
            .ipAddress(getClientIP(request))
            .userAgent(request.getHeader("User-Agent"))
            .sessionId(request.getSession().getId())
            .timestamp(Instant.now())
            .build();

        logSecurityEvent(event);
    }

    @Override
    public void onAuthenticationFailure(HttpServletRequest request,
                                        HttpServletResponse response,
                                        AuthenticationException exception) {
        SecurityEvent event = SecurityEvent.builder()
            .type("AUTH_FAILURE")
            .username(request.getParameter("username"))
            .ipAddress(getClientIP(request))
            .userAgent(request.getHeader("User-Agent"))
            .reason(exception.getMessage())
            .timestamp(Instant.now())
            .build();

        logSecurityEvent(event);
    }

    @Override
    public void handle(HttpServletRequest request,
                       HttpServletResponse response,
                       AccessDeniedException exception) throws IOException {
        Authentication auth = SecurityContextHolder.getContext().getAuthentication();

        SecurityEvent event = SecurityEvent.builder()
            .type("ACCESS_DENIED")
            .username(auth != null ? auth.getName() : "anonymous")
            .ipAddress(getClientIP(request))
            .resource(request.getRequestURI())
            .method(request.getMethod())
            .reason(exception.getMessage())
            .timestamp(Instant.now())
            .build();

        logSecurityEvent(event);

        response.sendError(HttpStatus.FORBIDDEN.value(), "Access Denied");
    }

    @Override
    public void onLogoutSuccess(HttpServletRequest request,
                                HttpServletResponse response,
                                Authentication authentication) {
        if (authentication != null) {
            SecurityEvent event = SecurityEvent.builder()
                .type("LOGOUT")
                .username(authentication.getName())
                .ipAddress(getClientIP(request))
                .timestamp(Instant.now())
                .build();

            logSecurityEvent(event);
        }
    }

    private void logSecurityEvent(SecurityEvent event) {
        // Structured JSON logging for SIEM integration
        securityLog.info(event.toJson());

        // Also persist to database for audit trail
        securityEventRepository.save(event);
    }

    private String getClientIP(HttpServletRequest request) {
        String xForwardedFor = request.getHeader("X-Forwarded-For");
        if (xForwardedFor != null && !xForwardedFor.isEmpty()) {
            return xForwardedFor.split(",")[0].trim();
        }
        return request.getRemoteAddr();
    }
}

Security Metrics with Micrometer

@Component
public class SecurityMetrics {

    private final Counter authSuccessCounter;
    private final Counter authFailureCounter;
    private final Counter accessDeniedCounter;
    private final Timer authenticationTimer;

    public SecurityMetrics(MeterRegistry registry) {
        this.authSuccessCounter = Counter.builder("security.auth.success")
            .description("Successful authentications")
            .register(registry);

        this.authFailureCounter = Counter.builder("security.auth.failure")
            .description("Failed authentications")
            .register(registry);

        this.accessDeniedCounter = Counter.builder("security.access.denied")
            .description("Access denied events")
            .register(registry);

        this.authenticationTimer = Timer.builder("security.auth.time")
            .description("Authentication processing time")
            .register(registry);
    }

    public void recordAuthSuccess() {
        authSuccessCounter.increment();
    }

    public void recordAuthFailure(String reason) {
        authFailureCounter.increment();
        Counter.builder("security.auth.failure.reason")
            .tag("reason", reason)
            .register(Metrics.globalRegistry)
            .increment();
    }

    public void recordAccessDenied(String resource) {
        accessDeniedCounter.increment();
    }

    public Timer.Sample startAuthTimer() {
        return Timer.start();
    }

    public void stopAuthTimer(Timer.Sample sample) {
        sample.stop(authenticationTimer);
    }
}

Audit Trail Entity

@Entity
@Table(name = "security_audit_log", indexes = {
    @Index(name = "idx_audit_username", columnList = "username"),
    @Index(name = "idx_audit_timestamp", columnList = "timestamp"),
    @Index(name = "idx_audit_type", columnList = "eventType")
})
public class SecurityAuditLog {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(nullable = false, length = 50)
    @Enumerated(EnumType.STRING)
    private SecurityEventType eventType;

    @Column(length = 255)
    private String username;

    @Column(length = 45)
    private String ipAddress;

    @Column(length = 500)
    private String userAgent;

    @Column(length = 255)
    private String resource;

    @Column(length = 10)
    private String httpMethod;

    @Column(length = 1000)
    private String details;

    @Column(nullable = false)
    private Instant timestamp;

    @Column(length = 36)
    private String sessionId;

    @Column(length = 36)
    private String correlationId;
}

public enum SecurityEventType {
    AUTH_SUCCESS,
    AUTH_FAILURE,
    LOGOUT,
    ACCESS_DENIED,
    PASSWORD_CHANGE,
    PASSWORD_RESET_REQUEST,
    ACCOUNT_LOCKED,
    ACCOUNT_UNLOCKED,
    SESSION_CREATED,
    SESSION_EXPIRED,
    SUSPICIOUS_ACTIVITY
}

HTTPS Enforcement

Production Configuration

# application-prod.yml
server:
  port: 8443
  ssl:
    enabled: true
    key-store: classpath:keystore.p12
    key-store-type: PKCS12
    key-store-password: ${SSL_KEYSTORE_PASSWORD}
    key-alias: tomcat
    protocol: TLS
    enabled-protocols: TLSv1.3,TLSv1.2
    ciphers:
      - TLS_AES_256_GCM_SHA384
      - TLS_AES_128_GCM_SHA256
      - TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384
      - TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384

HTTPS Redirect Configuration

@Configuration
public class HttpsConfig {

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http
            .requiresChannel(channel -> channel
                .anyRequest().requiresSecure()   // Force HTTPS
            )
            .headers(headers -> headers
                .httpStrictTransportSecurity(hsts -> hsts
                    .includeSubDomains(true)
                    .maxAgeInSeconds(31536000)   // 1 year
                    .preload(true)
                )
            );

        return http.build();
    }

    // HTTP to HTTPS redirect (for port 80)
    @Bean
    public ServletWebServerFactory servletContainer() {
        TomcatServletWebServerFactory tomcat = new TomcatServletWebServerFactory() {
            @Override
            protected void postProcessContext(Context context) {
                SecurityConstraint securityConstraint = new SecurityConstraint();
                securityConstraint.setUserConstraint("CONFIDENTIAL");
                SecurityCollection collection = new SecurityCollection();
                collection.addPattern("/*");
                securityConstraint.addCollection(collection);
                context.addConstraint(securityConstraint);
            }
        };
        tomcat.addAdditionalTomcatConnectors(redirectConnector());
        return tomcat;
    }

    private Connector redirectConnector() {
        Connector connector = new Connector(TomcatServletWebServerFactory.DEFAULT_PROTOCOL);
        connector.setScheme("http");
        connector.setPort(8080);
        connector.setSecure(false);
        connector.setRedirectPort(8443);
        return connector;
    }
}

Security Headers Hardening

Complete Security Headers Configuration

@Configuration
@EnableWebSecurity
public class SecurityHeadersConfig {

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http.headers(headers -> headers
            // Strict Transport Security
            .httpStrictTransportSecurity(hsts -> hsts
                .includeSubDomains(true)
                .maxAgeInSeconds(31536000)
                .preload(true)
            )

            // Content Security Policy
            .contentSecurityPolicy(csp -> csp
                .policyDirectives(
                    "default-src 'self'; " +
                    "script-src 'self' 'unsafe-inline' https://cdn.trusted.com; " +
                    "style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; " +
                    "img-src 'self' data: https:; " +
                    "font-src 'self' https://fonts.gstatic.com; " +
                    "connect-src 'self' https://api.yourservice.com; " +
                    "frame-ancestors 'none'; " +
                    "form-action 'self'; " +
                    "base-uri 'self'; " +
                    "object-src 'none'; " +
                    "upgrade-insecure-requests"
                )
            )

            // Permissions Policy (formerly Feature Policy)
            .permissionsPolicy(permissions -> permissions
                .policy("camera=(), microphone=(), geolocation=(self), payment=()")
            )

            // Frame Options
            .frameOptions(frame -> frame.deny())

            // XSS Protection (legacy browsers)
            .xssProtection(xss -> xss
                .headerValue(XXssProtectionHeaderWriter.HeaderValue.ENABLED_MODE_BLOCK)
            )

            // Content Type Options
            .contentTypeOptions(Customizer.withDefaults())

            // Referrer Policy
            .referrerPolicy(referrer -> referrer
                .policy(ReferrerPolicyHeaderWriter.ReferrerPolicy.STRICT_ORIGIN_WHEN_CROSS_ORIGIN)
            )

            // Cache Control for sensitive pages
            .cacheControl(Customizer.withDefaults())
        );

        return http.build();
    }
}

Custom Security Headers Filter

@Component
@Order(Ordered.HIGHEST_PRECEDENCE)
public class AdditionalSecurityHeadersFilter extends OncePerRequestFilter {

    @Override
    protected void doFilterInternal(HttpServletRequest request,
                                    HttpServletResponse response,
                                    FilterChain filterChain)
            throws ServletException, IOException {

        // Remove server identification
        response.setHeader("Server", "");

        // Cross-Origin headers
        response.setHeader("Cross-Origin-Embedder-Policy", "require-corp");
        response.setHeader("Cross-Origin-Opener-Policy", "same-origin");
        response.setHeader("Cross-Origin-Resource-Policy", "same-origin");

        // Prevent DNS prefetching
        response.setHeader("X-DNS-Prefetch-Control", "off");

        // Download options for IE
        response.setHeader("X-Download-Options", "noopen");

        // Permitted cross-domain policies
        response.setHeader("X-Permitted-Cross-Domain-Policies", "none");

        filterChain.doFilter(request, response);
    }
}

Input Validation & Sanitization

Request Validation

@RestController
@RequestMapping("/api/users")
@Validated
public class UserController {

    @PostMapping
    public ResponseEntity<User> createUser(@Valid @RequestBody CreateUserRequest request) {
        // Request is automatically validated
        return ResponseEntity.ok(userService.create(request));
    }
}

public class CreateUserRequest {

    @NotBlank(message = "Username is required")
    @Size(min = 3, max = 50, message = "Username must be 3-50 characters")
    @Pattern(regexp = "^[a-zA-Z0-9_]+$", message = "Username can only contain letters, numbers, and underscores")
    private String username;

    @NotBlank(message = "Email is required")
    @Email(message = "Invalid email format")
    @Size(max = 255)
    private String email;

    @NotBlank(message = "Password is required")
    @Size(min = 12, max = 128, message = "Password must be 12-128 characters")
    private String password;

    @Size(max = 100, message = "Name cannot exceed 100 characters")
    @SafeHtml // Requires org.owasp.encoder
    private String displayName;
}

HTML Sanitization

@Component
public class HtmlSanitizer {

    private final PolicyFactory policy;

    public HtmlSanitizer() {
        this.policy = new HtmlPolicyBuilder()
            .allowElements("p", "br", "b", "i", "u", "strong", "em", "a", "ul", "ol", "li")
            .allowAttributes("href").onElements("a")
            .allowUrlProtocols("https")
            .requireRelNofollowOnLinks()
            .toFactory();
    }

    public String sanitize(String untrusted) {
        if (untrusted == null) {
            return null;
        }
        return policy.sanitize(untrusted);
    }
}

SQL Injection Prevention

Always use parameterized queries:

@Repository
public class UserRepository {

    @Autowired
    private JdbcTemplate jdbcTemplate;

    // CORRECT - Parameterized query
    public User findByUsername(String username) {
        return jdbcTemplate.queryForObject(
            "SELECT * FROM users WHERE username = ?",
            new Object[]{username},
            userRowMapper
        );
    }

    // CORRECT - Named parameters
    public List<User> findByRole(String role) {
        String sql = "SELECT * FROM users WHERE role = :role";
        MapSqlParameterSource params = new MapSqlParameterSource()
            .addValue("role", role);
        return namedJdbcTemplate.query(sql, params, userRowMapper);
    }

    // NEVER DO THIS - SQL Injection vulnerable
    // public User findByUsernameBad(String username) {
    //     return jdbcTemplate.queryForObject(
    //         "SELECT * FROM users WHERE username = '" + username + "'",
    //         userRowMapper
    //     );
    // }
}

Secrets Management

Environment-Based Configuration

# application.yml - Default (dev)
spring:
  datasource:
    url: jdbc:h2:mem:testdb

# application-prod.yml - Production
spring:
  datasource:
    url: ${DATABASE_URL}
    username: ${DATABASE_USERNAME}
    password: ${DATABASE_PASSWORD}

jwt:
  secret: ${JWT_SECRET}
  expiration: ${JWT_EXPIRATION:86400000}

oauth2:
  google:
    client-id: ${GOOGLE_CLIENT_ID}
    client-secret: ${GOOGLE_CLIENT_SECRET}

Vault Integration

<dependency>
    <groupId>org.springframework.vault</groupId>
    <artifactId>spring-vault-core</artifactId>
</dependency>
@Configuration
public class VaultConfig extends AbstractVaultConfiguration {

    @Override
    public VaultEndpoint vaultEndpoint() {
        VaultEndpoint endpoint = new VaultEndpoint();
        endpoint.setHost("vault.example.com");
        endpoint.setPort(8200);
        endpoint.setScheme("https");
        return endpoint;
    }

    @Override
    public ClientAuthentication clientAuthentication() {
        return new TokenAuthentication(System.getenv("VAULT_TOKEN"));
    }
}

@Service
public class SecretService {

    @Autowired
    private VaultTemplate vaultTemplate;

    public String getSecret(String path, String key) {
        VaultResponseSupport<Map<String, Object>> response =
            vaultTemplate.read("secret/data/" + path);

        if (response != null && response.getData() != null) {
            Map<String, Object> data = (Map<String, Object>) response.getData().get("data");
            return (String) data.get(key);
        }
        throw new SecretNotFoundException("Secret not found: " + path + "/" + key);
    }
}

Encrypted Properties with Jasypt

<dependency>
    <groupId>com.github.ulisesbocchio</groupId>
    <artifactId>jasypt-spring-boot-starter</artifactId>
    <version>3.0.5</version>
</dependency>
# application.yml
jasypt:
  encryptor:
    password: ${JASYPT_PASSWORD}
    algorithm: PBEWithMD5AndDES

spring:
  datasource:
    password: ENC(encrypted_password_here)

Security Testing

Integration Test Configuration

@SpringBootTest
@AutoConfigureMockMvc
public class SecurityIntegrationTest {

    @Autowired
    private MockMvc mockMvc;

    @Test
    void publicEndpointAccessible() throws Exception {
        mockMvc.perform(get("/api/public/health"))
            .andExpect(status().isOk());
    }

    @Test
    void protectedEndpointRequiresAuth() throws Exception {
        mockMvc.perform(get("/api/users/me"))
            .andExpect(status().isUnauthorized());
    }

    @Test
    @WithMockUser(username = "user", roles = "USER")
    void authenticatedUserCanAccessProtectedEndpoint() throws Exception {
        mockMvc.perform(get("/api/users/me"))
            .andExpect(status().isOk());
    }

    @Test
    @WithMockUser(username = "user", roles = "USER")
    void userCannotAccessAdminEndpoint() throws Exception {
        mockMvc.perform(get("/api/admin/users"))
            .andExpect(status().isForbidden());
    }

    @Test
    @WithMockUser(username = "admin", roles = "ADMIN")
    void adminCanAccessAdminEndpoint() throws Exception {
        mockMvc.perform(get("/api/admin/users"))
            .andExpect(status().isOk());
    }

    @Test
    void securityHeadersPresent() throws Exception {
        mockMvc.perform(get("/api/public/health"))
            .andExpect(header().exists("X-Content-Type-Options"))
            .andExpect(header().exists("X-Frame-Options"))
            .andExpect(header().exists("X-XSS-Protection"))
            .andExpect(header().exists("Strict-Transport-Security"))
            .andExpect(header().string("X-Content-Type-Options", "nosniff"))
            .andExpect(header().string("X-Frame-Options", "DENY"));
    }
}

Custom Security Test Annotations

@Retention(RetentionPolicy.RUNTIME)
@WithMockUser(username = "admin", roles = {"USER", "ADMIN"})
public @interface WithMockAdmin {}

@Retention(RetentionPolicy.RUNTIME)
@WithMockUser(username = "user", roles = "USER")
public @interface WithMockRegularUser {}

// Usage
@Test
@WithMockAdmin
void adminOperationTest() throws Exception {
    // Test admin functionality
}

Production Deployment Checklist

Pre-Deployment Security Checklist

## Authentication & Authorization
- [ ] Strong password policy enforced (12+ chars, complexity)
- [ ] BCrypt/Argon2 with appropriate cost factor
- [ ] Account lockout after failed attempts
- [ ] Password history prevention
- [ ] MFA enabled for sensitive accounts
- [ ] Session timeout configured (30 min default)
- [ ] Concurrent session control

## Transport Security
- [ ] HTTPS enforced everywhere
- [ ] TLS 1.2+ only
- [ ] Strong cipher suites
- [ ] HSTS enabled with preload
- [ ] Certificate valid and auto-renewing

## Security Headers
- [ ] Content-Security-Policy configured
- [ ] X-Frame-Options: DENY
- [ ] X-Content-Type-Options: nosniff
- [ ] Referrer-Policy configured
- [ ] Permissions-Policy configured

## Input Validation
- [ ] All inputs validated server-side
- [ ] Parameterized queries only
- [ ] HTML sanitization for user content
- [ ] File upload restrictions

## API Security
- [ ] Rate limiting configured
- [ ] CORS properly restricted
- [ ] CSRF protection (for cookies)
- [ ] JWT expiration < 1 hour
- [ ] Refresh token rotation

## Secrets Management
- [ ] No secrets in code/config
- [ ] Environment variables or vault
- [ ] Secrets rotated regularly
- [ ] API keys have minimal permissions

## Logging & Monitoring
- [ ] Security events logged
- [ ] Failed auth attempts tracked
- [ ] Access denied events logged
- [ ] Log integrity protected
- [ ] Alerts configured

## Dependency Security
- [ ] Dependencies up to date
- [ ] No known vulnerabilities
- [ ] Automated scanning enabled
- [ ] Supply chain verified

Security Configuration Validation

@Component
public class SecurityConfigurationValidator implements ApplicationRunner {

    private static final Logger log = LoggerFactory.getLogger(SecurityConfigurationValidator.class);

    @Value("${server.ssl.enabled:false}")
    private boolean sslEnabled;

    @Value("${spring.profiles.active:default}")
    private String activeProfile;

    @Autowired
    private PasswordEncoder passwordEncoder;

    @Override
    public void run(ApplicationArguments args) {
        List<String> warnings = new ArrayList<>();
        List<String> errors = new ArrayList<>();

        // Check SSL in production
        if ("prod".equals(activeProfile) && !sslEnabled) {
            errors.add("SSL must be enabled in production");
        }

        // Check password encoder strength
        if (passwordEncoder instanceof BCryptPasswordEncoder) {
            log.info("Using BCrypt password encoder");
        } else if (passwordEncoder instanceof NoOpPasswordEncoder) {
            errors.add("NoOpPasswordEncoder detected - NEVER use in production");
        }

        // Log warnings
        warnings.forEach(w -> log.warn("SECURITY WARNING: {}", w));

        // Fail startup on critical errors in production
        if (!errors.isEmpty() && "prod".equals(activeProfile)) {
            errors.forEach(e -> log.error("SECURITY ERROR: {}", e));
            throw new SecurityConfigurationException(
                "Critical security configuration errors detected");
        }
    }
}

Complete Production Security Configuration

@Configuration
@EnableWebSecurity
@EnableMethodSecurity(prePostEnabled = true)
@Profile("prod")
public class ProductionSecurityConfig {

    @Autowired
    private SecurityEventLogger securityEventLogger;

    @Autowired
    private RateLimitFilter rateLimitFilter;

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http
            // Require HTTPS
            .requiresChannel(channel -> channel
                .anyRequest().requiresSecure()
            )

            // Session Management
            .sessionManagement(session -> session
                .sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED)
                .maximumSessions(1)
                .maxSessionsPreventsLogin(false)
                .sessionRegistry(sessionRegistry())
            )
            .sessionManagement(session -> session
                .sessionFixation().changeSessionId()
            )

            // CSRF
            .csrf(csrf -> csrf
                .csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse())
                .ignoringRequestMatchers("/api/webhooks/**")
            )

            // Authorization
            .authorizeHttpRequests(auth -> auth
                .requestMatchers("/api/public/**", "/health", "/actuator/health").permitAll()
                .requestMatchers("/api/admin/**").hasRole("ADMIN")
                .anyRequest().authenticated()
            )

            // Form Login
            .formLogin(form -> form
                .loginPage("/login")
                .successHandler(securityEventLogger)
                .failureHandler(securityEventLogger)
                .permitAll()
            )

            // Logout
            .logout(logout -> logout
                .logoutUrl("/logout")
                .logoutSuccessHandler(securityEventLogger)
                .invalidateHttpSession(true)
                .deleteCookies("JSESSIONID", "remember-me")
            )

            // Exception Handling
            .exceptionHandling(ex -> ex
                .accessDeniedHandler(securityEventLogger)
            )

            // Security Headers
            .headers(this::configureHeaders)

            // Add rate limiting
            .addFilterBefore(rateLimitFilter, UsernamePasswordAuthenticationFilter.class);

        return http.build();
    }

    private void configureHeaders(HeadersConfigurer<HttpSecurity> headers) {
        headers
            .httpStrictTransportSecurity(hsts -> hsts
                .includeSubDomains(true)
                .maxAgeInSeconds(31536000)
                .preload(true)
            )
            .contentSecurityPolicy(csp -> csp
                .policyDirectives("default-src 'self'; frame-ancestors 'none';")
            )
            .frameOptions(frame -> frame.deny())
            .contentTypeOptions(Customizer.withDefaults())
            .referrerPolicy(referrer -> referrer
                .policy(ReferrerPolicyHeaderWriter.ReferrerPolicy.STRICT_ORIGIN_WHEN_CROSS_ORIGIN)
            );
    }

    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder(12);
    }

    @Bean
    public SessionRegistry sessionRegistry() {
        return new SessionRegistryImpl();
    }

    @Bean
    public HttpSessionEventPublisher httpSessionEventPublisher() {
        return new HttpSessionEventPublisher();
    }
}

Key Takeaways

  1. Defense in Depth - Layer multiple security controls
  2. Secure by Default - Start restrictive, open as needed
  3. Least Privilege - Grant minimum required permissions
  4. Fail Securely - Errors should not expose information
  5. Monitor Everything - Log security events, set alerts
  6. Keep Updated - Patch dependencies regularly
  7. Test Security - Include security in your test suite

Series Complete

You’ve completed the Spring Security comprehensive guide:

  1. Architecture & Fundamentals
  2. Database Authentication
  3. OAuth2 & Social Login
  4. Method-Level Security
  5. CSRF, CORS & Headers
  6. Production Best Practices (this article)

For JWT-specific implementation, see: Spring Boot JWT Authentication Guide

Advertisement

MR

Moshiour Rahman

Software Architect & AI Engineer

Share:
MR

Moshiour Rahman

Software Architect & AI Engineer

Enterprise software architect with deep expertise in financial systems, distributed architecture, and AI-powered applications. Building large-scale systems at Fortune 500 companies. Specializing in LLM orchestration, multi-agent systems, and cloud-native solutions. I share battle-tested patterns from real enterprise projects.

Related Articles

Comments

Comments are powered by GitHub Discussions.

Configure Giscus at giscus.app to enable comments.