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.
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
- Part 1: Architecture & Fundamentals - Core concepts
- Part 2: Database Authentication - UserDetailsService
- Part 3: OAuth2 & Social Login - Google, GitHub integration
- Part 4: Method-Level Security - @PreAuthorize, SpEL
- Part 5: CSRF, CORS & Headers - Protection mechanisms
- Part 6: Production Best Practices - You are here
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
- Defense in Depth - Layer multiple security controls
- Secure by Default - Start restrictive, open as needed
- Least Privilege - Grant minimum required permissions
- Fail Securely - Errors should not expose information
- Monitor Everything - Log security events, set alerts
- Keep Updated - Patch dependencies regularly
- Test Security - Include security in your test suite
Series Complete
You’ve completed the Spring Security comprehensive guide:
- Architecture & Fundamentals
- Database Authentication
- OAuth2 & Social Login
- Method-Level Security
- CSRF, CORS & Headers
- Production Best Practices (this article)
For JWT-specific implementation, see: Spring Boot JWT Authentication Guide
Advertisement
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
Spring Boot 3 Virtual Threads: Complete Guide to Java 21 Concurrency
Master virtual threads in Spring Boot 3. Learn configuration, performance benchmarks, when to use them, common pitfalls, and production-ready patterns for high-throughput applications.
Spring BootSpring Security Method-Level Authorization: Complete @PreAuthorize Guide
Master method-level security in Spring Boot. Learn @PreAuthorize, @PostAuthorize, SpEL expressions, custom permissions, and domain object security.
Spring BootSpring Security Architecture: Complete Guide to How Security Works
Master Spring Security internals. Learn FilterChain, SecurityContext, Authentication flow, and how Spring Security protects your application.
Comments
Comments are powered by GitHub Discussions.
Configure Giscus at giscus.app to enable comments.