OWASP Top 10 security vulnerabilities for Java Spring Boot developers
Md Sanwar Hossain - Software Engineer
Md Sanwar Hossain

Software Engineer · Java · Spring Boot · Microservices

OWASP Top 10 for Java Developers: Fixing Real Vulnerabilities in Spring Boot APIs

The OWASP Top 10 is not a theoretical checklist — it is a ranked list of the vulnerabilities that attackers exploit most successfully in the real world. Every entry in the 2021 list has caused production breaches at companies with large engineering teams. This guide maps each category directly to Spring Boot code, showing the vulnerable pattern, explaining why it fails, and providing the production-hardened fix.

Table of Contents

  1. OWASP Top 10 2021: Why Java Developers Must Know This List
  2. A01: Broken Access Control in Spring Boot
  3. A02 & A03: Cryptographic Failures & Injection
  4. A05: Security Misconfiguration in Spring Boot
  5. A07: Authentication & Session Failures
  6. A09: Security Logging & Monitoring Failures
  7. A10: Server-Side Request Forgery (SSRF) in Spring Boot
  8. Dependency Vulnerability Scanning: A06

OWASP Top 10 2021: Why Java Developers Must Know This List

OWASP Top 10 2021 Java Spring Boot Security | mdsanwarhossain.me
OWASP Top 10 2021 — Severity-coded reference for Java & Spring Boot — mdsanwarhossain.me

The Open Web Application Security Project (OWASP) publishes the Top 10 every three to four years based on data from hundreds of thousands of real-world applications. The 2021 edition introduced three new categories that reflect the modern software supply chain: Insecure Design (A04), Software & Data Integrity Failures (A08), and Server-Side Request Forgery (A10). For Java developers building Spring Boot APIs, nearly every category maps directly to something you write in your daily work.

The table below gives you a quick orientation before we dive into each fix:

ID Category Spring Boot Risk Area Severity
A01 Broken Access Control Missing @PreAuthorize, IDOR via path variables Critical
A02 Cryptographic Failures Weak hashing, HTTP transport, secrets in logs High
A03 Injection Raw JDBC string concat, JPQL injection Critical
A04 Insecure Design No threat modeling, insecure defaults in design Medium
A05 Security Misconfiguration Exposed actuator, default creds, CORS wildcard High
A06 Vulnerable Components Outdated Spring Boot, Log4Shell, CVE deps High
A07 Auth & Session Failures Weak JWT secrets, no token expiry, no rotation High
A08 Software Integrity Failures Unsafe deserialization, unverified CI/CD artifacts Medium
A09 Logging & Monitoring Failures No auth failure logging, secrets in stack traces Medium
A10 SSRF User-controlled URL in RestTemplate / WebClient High

The ordering matters: A01 (Broken Access Control) moved from fifth place in 2017 to first place in 2021 because 94% of tested applications had at least one access control failure. It is the single most prevalent vulnerability class and the easiest to introduce accidentally in a REST API when developers focus on functionality and leave authorization as an afterthought.

A01: Broken Access Control in Spring Boot

Broken access control occurs when an authenticated user can perform actions or access data that they should not be able to. The most common manifestation in REST APIs is the Insecure Direct Object Reference (IDOR) — using a predictable identifier in a URL path to access another user's data. Consider this vulnerable endpoint:

// ❌ VULNERABLE — No authorization check, any authenticated user
// can read any other user's profile by changing the ID in the URL
@RestController
@RequestMapping("/api/users")
public class UserController {

    @GetMapping("/{userId}")
    public ResponseEntity<UserProfile> getUser(@PathVariable Long userId) {
        // Fetches ANY user's data — the caller's identity is never checked
        return ResponseEntity.ok(userService.findById(userId));
    }

    @PutMapping("/{userId}/orders/{orderId}")
    public ResponseEntity<Order> updateOrder(
            @PathVariable Long userId,
            @PathVariable Long orderId,
            @RequestBody OrderUpdateRequest request) {
        // An attacker can update anyone's order by guessing orderId
        return ResponseEntity.ok(orderService.update(orderId, request));
    }
}

This is a genuine critical vulnerability. An authenticated user can enumerate sequential IDs and read every other user's profile, order history, payment details, or address. The fix requires two complementary controls: method-level security with @PreAuthorize for role checks, and ownership validation in the service layer for resource-level access.

First, enable method security in your Spring Security configuration:

// Enable @PreAuthorize on all @Service and @Controller methods
@Configuration
@EnableWebSecurity
@EnableMethodSecurity(prePostEnabled = true, securedEnabled = true)
public class SecurityConfig {

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        return http
            .csrf(csrf -> csrf.disable()) // disabled for stateless JWT APIs
            .sessionManagement(session ->
                session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
            .authorizeHttpRequests(auth -> auth
                .requestMatchers("/api/auth/**").permitAll()
                .requestMatchers("/api/admin/**").hasRole("ADMIN")
                .anyRequest().authenticated()
            )
            .addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class)
            .build();
    }
}

With method security enabled, add @PreAuthorize at the controller level and ownership checks in the service layer:

// ✅ FIXED — Role-based access control at the controller layer
@RestController
@RequestMapping("/api/users")
public class UserController {

    // Any authenticated user can only access their own profile
    // Admins can access any profile
    @GetMapping("/{userId}")
    @PreAuthorize("hasRole('ADMIN') or #userId == authentication.principal.id")
    public ResponseEntity<UserProfile> getUser(@PathVariable Long userId) {
        return ResponseEntity.ok(userService.findById(userId));
    }

    // Order updates require ownership verification in service layer
    @PutMapping("/{userId}/orders/{orderId}")
    @PreAuthorize("isAuthenticated()")
    public ResponseEntity<Order> updateOrder(
            @PathVariable Long userId,
            @PathVariable Long orderId,
            @RequestBody OrderUpdateRequest request,
            Authentication authentication) {
        // Pass the caller's identity into the service — never trust the path variable alone
        return ResponseEntity.ok(
            orderService.updateWithOwnerCheck(orderId, request, authentication.getName())
        );
    }
}
// ✅ Service layer: ownership check prevents IDOR
@Service
public class OrderService {

    public Order updateWithOwnerCheck(Long orderId, OrderUpdateRequest request,
                                      String callerUsername) {
        Order order = orderRepository.findById(orderId)
            .orElseThrow(() -> new ResourceNotFoundException("Order not found"));

        // Verify the caller owns this order — prevents cross-user IDOR
        if (!order.getOwnerUsername().equals(callerUsername)) {
            // Log the attempted unauthorized access for security monitoring
            log.warn("Unauthorized order access attempt: user={}, orderId={}",
                callerUsername, orderId);
            throw new AccessDeniedException("You do not own this order");
        }

        order.applyUpdate(request);
        return orderRepository.save(order);
    }
}

The key principle: never trust input from the client to determine authorization. Path variables, query parameters, and request body fields that contain resource IDs are attacker-controlled. The server must always derive authorization from the authenticated security context and verify it against the resource being accessed.

For admin endpoints that need to list all resources, use pagination with strict role enforcement, and never expose internal IDs in responses — use UUIDs or opaque tokens that cannot be enumerated sequentially:

// ✅ Use UUID-based identifiers to prevent enumeration attacks
@Entity
public class UserProfile {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id; // internal auto-increment — never expose in API responses

    @Column(unique = true, nullable = false)
    private UUID publicId = UUID.randomUUID(); // expose this in API responses

    // ... other fields
}

A02 & A03: Cryptographic Failures & Injection

Spring Boot Defense-in-Depth Security Layers | mdsanwarhossain.me
Spring Boot Defense-in-Depth: Six security layers that must all be present — mdsanwarhossain.me

SQL Injection: The Classic That Never Goes Away

SQL injection is the most well-known vulnerability in the OWASP list and still appears regularly in Java codebases — particularly when developers use JdbcTemplate with raw string concatenation, thinking it is safe because they are not using an ORM. It is not safe:

// ❌ VULNERABLE — SQL injection via string concatenation with JdbcTemplate
@Repository
public class UserRepository {

    @Autowired
    private JdbcTemplate jdbcTemplate;

    public List<User> searchByUsername(String username) {
        // An attacker sends: username = "' OR '1'='1" and dumps the entire table
        // Or: username = "'; DROP TABLE users; --"
        String sql = "SELECT * FROM users WHERE username = '" + username + "'";
        return jdbcTemplate.query(sql, userRowMapper);
    }

    public User findByEmailAndStatus(String email, String status) {
        // Both parameters are injectable
        String sql = "SELECT * FROM users WHERE email = '" + email
                   + "' AND status = '" + status + "'";
        return jdbcTemplate.queryForObject(sql, userRowMapper);
    }
}

The attacker input ' OR '1'='1' -- turns the WHERE clause into a tautology that returns every row. More dangerous payloads can exfiltrate schema information or, on some databases, execute OS commands. The fix is always parameterized queries — they separate the SQL structure from the data, making injection structurally impossible:

// ✅ FIXED — Parameterized queries prevent SQL injection
@Repository
public class UserRepository {

    @Autowired
    private JdbcTemplate jdbcTemplate;

    public List<User> searchByUsername(String username) {
        // The ? placeholder is a bind variable — the driver sends it separately
        // from the SQL, so the database never interprets it as SQL syntax
        String sql = "SELECT * FROM users WHERE username = ?";
        return jdbcTemplate.query(sql, userRowMapper, username);
    }

    public Optional<User> findByEmailAndStatus(String email, String status) {
        String sql = "SELECT * FROM users WHERE email = ? AND status = ?";
        try {
            return Optional.of(jdbcTemplate.queryForObject(sql, userRowMapper, email, status));
        } catch (EmptyResultDataAccessException e) {
            return Optional.empty();
        }
    }
}

When using Spring Data JPA, JPQL with named parameters is safe. Raw @Query with string concatenation is not:

// ✅ SAFE — JPA named parameters, never interpolate into JPQL strings
public interface UserJpaRepository extends JpaRepository<User, Long> {

    // Spring Data derives a safe parameterized query automatically
    Optional<User> findByEmailAndStatus(String email, String status);

    // Named parameter in JPQL — safe
    @Query("SELECT u FROM User u WHERE u.department = :dept AND u.role = :role")
    List<User> findByDepartmentAndRole(
        @Param("dept") String department,
        @Param("role") String role
    );

    // ❌ NEVER do this — JPQL injection is possible just like SQL injection
    // @Query("SELECT u FROM User u WHERE u.username = '" + username + "'")
}

Password Hashing: BCrypt is Non-Negotiable

Storing passwords with MD5, SHA-1, or even SHA-256 is a cryptographic failure (A02). These are fast hashing algorithms designed for data integrity verification — an attacker with the hash table can crack billions of passwords per second using a GPU. BCrypt is a purpose-built password hashing function with a configurable cost factor that makes brute-forcing computationally infeasible:

// ❌ VULNERABLE — MD5 is completely broken for password storage
@Service
public class AuthService {
    public void registerUser(String username, String plainPassword) {
        // SHA-256 is also insufficient — GPU rigs can crack 10 billion/second
        String hashedPassword = DigestUtils.md5DigestAsHex(plainPassword.getBytes());
        userRepository.save(new User(username, hashedPassword));
    }

    public boolean authenticate(String username, String password) {
        User user = userRepository.findByUsername(username);
        // Comparing unsalted hashes — susceptible to rainbow table attacks
        return user.getPassword().equals(
            DigestUtils.md5DigestAsHex(password.getBytes())
        );
    }
}
// ✅ FIXED — BCrypt with cost factor 12 (adjust based on your server's capability)
@Configuration
public class PasswordEncoderConfig {

    @Bean
    public PasswordEncoder passwordEncoder() {
        // Cost factor 12 takes ~250ms per hash on modern hardware
        // This makes brute-forcing computationally prohibitive
        return new BCryptPasswordEncoder(12);
    }
}

@Service
public class AuthService {

    @Autowired
    private PasswordEncoder passwordEncoder;

    public void registerUser(String username, String plainPassword) {
        // BCrypt generates a unique salt for each hash automatically
        String hashedPassword = passwordEncoder.encode(plainPassword);
        userRepository.save(new User(username, hashedPassword));
        // Never log plainPassword — even at DEBUG level
    }

    public boolean authenticate(String username, String password) {
        User user = userRepository.findByUsername(username)
            .orElseThrow(() -> new BadCredentialsException("Invalid credentials"));
        // BCrypt.matches() handles the salt internally — constant-time comparison
        return passwordEncoder.matches(password, user.getPassword());
    }
}

Enforce HTTPS and Prevent Sensitive Data in Logs

// ✅ Force HTTPS redirect in Spring Security configuration
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
    return http
        // Redirect all HTTP to HTTPS with HSTS
        .requiresChannel(channel ->
            channel.anyRequest().requiresSecure()
        )
        // HSTS: tell browsers to always use HTTPS for 1 year
        .headers(headers -> headers
            .httpStrictTransportSecurity(hsts -> hsts
                .includeSubDomains(true)
                .maxAgeInSeconds(31536000)
            )
        )
        // ...other config
        .build();
}

// ✅ Mask sensitive fields in log output using @JsonIgnore and custom toString
public class LoginRequest {
    private String username;

    @JsonProperty(access = JsonProperty.Access.WRITE_ONLY)
    private String password;

    @Override
    public String toString() {
        // Never include the password field in any toString() used for logging
        return "LoginRequest{username='" + username + "', password='[REDACTED]'}";
    }
}

A05: Security Misconfiguration in Spring Boot

Spring Boot's autoconfiguration is excellent for developer productivity but can create security misconfigurations in production if left at their defaults. The three most dangerous default misconfigurations in Spring Boot are exposed Actuator endpoints, CORS wildcard origins, and verbose error messages that leak stack traces to clients.

Actuator Endpoints: Information Disclosure & RCE Risk

Spring Boot Actuator's /actuator/env endpoint exposes all environment variables including database passwords, API keys, and JWT secrets. The /actuator/heapdump endpoint lets anyone download a full heap dump containing decrypted credentials. The /actuator/loggers endpoint can be used to change log levels. Never expose Actuator without authentication:

# ❌ VULNERABLE application.yml — all actuator endpoints exposed without auth
management:
  endpoints:
    web:
      exposure:
        include: "*"   # Exposes env, heapdump, threaddump, loggers, beans, etc.
  endpoint:
    health:
      show-details: always  # Leaks database connection status to unauthenticated clients
# ✅ FIXED — Expose only what monitoring systems need, secure the rest
management:
  endpoints:
    web:
      exposure:
        # Only expose health and metrics for Prometheus scraping
        include: health,metrics,prometheus
      base-path: /internal/actuator  # Obscure the default path
  endpoint:
    health:
      # Only show details to authenticated users with ACTUATOR role
      show-details: when-authorized
      roles: ACTUATOR
    env:
      # Never expose environment variables
      enabled: false
    heapdump:
      enabled: false
    threaddump:
      enabled: false
// ✅ Secure actuator endpoints with Spring Security
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
    return http
        .authorizeHttpRequests(auth -> auth
            // Monitoring systems access health/metrics without authentication
            .requestMatchers("/internal/actuator/health").permitAll()
            .requestMatchers("/internal/actuator/prometheus").hasIpAddress("10.0.0.0/8")
            // All other actuator endpoints require the ACTUATOR role
            .requestMatchers("/internal/actuator/**").hasRole("ACTUATOR")
            .anyRequest().authenticated()
        )
        .build();
}

CORS: Never Use Wildcard in Production

// ❌ VULNERABLE — Wildcard CORS allows any website to make authenticated requests
@Configuration
public class CorsConfig implements WebMvcConfigurer {

    @Override
    public void addCorsMappings(CorsRegistry registry) {
        registry.addMapping("/api/**")
            .allowedOrigins("*")           // Any origin — allows cross-site attacks
            .allowedMethods("*")           // Including DELETE, PUT from any site
            .allowCredentials(true);       // With credentials — this is actually an error
            // Note: allowedOrigins("*") with allowCredentials(true) throws at runtime
            // but developers often switch to allowedOriginPatterns("*") which is dangerous
    }
}
// ✅ FIXED — Explicit allowlist of trusted origins
@Configuration
public class CorsConfig implements WebMvcConfigurer {

    @Value("${app.cors.allowed-origins}")
    private List<String> allowedOrigins;

    @Override
    public void addCorsMappings(CorsRegistry registry) {
        registry.addMapping("/api/**")
            .allowedOrigins(allowedOrigins.toArray(new String[0]))
            .allowedMethods("GET", "POST", "PUT", "DELETE", "PATCH")
            .allowedHeaders("Authorization", "Content-Type", "X-Requested-With")
            .allowCredentials(true)
            .maxAge(3600); // Cache preflight response for 1 hour
    }
}

// In application.yml:
// app:
//   cors:
//     allowed-origins:
//       - https://app.yourdomain.com
//       - https://admin.yourdomain.com

Suppress Verbose Error Responses

# ✅ Disable Spring Boot's default error page that leaks stack traces
server:
  error:
    include-stacktrace: never       # Never send stack traces to clients
    include-message: never          # Never expose exception messages
    include-binding-errors: never   # Never expose validation binding errors
    include-exception: false        # Never include exception class names

# ✅ Global exception handler to return safe, structured error responses
@RestControllerAdvice
public class GlobalExceptionHandler {

    private static final Logger log = LoggerFactory.getLogger(GlobalExceptionHandler.class);

    @ExceptionHandler(Exception.class)
    public ResponseEntity<ErrorResponse> handleGenericException(
            Exception ex, HttpServletRequest request) {
        // Log the full details server-side for debugging
        log.error("Unhandled exception for request {}: {}", request.getRequestURI(), ex.getMessage(), ex);
        // Return only a safe, generic message to the client
        return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
            .body(new ErrorResponse("An internal error occurred. Please try again later."));
    }

    @ExceptionHandler(AccessDeniedException.class)
    public ResponseEntity<ErrorResponse> handleAccessDenied(AccessDeniedException ex) {
        // Do NOT include the reason in the response — it could help attackers probe access controls
        return ResponseEntity.status(HttpStatus.FORBIDDEN)
            .body(new ErrorResponse("Access denied."));
    }
}

A07: Authentication & Session Failures

Authentication failures in Java APIs most commonly involve JWT implementation mistakes: weak signing secrets, tokens with no expiry, missing token revocation, and using JWTs for session management without refresh token rotation. Here is a production-hardened JWT configuration:

Vulnerable JWT: No Expiry, Weak Secret

// ❌ VULNERABLE — Multiple JWT security failures
@Component
public class JwtService {

    // Weak secret — easily brute-forced offline against captured tokens
    private static final String SECRET = "secret";

    public String generateToken(String username) {
        return Jwts.builder()
            .setSubject(username)
            // ❌ No expiry — once issued, token is valid forever
            // ❌ No issued-at claim — cannot detect token reuse after logout
            .signWith(Keys.hmacShaKeyFor(SECRET.getBytes()))
            .compact();
    }

    public boolean validateToken(String token) {
        try {
            Jwts.parserBuilder()
                .setSigningKey(Keys.hmacShaKeyFor(SECRET.getBytes()))
                .build()
                .parseClaimsJws(token);
            return true;
        } catch (JwtException e) {
            return false;
        }
        // ❌ No check against a revocation list — stolen tokens remain valid
    }
}

Hardened JWT: Short Expiry, Strong Keys, Refresh Rotation

// ✅ FIXED — Production-grade JWT configuration
@Component
public class JwtService {

    // ✅ 256-bit secret from environment variable — never hardcoded
    @Value("${jwt.secret}")
    private String jwtSecret;

    @Value("${jwt.access-token-expiry-minutes:15}")
    private int accessTokenExpiryMinutes; // Short-lived: 15 minutes

    @Value("${jwt.refresh-token-expiry-days:7}")
    private int refreshTokenExpiryDays;

    private SecretKey getSigningKey() {
        byte[] keyBytes = Decoders.BASE64.decode(jwtSecret);
        return Keys.hmacShaKeyFor(keyBytes);
    }

    public String generateAccessToken(UserDetails userDetails) {
        return Jwts.builder()
            .setSubject(userDetails.getUsername())
            .setIssuedAt(new Date())
            .setExpiration(Date.from(Instant.now().plusSeconds(accessTokenExpiryMinutes * 60L)))
            .claim("roles", userDetails.getAuthorities().stream()
                .map(GrantedAuthority::getAuthority).collect(Collectors.toList()))
            .claim("jti", UUID.randomUUID().toString()) // JWT ID for revocation tracking
            .signWith(getSigningKey(), SignatureAlgorithm.HS256)
            .compact();
    }

    public String generateRefreshToken(String username) {
        String tokenId = UUID.randomUUID().toString();
        String token = Jwts.builder()
            .setSubject(username)
            .setIssuedAt(new Date())
            .setExpiration(Date.from(Instant.now().plusSeconds(refreshTokenExpiryDays * 86400L)))
            .claim("jti", tokenId)
            .claim("type", "refresh")
            .signWith(getSigningKey(), SignatureAlgorithm.HS256)
            .compact();
        // Store the refresh token hash in Redis for revocation capability
        refreshTokenStore.store(tokenId, username, Duration.ofDays(refreshTokenExpiryDays));
        return token;
    }

    public Claims validateAndExtractClaims(String token) {
        Claims claims = Jwts.parserBuilder()
            .setSigningKey(getSigningKey())
            .build()
            .parseClaimsJws(token) // Throws ExpiredJwtException if expired
            .getBody();

        // Check the JTI against the revocation list (Redis set of revoked token IDs)
        String jti = claims.get("jti", String.class);
        if (tokenRevocationStore.isRevoked(jti)) {
            throw new JwtException("Token has been revoked");
        }
        return claims;
    }
}
// ✅ Refresh token rotation — old refresh token is invalidated on each use
@RestController
@RequestMapping("/api/auth")
public class AuthController {

    @PostMapping("/refresh")
    public ResponseEntity<TokenResponse> refreshTokens(
            @CookieValue("refresh_token") String refreshToken) {
        try {
            Claims claims = jwtService.validateAndExtractClaims(refreshToken);

            // Validate this is actually a refresh token
            if (!"refresh".equals(claims.get("type", String.class))) {
                return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
            }

            String username = claims.getSubject();
            String oldJti = claims.get("jti", String.class);

            // Invalidate the old refresh token — prevents replay attacks
            tokenRevocationStore.revoke(oldJti);

            // Issue fresh access + refresh tokens
            UserDetails userDetails = userDetailsService.loadUserByUsername(username);
            String newAccessToken = jwtService.generateAccessToken(userDetails);
            String newRefreshToken = jwtService.generateRefreshToken(username);

            // Deliver refresh token via HttpOnly cookie — not accessible to JavaScript
            ResponseCookie refreshCookie = ResponseCookie.from("refresh_token", newRefreshToken)
                .httpOnly(true)
                .secure(true)
                .sameSite("Strict")
                .path("/api/auth/refresh")
                .maxAge(Duration.ofDays(7))
                .build();

            return ResponseEntity.ok()
                .header(HttpHeaders.SET_COOKIE, refreshCookie.toString())
                .body(new TokenResponse(newAccessToken));
        } catch (JwtException e) {
            return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
        }
    }
}

Key security properties of this design: access tokens expire after 15 minutes, limiting the window for misuse after a token is stolen. Refresh tokens are single-use — every refresh invalidates the old token and issues a new one, so stolen refresh tokens are quickly detected (the legitimate user's next refresh will fail). Refresh tokens are stored in HttpOnly cookies, making them inaccessible to XSS attacks targeting JavaScript.

A09: Security Logging & Monitoring Failures

The OWASP Top 10 ranks logging and monitoring failures in the top 10 because without logs, breaches go undetected for months. The 2020 IBM Cost of a Data Breach report found the average time to identify a breach was 207 days. Adequate security logging cuts detection time dramatically. However, logging done wrong — capturing passwords, tokens, or PII — creates a secondary vulnerability where the attacker who accesses your logs harvests production credentials.

Spring Security Authentication Event Listener

// ✅ Capture authentication events for security audit trail
@Component
public class SecurityEventListener {

    private static final Logger securityLog = LoggerFactory.getLogger("SECURITY_AUDIT");

    @EventListener
    public void onAuthenticationSuccess(AuthenticationSuccessEvent event) {
        String username = event.getAuthentication().getName();
        // Do NOT log tokens, passwords, or credentials — only the outcome
        securityLog.info("AUTH_SUCCESS user={} ip={}", username, getCurrentIp());
    }

    @EventListener
    public void onAuthenticationFailure(AbstractAuthenticationFailureEvent event) {
        // Log failures without logging the attempted password
        String username = (String) event.getAuthentication().getPrincipal();
        String failureReason = event.getException().getClass().getSimpleName();
        // Sanitize username to prevent log injection — strip newlines
        String safeUsername = username.replaceAll("[\r\n]", "_");
        securityLog.warn("AUTH_FAILURE user={} reason={} ip={}",
            safeUsername, failureReason, getCurrentIp());
    }

    @EventListener
    public void onAuthorizationFailure(AuthorizationDeniedEvent event) {
        Authentication auth = (Authentication) event.getAuthentication().get();
        securityLog.warn("ACCESS_DENIED user={} resource={} ip={}",
            auth.getName(),
            event.getAuthorizationDecision(),
            getCurrentIp());
    }

    private String getCurrentIp() {
        // Use RequestContextHolder to get the current request IP
        try {
            HttpServletRequest request = ((ServletRequestAttributes)
                RequestContextHolder.getRequestAttributes()).getRequest();
            String xff = request.getHeader("X-Forwarded-For");
            return (xff != null) ? xff.split(",")[0].trim() : request.getRemoteAddr();
        } catch (Exception e) {
            return "unknown";
        }
    }
}

Preventing Sensitive Data in Exception Logs

// ❌ VULNERABLE — Exception message leaks the password in logs
public class PaymentService {
    public void processPayment(String cardNumber, String cvv, BigDecimal amount) {
        try {
            paymentGateway.charge(cardNumber, cvv, amount);
        } catch (Exception e) {
            // The exception message may contain the CVV or card number!
            log.error("Payment failed for card {} cvv {} amount {}: {}",
                cardNumber, cvv, amount, e.getMessage());
        }
    }
}
// ✅ FIXED — Mask sensitive fields before logging
public class PaymentService {

    public void processPayment(PaymentRequest request) {
        try {
            paymentGateway.charge(request);
        } catch (PaymentGatewayException e) {
            // Mask card number — show only last 4 digits
            String maskedCard = maskCardNumber(request.getCardNumber());
            // Never log CVV under any circumstances
            log.error("Payment failed: maskedCard={} amount={} error={}",
                maskedCard, request.getAmount(), e.getErrorCode());
            // Re-throw a sanitized exception that does not propagate raw gateway messages
            throw new PaymentProcessingException("Payment could not be processed");
        }
    }

    private String maskCardNumber(String cardNumber) {
        if (cardNumber == null || cardNumber.length() < 4) return "****";
        return "*".repeat(cardNumber.length() - 4) + cardNumber.substring(cardNumber.length() - 4);
    }
}

Logback Configuration: Security Audit Log to ELK

<!-- logback-spring.xml: Separate security audit log for SIEM integration -->
<configuration>
    <!-- Application log -->
    <appender name="APP_JSON" class="ch.qos.logback.core.rolling.RollingFileAppender">
        <file>logs/application.log</file>
        <encoder class="net.logstash.logback.encoder.LogstashEncoder"/>
        <rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
            <fileNamePattern>logs/application.%d{yyyy-MM-dd}.log</fileNamePattern>
            <maxHistory>30</maxHistory>
        </rollingPolicy>
    </appender>

    <!-- Security audit log — separate file, longer retention, forwarded to SIEM -->
    <appender name="SECURITY_AUDIT" class="ch.qos.logback.core.rolling.RollingFileAppender">
        <file>logs/security-audit.log</file>
        <encoder class="net.logstash.logback.encoder.LogstashEncoder">
            <customFields>{"log_type":"security_audit","service":"user-api"}</customFields>
        </encoder>
        <rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
            <fileNamePattern>logs/security-audit.%d{yyyy-MM-dd}.log</fileNamePattern>
            <maxHistory>365</maxHistory> <!-- 1-year retention for compliance -->
        </rollingPolicy>
    </appender>

    <logger name="SECURITY_AUDIT" level="INFO" additivity="false">
        <appender-ref ref="SECURITY_AUDIT"/>
    </logger>
</configuration>

A10: Server-Side Request Forgery (SSRF) in Spring Boot

SSRF is a new addition to the 2021 OWASP Top 10 and reflects the reality of cloud-hosted infrastructure. When an application makes HTTP requests to a URL provided by the user, an attacker can direct those requests to internal resources that should not be accessible — including cloud metadata services (AWS IMDSv1 at 169.254.169.254), internal databases, Kubernetes API servers, or other microservices on the private network.

// ❌ VULNERABLE — User-controlled URL passed directly to RestTemplate
@RestController
@RequestMapping("/api/previews")
public class UrlPreviewController {

    @Autowired
    private RestTemplate restTemplate;

    @GetMapping
    public ResponseEntity<String> fetchPreview(@RequestParam String url) {
        // Attacker sends: url=http://169.254.169.254/latest/meta-data/iam/security-credentials/
        // This fetches AWS instance credentials from the metadata service!
        // Or: url=http://internal-redis:6379  to probe internal services
        ResponseEntity<String> response = restTemplate.getForEntity(url, String.class);
        return ResponseEntity.ok(response.getBody());
    }
}
// ✅ FIXED — Strict URL allowlist validation before making any outbound request
@Service
public class SsrfSafeHttpClient {

    // Allowlist of trusted domains — loaded from configuration
    @Value("${app.ssrf.allowed-hosts}")
    private Set<String> allowedHosts;

    // Blocked IP ranges: loopback, private networks, link-local (cloud metadata)
    private static final List<String> BLOCKED_PREFIXES = List.of(
        "127.", "10.", "172.16.", "172.17.", "172.18.", "172.19.",
        "172.20.", "172.21.", "172.22.", "172.23.", "172.24.", "172.25.",
        "172.26.", "172.27.", "172.28.", "172.29.", "172.30.", "172.31.",
        "192.168.", "169.254.", // AWS metadata service
        "::1", "fc", "fd"      // IPv6 loopback and ULA
    );

    public String safeFetch(String rawUrl) {
        URI uri = validateAndParseUrl(rawUrl);
        // Resolve the hostname to its IP to prevent DNS rebinding attacks
        InetAddress resolved = resolveHostSafely(uri.getHost());
        checkIpNotBlocked(resolved);

        // Make the request using the resolved IP, not the hostname, to prevent TOCTOU
        return restTemplate.getForObject(uri.toString(), String.class);
    }

    private URI validateAndParseUrl(String rawUrl) {
        URI uri;
        try {
            uri = new URI(rawUrl);
        } catch (URISyntaxException e) {
            throw new InvalidUrlException("Malformed URL");
        }

        // Only allow HTTPS — never HTTP, file://, or other schemes
        if (!"https".equalsIgnoreCase(uri.getScheme())) {
            throw new InvalidUrlException("Only HTTPS URLs are permitted");
        }

        // Verify the host is on the explicit allowlist
        String host = uri.getHost();
        if (host == null || !allowedHosts.contains(host.toLowerCase())) {
            throw new InvalidUrlException("Host not in allowed list: " + host);
        }
        return uri;
    }

    private InetAddress resolveHostSafely(String hostname) {
        try {
            return InetAddress.getByName(hostname);
        } catch (UnknownHostException e) {
            throw new InvalidUrlException("Cannot resolve host: " + hostname);
        }
    }

    private void checkIpNotBlocked(InetAddress address) {
        String ipString = address.getHostAddress();
        // Check against blocked IP ranges
        boolean blocked = BLOCKED_PREFIXES.stream().anyMatch(ipString::startsWith)
            || address.isLoopbackAddress()
            || address.isSiteLocalAddress()
            || address.isLinkLocalAddress();
        if (blocked) {
            log.warn("SSRF_BLOCKED target_ip={}", ipString);
            throw new InvalidUrlException("Target IP address is not permitted");
        }
    }
}

Additionally, configure RestTemplate or WebClient to use a dedicated HTTP proxy for all outbound traffic, so even if validation is bypassed, internal traffic is routed through a controlled egress point. On Kubernetes, use NetworkPolicy to restrict which pods can make outbound requests.

Dependency Vulnerability Scanning: A06

The Log4Shell vulnerability (CVE-2021-44228) in December 2021 demonstrated that a single dependency with a remote code execution vulnerability can be catastrophic for every application using it. Broken or outdated components (A06) is now the second most common failure vector after access control. The solution is automated dependency vulnerability scanning integrated into your CI/CD pipeline.

OWASP Dependency-Check Maven Plugin

<!-- pom.xml: OWASP Dependency-Check configuration -->
<build>
    <plugins>
        <plugin>
            <groupId>org.owasp</groupId>
            <artifactId>dependency-check-maven</artifactId>
            <version>9.2.0</version>
            <configuration>
                <!-- Fail the build if any dependency has a CVSS score >= 7.0 (High) -->
                <failBuildOnCVSS>7</failBuildOnCVSS>
                <!-- Generate reports in multiple formats -->
                <formats>
                    <format>HTML</format>
                    <format>JSON</format>
                    <format>SARIF</format>
                </formats>
                <!-- Suppress known false positives with a suppression file -->
                <suppressionFile>owasp-suppressions.xml</suppressionFile>
                <!-- Update CVE database from NVD API -->
                <nvdApiKey>${env.NVD_API_KEY}</nvdApiKey>
                <!-- Skip test-scope dependencies -->
                <skipTestScope>false</skipTestScope>
            </configuration>
            <executions>
                <execution>
                    <goals>
                        <goal>check</goal>
                    </goals>
                </execution>
            </executions>
        </plugin>
    </plugins>
</build>
# .github/workflows/security.yml — Dependency scanning in CI/CD
name: Security Scan

on:
  push:
    branches: [main, develop]
  pull_request:
    branches: [main]
  schedule:
    # Run nightly to catch newly published CVEs against existing code
    - cron: '0 2 * * *'

jobs:
  dependency-check:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Set up Java 21
        uses: actions/setup-java@v4
        with:
          java-version: '21'
          distribution: 'temurin'

      - name: OWASP Dependency Check
        run: mvn dependency-check:check -DnvdApiKey=${{ secrets.NVD_API_KEY }}
        continue-on-error: false  # Hard fail — do not deploy with known High CVEs

      - name: Upload SARIF to GitHub Security tab
        uses: github/codeql-action/upload-sarif@v3
        if: always()
        with:
          sarif_file: target/dependency-check-report.sarif

Keeping Spring Boot Dependencies Current

<!-- Use Maven Versions Plugin to check for outdated dependencies -->
<plugin>
    <groupId>org.codehaus.mojo</groupId>
    <artifactId>versions-maven-plugin</artifactId>
    <version>2.16.2</version>
    <configuration>
        <generateBackupPoms>false</generateBackupPoms>
    </configuration>
</plugin>

<!-- Run: mvn versions:display-dependency-updates -->
<!-- Run: mvn versions:display-plugin-updates -->
# Renovate or Dependabot for automated PR creation on dependency updates
# .github/dependabot.yml
version: 2
updates:
  - package-ecosystem: maven
    directory: "/"
    schedule:
      interval: weekly
    open-pull-requests-limit: 10
    groups:
      # Group Spring Boot updates together
      spring-boot:
        patterns:
          - "org.springframework.boot:*"
          - "org.springframework.security:*"
          - "org.springframework:*"

Security Hardening Checklist

  • A01: Enable @EnableMethodSecurity, add @PreAuthorize on all endpoints, validate resource ownership in service layer
  • A02: Use BCrypt (cost ≥ 12) for passwords, enforce HTTPS with HSTS, never log sensitive fields
  • A03: Always use parameterized queries — zero tolerance for string concatenation in SQL/JPQL
  • A05: Disable or authenticate all Actuator endpoints, configure explicit CORS allowlist, suppress stack traces in error responses
  • A06: Run OWASP Dependency-Check in CI/CD, fail build on CVSS ≥ 7, use Dependabot for automated updates
  • A07: Short-lived JWTs (15 min), rotating refresh tokens in HttpOnly cookies, strong HS256 secrets from environment
  • A09: Log auth success/failure/denial events without sensitive data, separate security audit log, 1-year retention
  • A10: Validate & allowlist outbound URLs, resolve DNS and check IPs, block RFC-1918 and link-local addresses

Security is not a feature you add at the end — it is an architectural property woven through every layer of a Spring Boot application. The OWASP Top 10 gives you a prioritized, evidence-based map of where to focus your effort. By treating each category as a concrete engineering requirement with measurable controls (parameterized queries, BCrypt, short-lived tokens, SSRF allowlists), you convert a generic security checklist into production-safe code.

Leave a Comment

Related Posts

Md Sanwar Hossain - Software Engineer
Md Sanwar Hossain

Software Engineer · Java · Spring Boot · Microservices

Last updated: April 5, 2026