Web Security

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.

March 9, 20265 min readShipSafer Team

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:

  1. The API is stateless (no cookies)
  2. All requests include a Bearer token from localStorage or 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

  • SecurityFilterChain bean replaces WebSecurityConfigurerAdapter (deprecated)
  • CSRF disabled only for stateless JWT APIs; enabled with XSRF cookie for SPAs with sessions
  • SessionCreationPolicy.STATELESS for JWT-based APIs
  • JwtAuthenticationFilter catches JwtException — no unhandled exceptions
  • @EnableMethodSecurity + @PreAuthorize on sensitive service methods
  • BCryptPasswordEncoder(12) — never weaker
  • Actuator endpoints restricted to ADMIN role; /env and /shutdown not exposed
  • All secrets loaded from environment variables, not hardcoded
  • Security response headers configured
  • Dependency audit: mvn dependency-check:check (OWASP Dependency Check)

Check Your Security Score — Free

See exactly how your domain scores on DMARC, TLS, HTTP headers, and 25+ other automated security checks in under 60 seconds.