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) or Argon2PasswordEncoder
  • Use PasswordEncoderFactories.createDelegatingPasswordEncoder() from day one — it prefixes hashes with {bcrypt}, enabling future algorithm upgrades without a migration
  • Implement UserDetailsPasswordService to 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-password by 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 HS256 with 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, and nbf claims 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 send X-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 state parameter 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 health and info on the public port
  • Bind management.server.port to 127.0.0.1 or a private network interface
  • Require ROLE_ACTUATOR_ADMIN for all non-public actuator endpoints
  • Disable shutdown, env, heapdump, and configprops in production
  • Use EndpointRequest.toAnyEndpoint() in a separate high-priority SecurityFilterChain
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

DoDo Not
Store in Vault, AWS Secrets Manager, or K8s SecretsCommit to source control
Inject at runtime via environment variablesHardcode in application.properties
Rotate on schedule and on suspected compromiseShare between environments
Use different secrets per environmentUse 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 @PreAuthorize at the service layer with assertThrows(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 AccessDeniedException with 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

PartArticlesTopics
1 — Architecture1–5Filter chain, SecurityContext, AuthenticationManager
2 — Authentication6–11Form login, JWT, HTTP Basic, LDAP, X.509
3 — OAuth2/OIDC12–15OAuth2 fundamentals, login, resource server, authorization server
4 — Authorization16–19HTTP rules, RBAC, method security, domain object ACLs
5 — Passwords20–21Encoding, registration, reset, migration
6 — Session/CSRF/CORS22–24Session management, CSRF tokens, CORS configuration
7 — Headers/MFA25–26Security headers, TOTP, WebAuthn
8 — Testing/Production27–30Testing, 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.