Spring Boot Security: Authentication, Authorization, and Secure Configuration
Secure Spring Boot apps with Spring Security config, JWT filters, method-level @PreAuthorize, CSRF handling for SPAs vs traditional apps, and actuator protection.
Spring Boot Security: Authentication, Authorization, and Secure Configuration
Spring Security is one of the most powerful and most misunderstood security frameworks in the Java ecosystem. Its auto-configuration magic works well until it doesn't, and then it's confusing. This guide walks through a production-ready security configuration from scratch.
Spring Security Dependency
<!-- pom.xml -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-api</artifactId>
<version>0.12.3</version>
</dependency>
Once you add spring-boot-starter-security, all endpoints require authentication by default. A random password is generated at startup and logged. Your first task is to replace this with a proper configuration.
Security Configuration Class
@Configuration
@EnableWebSecurity
@EnableMethodSecurity
public class SecurityConfig {
private final JwtAuthenticationFilter jwtAuthFilter;
public SecurityConfig(JwtAuthenticationFilter jwtAuthFilter) {
this.jwtAuthFilter = jwtAuthFilter;
}
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
return http
.csrf(csrf -> csrf.disable()) // Disabled for stateless JWT API
.sessionManagement(session ->
session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/auth/**").permitAll()
.requestMatchers("/api/public/**").permitAll()
.requestMatchers("/actuator/health").permitAll()
.requestMatchers("/actuator/**").hasRole("ADMIN")
.anyRequest().authenticated()
)
.addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class)
.exceptionHandling(ex -> ex
.authenticationEntryPoint((req, res, authException) ->
res.sendError(HttpServletResponse.SC_UNAUTHORIZED, "Unauthorized"))
.accessDeniedHandler((req, res, accessDeniedException) ->
res.sendError(HttpServletResponse.SC_FORBIDDEN, "Forbidden"))
)
.build();
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder(12);
}
}
JWT Authentication Filter
@Component
public class JwtAuthenticationFilter extends OncePerRequestFilter {
private final JwtService jwtService;
private final UserDetailsService userDetailsService;
@Override
protected void doFilterInternal(
HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain
) throws ServletException, IOException {
final String authHeader = request.getHeader("Authorization");
if (authHeader == null || !authHeader.startsWith("Bearer ")) {
filterChain.doFilter(request, response);
return;
}
final String token = authHeader.substring(7);
try {
final String username = jwtService.extractUsername(token);
if (username != null &&
SecurityContextHolder.getContext().getAuthentication() == null) {
UserDetails userDetails = userDetailsService.loadUserByUsername(username);
if (jwtService.isTokenValid(token, userDetails)) {
UsernamePasswordAuthenticationToken authToken =
new UsernamePasswordAuthenticationToken(
userDetails,
null,
userDetails.getAuthorities()
);
authToken.setDetails(
new WebAuthenticationDetailsSource().buildDetails(request));
SecurityContextHolder.getContext().setAuthentication(authToken);
}
}
} catch (JwtException e) {
// Invalid token — log and continue without setting authentication
log.warn("Invalid JWT token: {}", e.getMessage());
}
filterChain.doFilter(request, response);
}
}
JWT Service
@Service
public class JwtService {
@Value("${app.jwt.secret}")
private String secretKey;
@Value("${app.jwt.expiration-ms:1800000}") // 30 minutes default
private long expirationMs;
private SecretKey getSigningKey() {
return Keys.hmacShaKeyFor(secretKey.getBytes(StandardCharsets.UTF_8));
}
public String generateToken(UserDetails userDetails) {
return Jwts.builder()
.subject(userDetails.getUsername())
.issuedAt(new Date())
.expiration(new Date(System.currentTimeMillis() + expirationMs))
.signWith(getSigningKey())
.compact();
}
public String extractUsername(String token) {
return Jwts.parser()
.verifyWith(getSigningKey())
.build()
.parseSignedClaims(token)
.getPayload()
.getSubject();
}
public boolean isTokenValid(String token, UserDetails userDetails) {
return extractUsername(token).equals(userDetails.getUsername())
&& !isTokenExpired(token);
}
private boolean isTokenExpired(String token) {
return extractExpiration(token).before(new Date());
}
}
Method-Level Authorization with @PreAuthorize
Enable with @EnableMethodSecurity on your configuration class, then annotate service or controller methods:
@Service
public class UserService {
@PreAuthorize("hasRole('ADMIN')")
public List<User> getAllUsers() {
return userRepository.findAll();
}
@PreAuthorize("hasRole('ADMIN') or #userId == authentication.principal.id")
public User getUserById(String userId) {
return userRepository.findById(userId)
.orElseThrow(() -> new ResourceNotFoundException("User not found"));
}
@PreAuthorize("hasRole('ADMIN')")
@PostAuthorize("returnObject.userId == authentication.principal.id or hasRole('ADMIN')")
public User updateUser(String userId, UpdateUserRequest request) {
// ...
}
}
SpEL expressions in @PreAuthorize are powerful. Common patterns:
@PreAuthorize("hasAnyRole('ADMIN', 'SUPER_ADMIN')")
@PreAuthorize("hasPermission(#document, 'WRITE')")
@PreAuthorize("isAuthenticated() and #userId == authentication.name")
@PreAuthorize("@securityService.canAccess(authentication, #resourceId)")
CSRF: SPA vs Traditional Web Applications
Stateless JWT APIs (SPAs): CSRF protection is not needed when:
- The API is stateless (no cookies)
- All requests include a Bearer token from
localStorageor memory
In this case, disable CSRF as shown in the configuration above.
Traditional Server-Side Rendered Applications: CSRF must be enabled:
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
return http
.csrf(csrf -> csrf
.csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse())
// withHttpOnlyFalse allows JavaScript to read the cookie
// Required for AJAX frameworks like Angular that auto-include it
)
.build();
}
If you use Spring Security with session cookies AND a JavaScript frontend, enable CSRF with the CookieCsrfTokenRepository:
// Frontend JavaScript reads the XSRF-TOKEN cookie and sends it as a header
function getCsrfToken() {
return document.cookie.split('; ')
.find(row => row.startsWith('XSRF-TOKEN='))
?.split('=')[1];
}
fetch('/api/data', {
method: 'POST',
headers: { 'X-XSRF-TOKEN': getCsrfToken() },
body: JSON.stringify(data),
});
Actuator Endpoint Protection
Spring Boot Actuator exposes management endpoints. Exposing all of them without authentication is a critical misconfiguration — /actuator/env leaks environment variables including secrets, and /actuator/shutdown can kill your application:
# application.yml
management:
endpoints:
web:
exposure:
include: health,info,metrics # Only expose what you need
# Never include: env, beans, mappings, shutdown in production
endpoint:
health:
show-details: never # Don't expose health details to unauthenticated users
server:
port: 8081 # Separate port from main app (firewall it)
Combined with the security config above, ADMIN role is required for all actuator endpoints except /actuator/health.
Password Encoding
Always use BCryptPasswordEncoder with a sufficient cost factor:
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder(12); // Cost factor 12 — ~300ms per hash
}
// Registration
String hashedPassword = passwordEncoder.encode(rawPassword);
user.setPassword(hashedPassword);
// Login
boolean matches = passwordEncoder.matches(rawPassword, storedHash);
Never roll your own hashing or use MD5/SHA-1.
Secrets Configuration
# application.yml — commit this
app:
jwt:
secret: ${JWT_SECRET} # Injected from environment
expiration-ms: ${JWT_EXPIRATION_MS:1800000}
spring:
datasource:
url: ${DATABASE_URL}
username: ${DATABASE_USERNAME}
password: ${DATABASE_PASSWORD}
# .env or system environment — never commit
JWT_SECRET=a-random-256-bit-secret-value-here
DATABASE_URL=jdbc:postgresql://localhost:5432/mydb
DATABASE_PASSWORD=securepassword
For Kubernetes, use Secrets mounted as environment variables or a secrets manager like AWS Secrets Manager with the Secrets Store CSI Driver.
Security Response Headers
Spring Security adds some headers by default, but add a full set with configuration:
http.headers(headers -> headers
.frameOptions(frame -> frame.deny())
.contentTypeOptions(Customizer.withDefaults())
.httpStrictTransportSecurity(hsts -> hsts
.maxAgeInSeconds(63072000)
.includeSubDomains(true)
.preload(true)
)
.referrerPolicy(referrer ->
referrer.policy(ReferrerPolicyHeaderWriter.ReferrerPolicy.STRICT_ORIGIN_WHEN_CROSS_ORIGIN))
.permissionsPolicy(permissions ->
permissions.policy("camera=(), microphone=(), geolocation=()"))
);
Security Checklist
-
SecurityFilterChainbean replacesWebSecurityConfigurerAdapter(deprecated) - CSRF disabled only for stateless JWT APIs; enabled with XSRF cookie for SPAs with sessions
-
SessionCreationPolicy.STATELESSfor JWT-based APIs -
JwtAuthenticationFiltercatchesJwtException— no unhandled exceptions -
@EnableMethodSecurity+@PreAuthorizeon sensitive service methods -
BCryptPasswordEncoder(12)— never weaker - Actuator endpoints restricted to ADMIN role;
/envand/shutdownnot exposed - All secrets loaded from environment variables, not hardcoded
- Security response headers configured
- Dependency audit:
mvn dependency-check:check(OWASP Dependency Check)