Spring Security Best Practices and Production Checklist
Using This Reference
This is the final article in the Spring Security series. It is a consolidated reference — not a tutorial. Come back to this checklist before every launch and when reviewing a new codebase. Each item links back to the relevant article in the series.
1. Keep Spring Security Updated
Security vulnerabilities in Spring Security itself are rare but severe when they occur. A dependency that is one minor version behind can expose known CVEs.
# Check for outdated dependencies
mvn versions:display-dependency-updates
# Run OWASP dependency check
mvn dependency-check:check
Subscribe to Spring Security advisories. When a security advisory is published, treat the update as a P1 — no release gating.
2. Authentication
Passwords
- Hash with
BCryptPasswordEncoder(minimum cost 10, calibrate to 200–500 ms) orArgon2PasswordEncoder - Use
PasswordEncoderFactories.createDelegatingPasswordEncoder()from day one — it prefixes hashes with{bcrypt}, enabling future algorithm upgrades without a migration - Implement
UserDetailsPasswordServiceto auto-upgrade hashes on login when the cost factor increases - Never store plaintext passwords, MD5 hashes, or SHA1 hashes
// Correct
@Bean
public PasswordEncoder passwordEncoder() {
return PasswordEncoderFactories.createDelegatingPasswordEncoder();
}
// Wrong
@Bean
public PasswordEncoder passwordEncoder() {
return NoOpPasswordEncoder.getInstance(); // plaintext — never use in production
}
Brute Force Protection
- Rate limit
/login,/register, and/reset-passwordby IP and username - Lock accounts after N failed attempts (5–10) with a timed unlock
- Log every failed authentication attempt with username and IP
Multi-Factor Authentication
- Offer TOTP (Google Authenticator) for all accounts
- Require TOTP or WebAuthn for admin and privileged accounts
- Always generate recovery codes at enrollment (store as bcrypt hashes)
3. Authorization
URL-Level Authorization
Always end with an explicit catch-all:
http.authorizeHttpRequests(auth -> auth
.requestMatchers("/public/**").permitAll()
.requestMatchers("/admin/**").hasRole("ADMIN")
.requestMatchers("/api/**").authenticated()
.anyRequest().denyAll() // NEVER use anyRequest().permitAll()
);
Rules are evaluated in order. More specific paths must come before broader patterns.
Method-Level Authorization
Enable @EnableMethodSecurity and annotate service methods directly:
@PreAuthorize("hasRole('ADMIN') or #userId == authentication.principal.id")
public User getUser(Long userId) { ... }
Test every @PreAuthorize annotation with both an allowed user and a denied user.
Data-Level Authorization
Never return data the caller is not authorized to see:
// WRONG — loads all, then filters (or worse, returns everything)
public List<Document> getDocuments() {
return documentRepository.findAll();
}
// CORRECT — filter in the query
public List<Document> getDocuments(Authentication auth) {
if (hasAdminRole(auth)) return documentRepository.findAll();
return documentRepository.findByOwnerId(auth.getName());
}
4. JWT Best Practices
- Use asymmetric keys (RS256 or ES256) — not
HS256with a shared secret — for tokens consumed by multiple services - Set short expiry: 15 minutes for access tokens, 7–30 days for refresh tokens
- Store the signing key in a secret manager (Vault, AWS KMS), not in application properties
- Validate
iss,aud,exp, andnbfclaims on every token - Implement refresh token rotation — each refresh issues a new refresh token and invalidates the previous one
- Maintain a deny list for immediately-revocable tokens (compromised sessions)
// Correct claim validation in resource server
http.oauth2ResourceServer(oauth2 -> oauth2
.jwt(jwt -> jwt
.jwtAuthenticationConverter(jwtAuthenticationConverter())
.decoder(NimbusJwtDecoder.withJwkSetUri(jwkSetUri)
.jwtProcessorCustomizer(processor -> {
processor.setJWTClaimsSetVerifier(new DefaultJWTClaimsVerifier<>(
new JWTClaimsSet.Builder()
.issuer("https://auth.example.com")
.audience("your-api")
.build(),
Set.of("sub", "iat", "exp", "jti")
));
})
.build())
)
);
5. Session Security
- Session cookies:
HttpOnly=true,Secure=true,SameSite=Strict - Use
changeSessionId()for session fixation protection (Spring Security default) - Set
maximumSessions(1)for sensitive applications — prevent concurrent logins - For multi-instance deployments, use Spring Session with Redis
- Log session creation and destruction events
- Invalidate all sessions on password change and logout
server.servlet.session.cookie.http-only=true
server.servlet.session.cookie.secure=true
server.servlet.session.cookie.same-site=strict
server.servlet.session.timeout=30m
6. CSRF
- CSRF protection is on by default — do not disable it for form-based apps
- Safe to disable only for stateless APIs using JWT Bearer tokens
- For SPAs, use
CookieCsrfTokenRepository.withHttpOnlyFalse()and sendX-XSRF-TOKEN - Test that CSRF is enforced with MockMvc’s
csrf()request post-processor
// Correct for SPA + stateless JWT
http.csrf(csrf -> csrf.disable())
.sessionManagement(session -> session
.sessionCreationPolicy(SessionCreationPolicy.STATELESS));
// Correct for SPA + session-based
http.csrf(csrf -> csrf
.csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse())
.csrfTokenRequestHandler(new XorCsrfTokenRequestAttributeHandler()));
7. Security Headers
Spring Security’s defaults are solid. Add CSP on top:
http.headers(headers -> headers
.contentSecurityPolicy(csp -> csp.policyDirectives(
"default-src 'self'; " +
"script-src 'self'; " +
"style-src 'self'; " +
"img-src 'self' data:; " +
"frame-ancestors 'none'; " +
"form-action 'self'; " +
"upgrade-insecure-requests"
))
.httpStrictTransportSecurity(hsts -> hsts
.maxAgeInSeconds(31536000)
.includeSubDomains(true)
)
.referrerPolicy(referrer -> referrer
.policy(ReferrerPolicyHeaderWriter.ReferrerPolicy.STRICT_ORIGIN_WHEN_CROSS_ORIGIN)
)
);
Test with Mozilla Observatory (observatory.mozilla.org) before launch.
8. OAuth2 and OpenID Connect
- Validate the
stateparameter in authorization code flows (Spring Security does this automatically) - Use PKCE for public clients (SPAs, mobile apps) even with confidential clients
- Scope tokens — grant the minimum scope needed per use case
- Rotate client secrets on schedule and on suspected compromise
- Use short-lived access tokens; implement refresh token rotation
9. Actuator Security
- Expose only
healthandinfoon the public port - Bind
management.server.portto127.0.0.1or a private network interface - Require
ROLE_ACTUATOR_ADMINfor all non-public actuator endpoints - Disable
shutdown,env,heapdump, andconfigpropsin production - Use
EndpointRequest.toAnyEndpoint()in a separate high-prioritySecurityFilterChain
management.endpoints.web.exposure.include=health,info
management.server.port=8081
management.server.address=127.0.0.1
management.endpoint.health.show-details=when-authorized
management.endpoint.health.roles=ACTUATOR_ADMIN
10. Secrets Management
| Do | Do Not |
|---|---|
| Store in Vault, AWS Secrets Manager, or K8s Secrets | Commit to source control |
| Inject at runtime via environment variables | Hardcode in application.properties |
| Rotate on schedule and on suspected compromise | Share between environments |
| Use different secrets per environment | Use the same signing key for dev and prod |
11. Testing
- Test every protected endpoint: unauthenticated (401/redirect), wrong role (403), correct role (200)
- Test CSRF enforcement for form-based apps
- Test
@PreAuthorizeat the service layer withassertThrows(AccessDeniedException.class, ...) - Include security tests in CI — do not let PRs merge without passing security tests
- Test the boundary conditions of URL patterns (trailing slashes, path traversal)
// Test the boundaries — not just the happy path
@Test
@WithMockUser(roles = "USER")
void userCannotBypassAdminEndpointWithTrailingSlash() throws Exception {
mockMvc.perform(get("/admin/")).andExpect(status().isForbidden());
mockMvc.perform(get("/admin")).andExpect(status().isForbidden());
mockMvc.perform(get("/admin/users")).andExpect(status().isForbidden());
}
12. Monitoring and Incident Response
What to Log
- Every authentication attempt (success and failure) with username, IP, timestamp
- Every
AccessDeniedExceptionwith user, resource, and timestamp - Every privilege escalation or role change
- Every password change and MFA enrollment/removal
What to Alert On
- Spike in authentication failures from a single IP (credential stuffing)
- Spike in
AccessDeniedException(scanning/probing) - Authentication from a new country or impossible travel
- Admin account login outside business hours
Incident Response
- Have a runbook for: account takeover, credential leak, session fixation, dependency CVE
- Know how to: force logout all sessions for a user, rotate JWT signing keys (which invalidates all tokens), disable an account immediately
Common Anti-Patterns — Avoid These
// 1. Permitting all by default
.anyRequest().permitAll() // new endpoints immediately public
// 2. Disabling CSRF for a form-based app
http.csrf(csrf -> csrf.disable()) // direct CSRF vulnerability
// 3. Using MD5 or SHA1
new MessageDigestPasswordEncoder("MD5") // trivially brute-forced
// 4. Wildcard CORS with credentials
config.setAllowedOrigins(List.of("*"));
config.setAllowCredentials(true); // Spring throws IllegalArgumentException
// 5. Storing JWT secrets in application.properties
spring.security.oauth2.resourceserver.jwt.jwk-set-uri=...
jwt.signing.key=super-secret // leaked with every config dump
// 6. Self-invocation bypassing @PreAuthorize
@Service
class OrderService {
@PreAuthorize("hasRole('ADMIN')")
public void delete(Long id) { ... }
public void batch() {
delete(1L); // bypasses @PreAuthorize — same bean, no proxy
}
}
// 7. No deny-all fallback
http.authorizeHttpRequests(auth -> auth
.requestMatchers("/admin/**").hasRole("ADMIN")
// new endpoint added → immediately accessible to everyone
);
Recap: What This Series Covered
| Part | Articles | Topics |
|---|---|---|
| 1 — Architecture | 1–5 | Filter chain, SecurityContext, AuthenticationManager |
| 2 — Authentication | 6–11 | Form login, JWT, HTTP Basic, LDAP, X.509 |
| 3 — OAuth2/OIDC | 12–15 | OAuth2 fundamentals, login, resource server, authorization server |
| 4 — Authorization | 16–19 | HTTP rules, RBAC, method security, domain object ACLs |
| 5 — Passwords | 20–21 | Encoding, registration, reset, migration |
| 6 — Session/CSRF/CORS | 22–24 | Session management, CSRF tokens, CORS configuration |
| 7 — Headers/MFA | 25–26 | Security headers, TOTP, WebAuthn |
| 8 — Testing/Production | 27–30 | Testing, Actuator, reactive security, best practices |
Spring Security is wide and deep. The filter chain is the foundation — when something breaks, trace the request through the chain. When adding a new authentication mechanism, find the right filter to extend or replace. When authorization is wrong, check the order of rules, the ROLE_ prefix convention, and whether method security is enabled.
Security is not a feature — it is a property of the system that must be preserved with every change. Test it, monitor it, and treat every security advisory as urgent.