AI Code Review

Why Spring Security Misconfigurations Cause Critical Auth Bypasses and How To Test Them

Amartya | CodeAnt AI Code Review Platform
Sonali Sood

Founding GTM, CodeAnt AI

Why Spring Security Is the Most Consistently Misconfigured Auth Framework

Spring Security penetration testing is not just testing for framework vulnerabilities. It is testing whether your authentication and authorization logic can be bypassed through misconfiguration, filter chain gaps, JWT validation flaws, or access control errors that attackers routinely exploit.

That matters because many critical Spring Security breaches do not come from flaws in the framework itself. They come from seemingly harmless configuration decisions such as misused web.ignoring() rules, wildcard matcher gaps, broken method security, or exposed actuator endpoints that silently create authentication bypass paths.

These are exactly the kinds of issues traditional scanners often miss and why automated penetration testing, white box code review are increasingly used to improve codebase security in modern Java applications.

In this guide, you will learn:

  • The highest-impact Spring Security auth bypass patterns

  • How attackers exploit filter chain and configuration weaknesses

  • How to test Spring Boot applications for real exploit paths

  • How automated penetration testing finds what point tools often miss

  • What modern code security platforms should validate in enterprise environments

Whether you want to improve your codebase security, evaluate the best security platform for enterprises, or understand how Spring Security vulnerabilities actually become breaches, this guide walks through every major attack pattern and how to test for it.

Spring Security Architecture: What You Must Understand Before Testing

The Filter Chain Model

Spring Security operates as a chain of servlet filters that execute before every HTTP request reaches the application's controllers. Understanding this chain is the prerequisite for understanding every misconfiguration.

// The security filter chain execution order:
// (Simplified — actual chain has ~15+ filters)

HTTP Request
    ↓
SecurityContextPersistenceFilter    // Loads/saves SecurityContextUsernamePasswordAuthenticationFilter // Handles form login (if configured)
    ↓
BasicAuthenticationFilter           // Handles HTTP Basic auth (if configured)BearerTokenAuthenticationFilter     // Handles JWT/OAuth2 (if configured)
    ↓
ExceptionTranslationFilter          // Handles security exceptionsFilterSecurityInterceptor           // Enforces authorization rules ← KEY
    ↓
DispatcherServlet                   // Routes to controllersController method executes
// The security filter chain execution order:
// (Simplified — actual chain has ~15+ filters)

HTTP Request
    ↓
SecurityContextPersistenceFilter    // Loads/saves SecurityContextUsernamePasswordAuthenticationFilter // Handles form login (if configured)
    ↓
BasicAuthenticationFilter           // Handles HTTP Basic auth (if configured)BearerTokenAuthenticationFilter     // Handles JWT/OAuth2 (if configured)
    ↓
ExceptionTranslationFilter          // Handles security exceptionsFilterSecurityInterceptor           // Enforces authorization rules ← KEY
    ↓
DispatcherServlet                   // Routes to controllersController method executes
// The security filter chain execution order:
// (Simplified — actual chain has ~15+ filters)

HTTP Request
    ↓
SecurityContextPersistenceFilter    // Loads/saves SecurityContextUsernamePasswordAuthenticationFilter // Handles form login (if configured)
    ↓
BasicAuthenticationFilter           // Handles HTTP Basic auth (if configured)BearerTokenAuthenticationFilter     // Handles JWT/OAuth2 (if configured)
    ↓
ExceptionTranslationFilter          // Handles security exceptionsFilterSecurityInterceptor           // Enforces authorization rules ← KEY
    ↓
DispatcherServlet                   // Routes to controllersController method executes

The FilterSecurityInterceptor is where authorizeHttpRequests() rules are enforced. If a request reaches this filter with authentication, the rules are checked. If the filter chain is bypassed entirely, via WebSecurityCustomizer.ignoring() the FilterSecurityInterceptor never runs. The request goes directly to the controller.

This is the architecture that makes WebSecurityCustomizer.ignoring() so dangerous. It's not a security rule with a "permit" outcome. It's a complete removal of security enforcement for matched paths.

The Two Ways to "Allow" a Request

There is a critical semantic difference between two ways to allow unauthenticated access in Spring Security:

// WAY 1: permitAll() — The request goes THROUGH security filters
// The authentication mechanism still runs
// If the user sends a token, it's still validated
// But access is permitted regardless of authentication result
http.authorizeHttpRequests(auth -> auth
    .requestMatchers("/api/public/**").permitAll()
);
// → Security filters run, authentication validated, access permitted
// → CORRECT for public endpoints

// WAY 2: web.ignoring() — The request BYPASSES ALL security filters
// No authentication processing occurs
// No CSRF protection
// No security context population
// The URL simply doesn't participate in security at all
webSecurity.ignoring().requestMatchers("/api/public/**");
// → Security filters DON'T run — request goes straight to controller
// → DANGEROUS for anything sensitive — authentication can't be enforced
// → Even if you add authentication checks to the controller,
//    the security infrastructure is completely absent
// WAY 1: permitAll() — The request goes THROUGH security filters
// The authentication mechanism still runs
// If the user sends a token, it's still validated
// But access is permitted regardless of authentication result
http.authorizeHttpRequests(auth -> auth
    .requestMatchers("/api/public/**").permitAll()
);
// → Security filters run, authentication validated, access permitted
// → CORRECT for public endpoints

// WAY 2: web.ignoring() — The request BYPASSES ALL security filters
// No authentication processing occurs
// No CSRF protection
// No security context population
// The URL simply doesn't participate in security at all
webSecurity.ignoring().requestMatchers("/api/public/**");
// → Security filters DON'T run — request goes straight to controller
// → DANGEROUS for anything sensitive — authentication can't be enforced
// → Even if you add authentication checks to the controller,
//    the security infrastructure is completely absent
// WAY 1: permitAll() — The request goes THROUGH security filters
// The authentication mechanism still runs
// If the user sends a token, it's still validated
// But access is permitted regardless of authentication result
http.authorizeHttpRequests(auth -> auth
    .requestMatchers("/api/public/**").permitAll()
);
// → Security filters run, authentication validated, access permitted
// → CORRECT for public endpoints

// WAY 2: web.ignoring() — The request BYPASSES ALL security filters
// No authentication processing occurs
// No CSRF protection
// No security context population
// The URL simply doesn't participate in security at all
webSecurity.ignoring().requestMatchers("/api/public/**");
// → Security filters DON'T run — request goes straight to controller
// → DANGEROUS for anything sensitive — authentication can't be enforced
// → Even if you add authentication checks to the controller,
//    the security infrastructure is completely absent

This distinction is the source of the most consistently critical Spring Security finding in penetration testing: developers using web.ignoring() thinking it's equivalent to permitAll(), when it actually creates a completely unprotected namespace.

Bypass Pattern 1: WebSecurityCustomizer.ignoring(): The Silent Namespace Bypass

The Vulnerability

This is the most impactful and most consistently found Spring Security misconfiguration. A WebSecurityCustomizer bean that calls .ignoring() removes matched URL patterns from ALL security processing, completely, unconditionally.

// VULNERABLE configuration — common in production Spring Boot applications

@Configuration
@EnableWebSecurity
public class SecurityConfig {

    // Configuration 1: The filter chain with rules
    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http
            .authorizeHttpRequests(auth -> auth
                .requestMatchers("/api/public/**").permitAll()
                .requestMatchers("/api/admin/**").hasRole("ADMIN")
                .requestMatchers("/api/v1/**").authenticated()
                .anyRequest().authenticated()
            )
            .oauth2ResourceServer(oauth2 -> oauth2.jwt(Customizer.withDefaults()));

        return http.build();
    }

    // Configuration 2: THE VULNERABILITY
    // This completely removes /api/v2/** from security processing
    // The rules in securityFilterChain() don't apply to /api/v2/
    // Including the .requestMatchers("/api/admin/**").hasRole("ADMIN") rule
    @Bean
    public WebSecurityCustomizer webSecurityCustomizer() {
        return (web) -> web.ignoring()
            .requestMatchers(
                "/api/v2/**",           // ← EVERYTHING under /api/v2/ is PUBLIC
                "/actuator/health",     // Fine for health check
                "/swagger-ui/**",       // Fine for docs (in non-prod)
                "/v3/api-docs/**"       // Fine for OpenAPI spec
            );
    }
}
// VULNERABLE configuration — common in production Spring Boot applications

@Configuration
@EnableWebSecurity
public class SecurityConfig {

    // Configuration 1: The filter chain with rules
    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http
            .authorizeHttpRequests(auth -> auth
                .requestMatchers("/api/public/**").permitAll()
                .requestMatchers("/api/admin/**").hasRole("ADMIN")
                .requestMatchers("/api/v1/**").authenticated()
                .anyRequest().authenticated()
            )
            .oauth2ResourceServer(oauth2 -> oauth2.jwt(Customizer.withDefaults()));

        return http.build();
    }

    // Configuration 2: THE VULNERABILITY
    // This completely removes /api/v2/** from security processing
    // The rules in securityFilterChain() don't apply to /api/v2/
    // Including the .requestMatchers("/api/admin/**").hasRole("ADMIN") rule
    @Bean
    public WebSecurityCustomizer webSecurityCustomizer() {
        return (web) -> web.ignoring()
            .requestMatchers(
                "/api/v2/**",           // ← EVERYTHING under /api/v2/ is PUBLIC
                "/actuator/health",     // Fine for health check
                "/swagger-ui/**",       // Fine for docs (in non-prod)
                "/v3/api-docs/**"       // Fine for OpenAPI spec
            );
    }
}
// VULNERABLE configuration — common in production Spring Boot applications

@Configuration
@EnableWebSecurity
public class SecurityConfig {

    // Configuration 1: The filter chain with rules
    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http
            .authorizeHttpRequests(auth -> auth
                .requestMatchers("/api/public/**").permitAll()
                .requestMatchers("/api/admin/**").hasRole("ADMIN")
                .requestMatchers("/api/v1/**").authenticated()
                .anyRequest().authenticated()
            )
            .oauth2ResourceServer(oauth2 -> oauth2.jwt(Customizer.withDefaults()));

        return http.build();
    }

    // Configuration 2: THE VULNERABILITY
    // This completely removes /api/v2/** from security processing
    // The rules in securityFilterChain() don't apply to /api/v2/
    // Including the .requestMatchers("/api/admin/**").hasRole("ADMIN") rule
    @Bean
    public WebSecurityCustomizer webSecurityCustomizer() {
        return (web) -> web.ignoring()
            .requestMatchers(
                "/api/v2/**",           // ← EVERYTHING under /api/v2/ is PUBLIC
                "/actuator/health",     // Fine for health check
                "/swagger-ui/**",       // Fine for docs (in non-prod)
                "/v3/api-docs/**"       // Fine for OpenAPI spec
            );
    }
}

What the Attacker Finds

With web.ignoring() applied to /api/v2/**:

# Testing: is /api/v2/ actually bypassed?

# Step 1: Try an admin endpoint under /api/v1/ — correctly protected
GET /api/v1/admin/users HTTP/1.1
Host: api.company.com
# No Authorization header

HTTP/1.1 401 Unauthorized
# Correctly rejected

# Step 2: Try the same functionality under /api/v2/ — bypassed
GET /api/v2/admin/users HTTP/1.1
Host: api.company.com
# No Authorization header

HTTP/1.1 200 OK
Content-Type: application/json
{"users": [{"id": 1, "email": "admin@company.com", "role": "SUPERADMIN"}, ...]

# Testing: is /api/v2/ actually bypassed?

# Step 1: Try an admin endpoint under /api/v1/ — correctly protected
GET /api/v1/admin/users HTTP/1.1
Host: api.company.com
# No Authorization header

HTTP/1.1 401 Unauthorized
# Correctly rejected

# Step 2: Try the same functionality under /api/v2/ — bypassed
GET /api/v2/admin/users HTTP/1.1
Host: api.company.com
# No Authorization header

HTTP/1.1 200 OK
Content-Type: application/json
{"users": [{"id": 1, "email": "admin@company.com", "role": "SUPERADMIN"}, ...]

# Testing: is /api/v2/ actually bypassed?

# Step 1: Try an admin endpoint under /api/v1/ — correctly protected
GET /api/v1/admin/users HTTP/1.1
Host: api.company.com
# No Authorization header

HTTP/1.1 401 Unauthorized
# Correctly rejected

# Step 2: Try the same functionality under /api/v2/ — bypassed
GET /api/v2/admin/users HTTP/1.1
Host: api.company.com
# No Authorization header

HTTP/1.1 200 OK
Content-Type: application/json
{"users": [{"id": 1, "email": "admin@company.com", "role": "SUPERADMIN"}, ...]

How to Find This During Testing

The attack surface for web.ignoring() bypasses requires knowing what URLs are excluded. In a white box engagement, this comes from reading the configuration. In a black box engagement:

# Black box discovery of web.ignoring() exclusions:

import requests
import json

TARGET = "<https://api.company.com>"

def discover_ignored_paths(base_urls, auth_headers):
    """
    Discover URL patterns that bypass Spring Security
    by testing common exclusion patterns without credentials.
    """

    # Common patterns used in web.ignoring() configurations
    candidate_paths = [
        # Version namespace bypasses
        "/api/v2/",
        "/api/v2/admin/users",
        "/api/v2/data/export",
        "/api/v2/internal/config",

        # Actuator endpoints
        "/actuator",
        "/actuator/env",          # Exposes ALL environment variables
        "/actuator/heapdump",     # JVM heap dump — contains secrets in memory
        "/actuator/mappings",     # All route mappings
        "/actuator/beans",        # All Spring beans
        "/actuator/configprops",  # All configuration properties

        # Common exclusion targets
        "/api/internal/",
        "/api/management/",
        "/management/",
        "/metrics/",
        "/health/",

        # Documentation endpoints that get excluded
        "/swagger-ui/",
        "/swagger-ui.html",
        "/v2/api-docs",
        "/v3/api-docs",
        "/graphql",              # Sometimes excluded for IDE tooling
    ]

    excluded_paths = []

    for path in candidate_paths:
        # Test without any authentication
        unauthenticated_response = requests.get(
            f"{TARGET}{path}",
            timeout=10,
            allow_redirects=False
        )

        # Test WITH valid authentication
        authenticated_response = requests.get(
            f"{TARGET}{path}",
            headers=auth_headers,
            timeout=10,
            allow_redirects=False
        )

        if unauthenticated_response.status_code == 200:
            excluded_paths.append({
                'path': path,
                'unauthenticated_status': unauthenticated_response.status_code,
                'response_size': len(unauthenticated_response.content),
                'finding': 'Accessible without authentication',
                'severity': assess_path_severity(path, unauthenticated_response)
            })

    return excluded_paths

def assess_path_severity(path, response):
    critical_indicators = ['admin', 'export', 'users', 'config', 'heapdump', 'env']
    high_indicators = ['actuator', 'management', 'internal', 'metrics']

    if any(ind in path.lower() for ind in critical_indicators):
        return 'CRITICAL'
    elif any(ind in path.lower() for ind in high_indicators):
        return 'HIGH'
    elif len(response.content) > 1000:
        return 'MEDIUM'
    return 'LOW'
# Black box discovery of web.ignoring() exclusions:

import requests
import json

TARGET = "<https://api.company.com>"

def discover_ignored_paths(base_urls, auth_headers):
    """
    Discover URL patterns that bypass Spring Security
    by testing common exclusion patterns without credentials.
    """

    # Common patterns used in web.ignoring() configurations
    candidate_paths = [
        # Version namespace bypasses
        "/api/v2/",
        "/api/v2/admin/users",
        "/api/v2/data/export",
        "/api/v2/internal/config",

        # Actuator endpoints
        "/actuator",
        "/actuator/env",          # Exposes ALL environment variables
        "/actuator/heapdump",     # JVM heap dump — contains secrets in memory
        "/actuator/mappings",     # All route mappings
        "/actuator/beans",        # All Spring beans
        "/actuator/configprops",  # All configuration properties

        # Common exclusion targets
        "/api/internal/",
        "/api/management/",
        "/management/",
        "/metrics/",
        "/health/",

        # Documentation endpoints that get excluded
        "/swagger-ui/",
        "/swagger-ui.html",
        "/v2/api-docs",
        "/v3/api-docs",
        "/graphql",              # Sometimes excluded for IDE tooling
    ]

    excluded_paths = []

    for path in candidate_paths:
        # Test without any authentication
        unauthenticated_response = requests.get(
            f"{TARGET}{path}",
            timeout=10,
            allow_redirects=False
        )

        # Test WITH valid authentication
        authenticated_response = requests.get(
            f"{TARGET}{path}",
            headers=auth_headers,
            timeout=10,
            allow_redirects=False
        )

        if unauthenticated_response.status_code == 200:
            excluded_paths.append({
                'path': path,
                'unauthenticated_status': unauthenticated_response.status_code,
                'response_size': len(unauthenticated_response.content),
                'finding': 'Accessible without authentication',
                'severity': assess_path_severity(path, unauthenticated_response)
            })

    return excluded_paths

def assess_path_severity(path, response):
    critical_indicators = ['admin', 'export', 'users', 'config', 'heapdump', 'env']
    high_indicators = ['actuator', 'management', 'internal', 'metrics']

    if any(ind in path.lower() for ind in critical_indicators):
        return 'CRITICAL'
    elif any(ind in path.lower() for ind in high_indicators):
        return 'HIGH'
    elif len(response.content) > 1000:
        return 'MEDIUM'
    return 'LOW'
# Black box discovery of web.ignoring() exclusions:

import requests
import json

TARGET = "<https://api.company.com>"

def discover_ignored_paths(base_urls, auth_headers):
    """
    Discover URL patterns that bypass Spring Security
    by testing common exclusion patterns without credentials.
    """

    # Common patterns used in web.ignoring() configurations
    candidate_paths = [
        # Version namespace bypasses
        "/api/v2/",
        "/api/v2/admin/users",
        "/api/v2/data/export",
        "/api/v2/internal/config",

        # Actuator endpoints
        "/actuator",
        "/actuator/env",          # Exposes ALL environment variables
        "/actuator/heapdump",     # JVM heap dump — contains secrets in memory
        "/actuator/mappings",     # All route mappings
        "/actuator/beans",        # All Spring beans
        "/actuator/configprops",  # All configuration properties

        # Common exclusion targets
        "/api/internal/",
        "/api/management/",
        "/management/",
        "/metrics/",
        "/health/",

        # Documentation endpoints that get excluded
        "/swagger-ui/",
        "/swagger-ui.html",
        "/v2/api-docs",
        "/v3/api-docs",
        "/graphql",              # Sometimes excluded for IDE tooling
    ]

    excluded_paths = []

    for path in candidate_paths:
        # Test without any authentication
        unauthenticated_response = requests.get(
            f"{TARGET}{path}",
            timeout=10,
            allow_redirects=False
        )

        # Test WITH valid authentication
        authenticated_response = requests.get(
            f"{TARGET}{path}",
            headers=auth_headers,
            timeout=10,
            allow_redirects=False
        )

        if unauthenticated_response.status_code == 200:
            excluded_paths.append({
                'path': path,
                'unauthenticated_status': unauthenticated_response.status_code,
                'response_size': len(unauthenticated_response.content),
                'finding': 'Accessible without authentication',
                'severity': assess_path_severity(path, unauthenticated_response)
            })

    return excluded_paths

def assess_path_severity(path, response):
    critical_indicators = ['admin', 'export', 'users', 'config', 'heapdump', 'env']
    high_indicators = ['actuator', 'management', 'internal', 'metrics']

    if any(ind in path.lower() for ind in critical_indicators):
        return 'CRITICAL'
    elif any(ind in path.lower() for ind in high_indicators):
        return 'HIGH'
    elif len(response.content) > 1000:
        return 'MEDIUM'
    return 'LOW'

The Fix

// SECURE: Use permitAll() instead of web.ignoring() for truly public endpoints
// NEVER use web.ignoring() for anything that might need authentication context

@Configuration
@EnableWebSecurity
public class SecurityConfig {

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http
            .authorizeHttpRequests(auth -> auth
                // Explicitly public — still goes through security filters
                .requestMatchers("/api/public/**").permitAll()
                .requestMatchers("/actuator/health").permitAll()
                .requestMatchers("/actuator/info").permitAll()

                // API versioning — apply SAME rules to all versions
                .requestMatchers("/api/v1/admin/**", "/api/v2/admin/**").hasRole("ADMIN")
                .requestMatchers("/api/v1/**", "/api/v2/**").authenticated()

                // Catch-all
                .anyRequest().authenticated()
            )
            .oauth2ResourceServer(oauth2 -> oauth2.jwt(Customizer.withDefaults()));

        return http.build();
    }

    // Only use web.ignoring() for truly static resources that can NEVER
    // contain sensitive data and don't need authentication context
    @Bean
    public WebSecurityCustomizer webSecurityCustomizer() {
        return (web) -> web.ignoring()
            .requestMatchers(
                "/static/**",      // CSS, JS, images
                "/favicon.ico"     // Browser default
                // NEVER: /api/**, /actuator/**, /admin/**
            );
    }
}
// SECURE: Use permitAll() instead of web.ignoring() for truly public endpoints
// NEVER use web.ignoring() for anything that might need authentication context

@Configuration
@EnableWebSecurity
public class SecurityConfig {

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http
            .authorizeHttpRequests(auth -> auth
                // Explicitly public — still goes through security filters
                .requestMatchers("/api/public/**").permitAll()
                .requestMatchers("/actuator/health").permitAll()
                .requestMatchers("/actuator/info").permitAll()

                // API versioning — apply SAME rules to all versions
                .requestMatchers("/api/v1/admin/**", "/api/v2/admin/**").hasRole("ADMIN")
                .requestMatchers("/api/v1/**", "/api/v2/**").authenticated()

                // Catch-all
                .anyRequest().authenticated()
            )
            .oauth2ResourceServer(oauth2 -> oauth2.jwt(Customizer.withDefaults()));

        return http.build();
    }

    // Only use web.ignoring() for truly static resources that can NEVER
    // contain sensitive data and don't need authentication context
    @Bean
    public WebSecurityCustomizer webSecurityCustomizer() {
        return (web) -> web.ignoring()
            .requestMatchers(
                "/static/**",      // CSS, JS, images
                "/favicon.ico"     // Browser default
                // NEVER: /api/**, /actuator/**, /admin/**
            );
    }
}
// SECURE: Use permitAll() instead of web.ignoring() for truly public endpoints
// NEVER use web.ignoring() for anything that might need authentication context

@Configuration
@EnableWebSecurity
public class SecurityConfig {

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http
            .authorizeHttpRequests(auth -> auth
                // Explicitly public — still goes through security filters
                .requestMatchers("/api/public/**").permitAll()
                .requestMatchers("/actuator/health").permitAll()
                .requestMatchers("/actuator/info").permitAll()

                // API versioning — apply SAME rules to all versions
                .requestMatchers("/api/v1/admin/**", "/api/v2/admin/**").hasRole("ADMIN")
                .requestMatchers("/api/v1/**", "/api/v2/**").authenticated()

                // Catch-all
                .anyRequest().authenticated()
            )
            .oauth2ResourceServer(oauth2 -> oauth2.jwt(Customizer.withDefaults()));

        return http.build();
    }

    // Only use web.ignoring() for truly static resources that can NEVER
    // contain sensitive data and don't need authentication context
    @Bean
    public WebSecurityCustomizer webSecurityCustomizer() {
        return (web) -> web.ignoring()
            .requestMatchers(
                "/static/**",      // CSS, JS, images
                "/favicon.ico"     // Browser default
                // NEVER: /api/**, /actuator/**, /admin/**
            );
    }
}

Bypass Pattern 2: antMatchers() URL Pattern Matching Gaps

The Vulnerability: Wildcard Semantics

Spring Security's URL pattern matching has specific semantics that catch developers off guard. The ** wildcard matches any number of path segments, but only when used correctly.

// VULNERABLE: Wildcard doesn't cover what the developer intended

http.authorizeHttpRequests(auth -> auth
    // Developer intends: protect all admin URLs
    // What this actually matches: /api/admin followed by EXACTLY ONE segment
    .requestMatchers("/api/admin/*").hasRole("ADMIN")

    // What it DOES protect:
    // /api/admin/users  ✓
    // /api/admin/settings  ✓

    // What it DOES NOT protect (bypasses):
    // /api/admin/users/export  ✗ (two segments after /admin/)
    // /api/admin/users/1/delete  ✗ (three segments)
    // /api/admin/  ✗ (trailing slash, zero segments)
);
// VULNERABLE: Wildcard doesn't cover what the developer intended

http.authorizeHttpRequests(auth -> auth
    // Developer intends: protect all admin URLs
    // What this actually matches: /api/admin followed by EXACTLY ONE segment
    .requestMatchers("/api/admin/*").hasRole("ADMIN")

    // What it DOES protect:
    // /api/admin/users  ✓
    // /api/admin/settings  ✓

    // What it DOES NOT protect (bypasses):
    // /api/admin/users/export  ✗ (two segments after /admin/)
    // /api/admin/users/1/delete  ✗ (three segments)
    // /api/admin/  ✗ (trailing slash, zero segments)
);
// VULNERABLE: Wildcard doesn't cover what the developer intended

http.authorizeHttpRequests(auth -> auth
    // Developer intends: protect all admin URLs
    // What this actually matches: /api/admin followed by EXACTLY ONE segment
    .requestMatchers("/api/admin/*").hasRole("ADMIN")

    // What it DOES protect:
    // /api/admin/users  ✓
    // /api/admin/settings  ✓

    // What it DOES NOT protect (bypasses):
    // /api/admin/users/export  ✗ (two segments after /admin/)
    // /api/admin/users/1/delete  ✗ (three segments)
    // /api/admin/  ✗ (trailing slash, zero segments)
);

The correct wildcard for "all paths under /api/admin/":

// SECURE: Double wildcard covers all sub-paths
.requestMatchers("/api/admin/**").hasRole("ADMIN")

// What /** matches:
// /api/admin  ✓
// /api/admin/  ✓
// /api/admin/users  ✓
// /api/admin/users/export  ✓
// /api/admin/users/1/delete  ✓
// /api/admin/anything/nested/deeply  ✓
// SECURE: Double wildcard covers all sub-paths
.requestMatchers("/api/admin/**").hasRole("ADMIN")

// What /** matches:
// /api/admin  ✓
// /api/admin/  ✓
// /api/admin/users  ✓
// /api/admin/users/export  ✓
// /api/admin/users/1/delete  ✓
// /api/admin/anything/nested/deeply  ✓
// SECURE: Double wildcard covers all sub-paths
.requestMatchers("/api/admin/**").hasRole("ADMIN")

// What /** matches:
// /api/admin  ✓
// /api/admin/  ✓
// /api/admin/users  ✓
// /api/admin/users/export  ✓
// /api/admin/users/1/delete  ✓
// /api/admin/anything/nested/deeply  ✓

Testing Wildcard Gaps

def test_wildcard_gaps(base_url, protected_path, auth_headers):
    """
    Test for URL pattern matching gaps in Spring Security configuration.
    If /api/admin/* is used instead of /api/admin/**, deep paths bypass security.
    """

    # Generate test paths at increasing depth
    base = protected_path.rstrip('/')
    test_paths = [
        f"{base}",                          # Base path
        f"{base}/",                         # Trailing slash
        f"{base}/resource",                 # Single segment
        f"{base}/resource/",                # Single segment + trailing slash
        f"{base}/resource/action",          # Two segments
        f"{base}/resource/1",               # Segment with ID
        f"{base}/resource/1/details",       # Three segments
        f"{base}/resource/1/action/confirm",# Four segments
        f"{base}/..;/resource",             # Path traversal variant
        f"{base}/%2F/resource",             # URL-encoded slash
        f"{base}//resource",                # Double slash
    ]

    findings = []

    for path in test_paths:
        # Test without authentication
        response = requests.get(
            f"{base_url}{path}",
            allow_redirects=False,
            timeout=10
        )

        if response.status_code not in [401, 403]:
            # Test WITH authentication to confirm this is a real endpoint
            auth_response = requests.get(
                f"{base_url}{path}",
                headers=auth_headers,
                allow_redirects=False,
                timeout=10
            )

            if auth_response.status_code == 200:
                findings.append({
                    'path': path,
                    'unauth_status': response.status_code,
                    'auth_status': auth_response.status_code,
                    'finding': 'Path bypasses authorization rule',
                    'likely_cause': 'Single wildcard (*) instead of double (**)',
                    'response_preview': response.text[:200]
                })

    return findings
def test_wildcard_gaps(base_url, protected_path, auth_headers):
    """
    Test for URL pattern matching gaps in Spring Security configuration.
    If /api/admin/* is used instead of /api/admin/**, deep paths bypass security.
    """

    # Generate test paths at increasing depth
    base = protected_path.rstrip('/')
    test_paths = [
        f"{base}",                          # Base path
        f"{base}/",                         # Trailing slash
        f"{base}/resource",                 # Single segment
        f"{base}/resource/",                # Single segment + trailing slash
        f"{base}/resource/action",          # Two segments
        f"{base}/resource/1",               # Segment with ID
        f"{base}/resource/1/details",       # Three segments
        f"{base}/resource/1/action/confirm",# Four segments
        f"{base}/..;/resource",             # Path traversal variant
        f"{base}/%2F/resource",             # URL-encoded slash
        f"{base}//resource",                # Double slash
    ]

    findings = []

    for path in test_paths:
        # Test without authentication
        response = requests.get(
            f"{base_url}{path}",
            allow_redirects=False,
            timeout=10
        )

        if response.status_code not in [401, 403]:
            # Test WITH authentication to confirm this is a real endpoint
            auth_response = requests.get(
                f"{base_url}{path}",
                headers=auth_headers,
                allow_redirects=False,
                timeout=10
            )

            if auth_response.status_code == 200:
                findings.append({
                    'path': path,
                    'unauth_status': response.status_code,
                    'auth_status': auth_response.status_code,
                    'finding': 'Path bypasses authorization rule',
                    'likely_cause': 'Single wildcard (*) instead of double (**)',
                    'response_preview': response.text[:200]
                })

    return findings
def test_wildcard_gaps(base_url, protected_path, auth_headers):
    """
    Test for URL pattern matching gaps in Spring Security configuration.
    If /api/admin/* is used instead of /api/admin/**, deep paths bypass security.
    """

    # Generate test paths at increasing depth
    base = protected_path.rstrip('/')
    test_paths = [
        f"{base}",                          # Base path
        f"{base}/",                         # Trailing slash
        f"{base}/resource",                 # Single segment
        f"{base}/resource/",                # Single segment + trailing slash
        f"{base}/resource/action",          # Two segments
        f"{base}/resource/1",               # Segment with ID
        f"{base}/resource/1/details",       # Three segments
        f"{base}/resource/1/action/confirm",# Four segments
        f"{base}/..;/resource",             # Path traversal variant
        f"{base}/%2F/resource",             # URL-encoded slash
        f"{base}//resource",                # Double slash
    ]

    findings = []

    for path in test_paths:
        # Test without authentication
        response = requests.get(
            f"{base_url}{path}",
            allow_redirects=False,
            timeout=10
        )

        if response.status_code not in [401, 403]:
            # Test WITH authentication to confirm this is a real endpoint
            auth_response = requests.get(
                f"{base_url}{path}",
                headers=auth_headers,
                allow_redirects=False,
                timeout=10
            )

            if auth_response.status_code == 200:
                findings.append({
                    'path': path,
                    'unauth_status': response.status_code,
                    'auth_status': auth_response.status_code,
                    'finding': 'Path bypasses authorization rule',
                    'likely_cause': 'Single wildcard (*) instead of double (**)',
                    'response_preview': response.text[:200]
                })

    return findings

Path Traversal Bypasses in URL Matching

// VULNERABLE: Path normalization differences between Tomcat and Spring Security

// Spring Security checks: /api/admin/..;/users
// Tomcat normalizes to: /api/users
// Result: /api/users is requested but Spring Security evaluated /api/admin/..;/users
// If /api/admin/** is protected but /api/users is public → BYPASS

// Test variations:
bypass_variants = [
    "/api/admin/..;/users",           # Semicolon path traversal
    "/api/admin/%2F../users",         # URL-encoded slash traversal
    "/api/admin/./users",             # Current directory
    "/api/admin/%252F../users",       # Double-encoded slash
    "/api/users/../../admin/users",   # Up-directory traversal
    "//api/admin/users",              # Double leading slash
    "/api//admin/users",              # Double slash in path
]

# Spring Boot 3.x (Spring Security 6.x) addressed many of these
# Spring Boot 2.x applications are more susceptible
# Always test the specific Spring Boot version in scope
// VULNERABLE: Path normalization differences between Tomcat and Spring Security

// Spring Security checks: /api/admin/..;/users
// Tomcat normalizes to: /api/users
// Result: /api/users is requested but Spring Security evaluated /api/admin/..;/users
// If /api/admin/** is protected but /api/users is public → BYPASS

// Test variations:
bypass_variants = [
    "/api/admin/..;/users",           # Semicolon path traversal
    "/api/admin/%2F../users",         # URL-encoded slash traversal
    "/api/admin/./users",             # Current directory
    "/api/admin/%252F../users",       # Double-encoded slash
    "/api/users/../../admin/users",   # Up-directory traversal
    "//api/admin/users",              # Double leading slash
    "/api//admin/users",              # Double slash in path
]

# Spring Boot 3.x (Spring Security 6.x) addressed many of these
# Spring Boot 2.x applications are more susceptible
# Always test the specific Spring Boot version in scope
// VULNERABLE: Path normalization differences between Tomcat and Spring Security

// Spring Security checks: /api/admin/..;/users
// Tomcat normalizes to: /api/users
// Result: /api/users is requested but Spring Security evaluated /api/admin/..;/users
// If /api/admin/** is protected but /api/users is public → BYPASS

// Test variations:
bypass_variants = [
    "/api/admin/..;/users",           # Semicolon path traversal
    "/api/admin/%2F../users",         # URL-encoded slash traversal
    "/api/admin/./users",             # Current directory
    "/api/admin/%252F../users",       # Double-encoded slash
    "/api/users/../../admin/users",   # Up-directory traversal
    "//api/admin/users",              # Double leading slash
    "/api//admin/users",              # Double slash in path
]

# Spring Boot 3.x (Spring Security 6.x) addressed many of these
# Spring Boot 2.x applications are more susceptible
# Always test the specific Spring Boot version in scope

Bypass Pattern 3: @PreAuthorize and Method Security Failures

The Inheritance Problem

Method-level security annotations (@PreAuthorize, @Secured, @RolesAllowed) have an inheritance problem: they protect the annotated method but not overrides of that method in subclasses.

// Base controller — protected
@RestController
public class BaseAdminController {

    @GetMapping("/api/admin/users")
    @PreAuthorize("hasRole('ADMIN')")  // ← Protected
    public ResponseEntity<List<User>> getUsers() {
        return ResponseEntity.ok(userService.getAllUsers());
    }
}

// Subclass controller — VULNERABLE
// The @PreAuthorize annotation is NOT inherited by overrides
@RestController
public class ExtendedAdminController extends BaseAdminController {

    @Override
    @GetMapping("/api/v2/admin/users")  // Different mapping — new endpoint
    public ResponseEntity<List<User>> getUsers() {
        // @PreAuthorize from parent NOT applied here
        // This method is publicly accessible
        return ResponseEntity.ok(userService.getAllUsers());
    }
}
// Base controller — protected
@RestController
public class BaseAdminController {

    @GetMapping("/api/admin/users")
    @PreAuthorize("hasRole('ADMIN')")  // ← Protected
    public ResponseEntity<List<User>> getUsers() {
        return ResponseEntity.ok(userService.getAllUsers());
    }
}

// Subclass controller — VULNERABLE
// The @PreAuthorize annotation is NOT inherited by overrides
@RestController
public class ExtendedAdminController extends BaseAdminController {

    @Override
    @GetMapping("/api/v2/admin/users")  // Different mapping — new endpoint
    public ResponseEntity<List<User>> getUsers() {
        // @PreAuthorize from parent NOT applied here
        // This method is publicly accessible
        return ResponseEntity.ok(userService.getAllUsers());
    }
}
// Base controller — protected
@RestController
public class BaseAdminController {

    @GetMapping("/api/admin/users")
    @PreAuthorize("hasRole('ADMIN')")  // ← Protected
    public ResponseEntity<List<User>> getUsers() {
        return ResponseEntity.ok(userService.getAllUsers());
    }
}

// Subclass controller — VULNERABLE
// The @PreAuthorize annotation is NOT inherited by overrides
@RestController
public class ExtendedAdminController extends BaseAdminController {

    @Override
    @GetMapping("/api/v2/admin/users")  // Different mapping — new endpoint
    public ResponseEntity<List<User>> getUsers() {
        // @PreAuthorize from parent NOT applied here
        // This method is publicly accessible
        return ResponseEntity.ok(userService.getAllUsers());
    }
}

The fix: annotations must be present on every method that needs protection, including overrides:

// SECURE: Annotate the override explicitly
@RestController
public class ExtendedAdminController extends BaseAdminController {

    @Override
    @GetMapping("/api/v2/admin/users")
    @PreAuthorize("hasRole('ADMIN')")  // ← Must be repeated on the override
    public ResponseEntity<List<User>> getUsers() {
        return ResponseEntity.ok(userService.getAllUsers());
    }
}
// SECURE: Annotate the override explicitly
@RestController
public class ExtendedAdminController extends BaseAdminController {

    @Override
    @GetMapping("/api/v2/admin/users")
    @PreAuthorize("hasRole('ADMIN')")  // ← Must be repeated on the override
    public ResponseEntity<List<User>> getUsers() {
        return ResponseEntity.ok(userService.getAllUsers());
    }
}
// SECURE: Annotate the override explicitly
@RestController
public class ExtendedAdminController extends BaseAdminController {

    @Override
    @GetMapping("/api/v2/admin/users")
    @PreAuthorize("hasRole('ADMIN')")  // ← Must be repeated on the override
    public ResponseEntity<List<User>> getUsers() {
        return ResponseEntity.ok(userService.getAllUsers());
    }
}

The SpEL Expression Bypass

Spring Security's @PreAuthorize uses Spring Expression Language (SpEL). Complex SpEL expressions can be bypassed through logic errors:

// VULNERABLE: Logic error in SpEL expression

@GetMapping("/api/reports/{id}")
@PreAuthorize(
    "hasRole('ADMIN') or " +
    "(hasRole('USER') and #reportId == authentication.principal.reportId)"
)
// Developer intends: Admin can see all reports, user can see their own
// The 'or' short-circuits: if user has ADMIN role, the whole expression is true
// BUT: the reportId comparison is between #reportId (Long) and
//      authentication.principal.reportId (String) → type mismatch → always false?
// No — Spring auto-converts. But what if reportId is null?
// null == null → TRUE → any user can access reports where reportId is null

public ResponseEntity<Report> getReport(@PathVariable Long reportId) {
    return ResponseEntity.ok(reportService.getReport(reportId));
}

// Testing the SpEL bypass:
GET /api/reports/null HTTP/1.1
GET /api/reports/0 HTTP/1.1
GET /api/reports/-1 HTTP/1.1
// Test edge cases in the expression
// VULNERABLE: Logic error in SpEL expression

@GetMapping("/api/reports/{id}")
@PreAuthorize(
    "hasRole('ADMIN') or " +
    "(hasRole('USER') and #reportId == authentication.principal.reportId)"
)
// Developer intends: Admin can see all reports, user can see their own
// The 'or' short-circuits: if user has ADMIN role, the whole expression is true
// BUT: the reportId comparison is between #reportId (Long) and
//      authentication.principal.reportId (String) → type mismatch → always false?
// No — Spring auto-converts. But what if reportId is null?
// null == null → TRUE → any user can access reports where reportId is null

public ResponseEntity<Report> getReport(@PathVariable Long reportId) {
    return ResponseEntity.ok(reportService.getReport(reportId));
}

// Testing the SpEL bypass:
GET /api/reports/null HTTP/1.1
GET /api/reports/0 HTTP/1.1
GET /api/reports/-1 HTTP/1.1
// Test edge cases in the expression
// VULNERABLE: Logic error in SpEL expression

@GetMapping("/api/reports/{id}")
@PreAuthorize(
    "hasRole('ADMIN') or " +
    "(hasRole('USER') and #reportId == authentication.principal.reportId)"
)
// Developer intends: Admin can see all reports, user can see their own
// The 'or' short-circuits: if user has ADMIN role, the whole expression is true
// BUT: the reportId comparison is between #reportId (Long) and
//      authentication.principal.reportId (String) → type mismatch → always false?
// No — Spring auto-converts. But what if reportId is null?
// null == null → TRUE → any user can access reports where reportId is null

public ResponseEntity<Report> getReport(@PathVariable Long reportId) {
    return ResponseEntity.ok(reportService.getReport(reportId));
}

// Testing the SpEL bypass:
GET /api/reports/null HTTP/1.1
GET /api/reports/0 HTTP/1.1
GET /api/reports/-1 HTTP/1.1
// Test edge cases in the expression

Testing @PreAuthorize Coverage

In a white box engagement, the AI scans every controller method for annotation presence:

// What the analysis looks for:

// Pattern 1: Controller class with @PreAuthorize on class level
// but individual methods that override with weaker/no annotation
@RestController
@PreAuthorize("isAuthenticated()")  // Class-level: all methods need auth
public class UserController {

    @GetMapping("/api/users/{id}")
    // No annotation here — INHERITS from class level ✓
    public ResponseEntity<User> getUser(@PathVariable Long id) { ... }

    @GetMapping("/api/users/export")
    @PreAuthorize("permitAll()")  // ← Overrides class-level! Now public!
    public ResponseEntity<byte[]> exportUsers() { ... }
}

// Pattern 2: @Secured with role name vs hasRole() mismatch
@Secured("ADMIN")          // Uses the raw role name — requires "ADMIN"
@PreAuthorize("hasRole('ADMIN')")  // hasRole() adds "ROLE_" prefix — requires "ROLE_ADMIN"
// These are NOT equivalent if roles aren't stored with "ROLE_" prefix

// Pattern 3: @PreAuthorize on abstract method, concrete method unprotected
public abstract class BaseController {
    @PreAuthorize("hasRole('ADMIN')")
    public abstract ResponseEntity<?> sensitiveOperation();
}

public class ConcreteController extends BaseController {
    @Override
    // @PreAuthorize NOT inherited — this method is unprotected
    public ResponseEntity<?> sensitiveOperation() {
        return performAdminOperation();
    }
}
// What the analysis looks for:

// Pattern 1: Controller class with @PreAuthorize on class level
// but individual methods that override with weaker/no annotation
@RestController
@PreAuthorize("isAuthenticated()")  // Class-level: all methods need auth
public class UserController {

    @GetMapping("/api/users/{id}")
    // No annotation here — INHERITS from class level ✓
    public ResponseEntity<User> getUser(@PathVariable Long id) { ... }

    @GetMapping("/api/users/export")
    @PreAuthorize("permitAll()")  // ← Overrides class-level! Now public!
    public ResponseEntity<byte[]> exportUsers() { ... }
}

// Pattern 2: @Secured with role name vs hasRole() mismatch
@Secured("ADMIN")          // Uses the raw role name — requires "ADMIN"
@PreAuthorize("hasRole('ADMIN')")  // hasRole() adds "ROLE_" prefix — requires "ROLE_ADMIN"
// These are NOT equivalent if roles aren't stored with "ROLE_" prefix

// Pattern 3: @PreAuthorize on abstract method, concrete method unprotected
public abstract class BaseController {
    @PreAuthorize("hasRole('ADMIN')")
    public abstract ResponseEntity<?> sensitiveOperation();
}

public class ConcreteController extends BaseController {
    @Override
    // @PreAuthorize NOT inherited — this method is unprotected
    public ResponseEntity<?> sensitiveOperation() {
        return performAdminOperation();
    }
}
// What the analysis looks for:

// Pattern 1: Controller class with @PreAuthorize on class level
// but individual methods that override with weaker/no annotation
@RestController
@PreAuthorize("isAuthenticated()")  // Class-level: all methods need auth
public class UserController {

    @GetMapping("/api/users/{id}")
    // No annotation here — INHERITS from class level ✓
    public ResponseEntity<User> getUser(@PathVariable Long id) { ... }

    @GetMapping("/api/users/export")
    @PreAuthorize("permitAll()")  // ← Overrides class-level! Now public!
    public ResponseEntity<byte[]> exportUsers() { ... }
}

// Pattern 2: @Secured with role name vs hasRole() mismatch
@Secured("ADMIN")          // Uses the raw role name — requires "ADMIN"
@PreAuthorize("hasRole('ADMIN')")  // hasRole() adds "ROLE_" prefix — requires "ROLE_ADMIN"
// These are NOT equivalent if roles aren't stored with "ROLE_" prefix

// Pattern 3: @PreAuthorize on abstract method, concrete method unprotected
public abstract class BaseController {
    @PreAuthorize("hasRole('ADMIN')")
    public abstract ResponseEntity<?> sensitiveOperation();
}

public class ConcreteController extends BaseController {
    @Override
    // @PreAuthorize NOT inherited — this method is unprotected
    public ResponseEntity<?> sensitiveOperation() {
        return performAdminOperation();
    }
}

Bypass Pattern 4: JWT Validation Failures in OAuth2 Resource Servers

The Algorithm Trust Problem

Spring Security's OAuth2 resource server configuration validates JWTs, but the validation is only as strong as the configuration. The same algorithm confusion attacks from the JWT Security Testing guide manifest in Spring Security's JWT validation when misconfigured.

// VULNERABLE: Accepting multiple algorithms — confusion attack possible

@Configuration
public class SecurityConfig {

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
            .oauth2ResourceServer(oauth2 -> oauth2
                .jwt(jwt -> jwt
                    .decoder(jwtDecoder())
                )
            );
        return http.build();
    }

    @Bean
    public JwtDecoder jwtDecoder() {
        // VULNERABLE: Accepts both RS256 and HS256
        // Attacker can use the public key as HMAC secret for HS256
        return NimbusJwtDecoder
            .withJwkSetUri("<https://auth.company.com/.well-known/jwks.json>")
            .jwsAlgorithms(algorithms -> {
                algorithms.add(MacAlgorithm.HS256);   // ← VULNERABLE
                algorithms.add(SignatureAlgorithm.RS256);
            })
            .build();
    }
}

// Also vulnerable: Not validating issuer or audience
@Bean
public JwtDecoder jwtDecoder() {
    NimbusJwtDecoder decoder = NimbusJwtDecoder
        .withJwkSetUri("<https://auth.company.com/.well-known/jwks.json>")
        .build();

    // MISSING: Issuer validation
    // MISSING: Audience validation
    // A token from ANY service using this JWKS endpoint is accepted

    return decoder;
}
// VULNERABLE: Accepting multiple algorithms — confusion attack possible

@Configuration
public class SecurityConfig {

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
            .oauth2ResourceServer(oauth2 -> oauth2
                .jwt(jwt -> jwt
                    .decoder(jwtDecoder())
                )
            );
        return http.build();
    }

    @Bean
    public JwtDecoder jwtDecoder() {
        // VULNERABLE: Accepts both RS256 and HS256
        // Attacker can use the public key as HMAC secret for HS256
        return NimbusJwtDecoder
            .withJwkSetUri("<https://auth.company.com/.well-known/jwks.json>")
            .jwsAlgorithms(algorithms -> {
                algorithms.add(MacAlgorithm.HS256);   // ← VULNERABLE
                algorithms.add(SignatureAlgorithm.RS256);
            })
            .build();
    }
}

// Also vulnerable: Not validating issuer or audience
@Bean
public JwtDecoder jwtDecoder() {
    NimbusJwtDecoder decoder = NimbusJwtDecoder
        .withJwkSetUri("<https://auth.company.com/.well-known/jwks.json>")
        .build();

    // MISSING: Issuer validation
    // MISSING: Audience validation
    // A token from ANY service using this JWKS endpoint is accepted

    return decoder;
}
// VULNERABLE: Accepting multiple algorithms — confusion attack possible

@Configuration
public class SecurityConfig {

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
            .oauth2ResourceServer(oauth2 -> oauth2
                .jwt(jwt -> jwt
                    .decoder(jwtDecoder())
                )
            );
        return http.build();
    }

    @Bean
    public JwtDecoder jwtDecoder() {
        // VULNERABLE: Accepts both RS256 and HS256
        // Attacker can use the public key as HMAC secret for HS256
        return NimbusJwtDecoder
            .withJwkSetUri("<https://auth.company.com/.well-known/jwks.json>")
            .jwsAlgorithms(algorithms -> {
                algorithms.add(MacAlgorithm.HS256);   // ← VULNERABLE
                algorithms.add(SignatureAlgorithm.RS256);
            })
            .build();
    }
}

// Also vulnerable: Not validating issuer or audience
@Bean
public JwtDecoder jwtDecoder() {
    NimbusJwtDecoder decoder = NimbusJwtDecoder
        .withJwkSetUri("<https://auth.company.com/.well-known/jwks.json>")
        .build();

    // MISSING: Issuer validation
    // MISSING: Audience validation
    // A token from ANY service using this JWKS endpoint is accepted

    return decoder;
}
// SECURE: Strict algorithm + claim validation

@Bean
public JwtDecoder jwtDecoder() {
    NimbusJwtDecoder decoder = NimbusJwtDecoder
        .withJwkSetUri("<https://auth.company.com/.well-known/jwks.json>")
        .jwsAlgorithm(SignatureAlgorithm.RS256)  // Single algorithm only
        .build();

    // Validate issuer and audience
    OAuth2TokenValidator<Jwt> issuerValidator =
        JwtValidators.createDefaultWithIssuer("<https://auth.company.com>");

    OAuth2TokenValidator<Jwt> audienceValidator = new JwtClaimValidator<>(
        JwtClaimNames.AUD,
        aud -> aud != null && aud.contains("<https://api.company.com>")
    );

    OAuth2TokenValidator<Jwt> combinedValidator = new DelegatingOAuth2TokenValidator<>(
        issuerValidator,
        audienceValidator
    );

    decoder.setJwtValidator(combinedValidator);

    return decoder;
}
// SECURE: Strict algorithm + claim validation

@Bean
public JwtDecoder jwtDecoder() {
    NimbusJwtDecoder decoder = NimbusJwtDecoder
        .withJwkSetUri("<https://auth.company.com/.well-known/jwks.json>")
        .jwsAlgorithm(SignatureAlgorithm.RS256)  // Single algorithm only
        .build();

    // Validate issuer and audience
    OAuth2TokenValidator<Jwt> issuerValidator =
        JwtValidators.createDefaultWithIssuer("<https://auth.company.com>");

    OAuth2TokenValidator<Jwt> audienceValidator = new JwtClaimValidator<>(
        JwtClaimNames.AUD,
        aud -> aud != null && aud.contains("<https://api.company.com>")
    );

    OAuth2TokenValidator<Jwt> combinedValidator = new DelegatingOAuth2TokenValidator<>(
        issuerValidator,
        audienceValidator
    );

    decoder.setJwtValidator(combinedValidator);

    return decoder;
}
// SECURE: Strict algorithm + claim validation

@Bean
public JwtDecoder jwtDecoder() {
    NimbusJwtDecoder decoder = NimbusJwtDecoder
        .withJwkSetUri("<https://auth.company.com/.well-known/jwks.json>")
        .jwsAlgorithm(SignatureAlgorithm.RS256)  // Single algorithm only
        .build();

    // Validate issuer and audience
    OAuth2TokenValidator<Jwt> issuerValidator =
        JwtValidators.createDefaultWithIssuer("<https://auth.company.com>");

    OAuth2TokenValidator<Jwt> audienceValidator = new JwtClaimValidator<>(
        JwtClaimNames.AUD,
        aud -> aud != null && aud.contains("<https://api.company.com>")
    );

    OAuth2TokenValidator<Jwt> combinedValidator = new DelegatingOAuth2TokenValidator<>(
        issuerValidator,
        audienceValidator
    );

    decoder.setJwtValidator(combinedValidator);

    return decoder;
}

Testing Spring Security JWT Configuration

// White box analysis: read JwtDecoder configuration

public class SpringSecurityJwtAnalyzer {

    public List<Finding> analyzeJwtConfiguration(ApplicationContext context) {
        List<Finding> findings = new ArrayList<>();

        // Get all JwtDecoder beans
        Map<String, JwtDecoder> decoders = context.getBeansOfType(JwtDecoder.class);

        for (Map.Entry<String, JwtDecoder> entry : decoders.entrySet()) {
            JwtDecoder decoder = entry.getValue();

            if (decoder instanceof NimbusJwtDecoder nimbusDecoder) {
                // Check algorithm allowlist
                Set<JWSAlgorithm> algorithms = extractAlgorithms(nimbusDecoder);

                if (algorithms.contains(JWSAlgorithm.NONE)) {
                    findings.add(new Finding(
                        "CRITICAL",
                        "JWT decoder accepts 'none' algorithm",
                        "Complete authentication bypass — forge any JWT"
                    ));
                }

                if (algorithms.size() > 1 &&
                    algorithms.stream().anyMatch(a -> a.getRequirement() == Requirement.REQUIRED)) {
                    findings.add(new Finding(
                        "HIGH",
                        "JWT decoder accepts multiple algorithms — confusion attack possible",
                        "Algorithm: " + algorithms
                    ));
                }

                // Check validator configuration
                OAuth2TokenValidator<Jwt> validator = extractValidator(nimbusDecoder);

                if (validator == null || validator instanceof JwtTimestampValidator) {
                    findings.add(new Finding(
                        "HIGH",
                        "JWT decoder missing issuer/audience validation",
                        "Tokens from any service with this JWKS will be accepted"
                    ));
                }
            }
        }

        return findings;
    }
}
// White box analysis: read JwtDecoder configuration

public class SpringSecurityJwtAnalyzer {

    public List<Finding> analyzeJwtConfiguration(ApplicationContext context) {
        List<Finding> findings = new ArrayList<>();

        // Get all JwtDecoder beans
        Map<String, JwtDecoder> decoders = context.getBeansOfType(JwtDecoder.class);

        for (Map.Entry<String, JwtDecoder> entry : decoders.entrySet()) {
            JwtDecoder decoder = entry.getValue();

            if (decoder instanceof NimbusJwtDecoder nimbusDecoder) {
                // Check algorithm allowlist
                Set<JWSAlgorithm> algorithms = extractAlgorithms(nimbusDecoder);

                if (algorithms.contains(JWSAlgorithm.NONE)) {
                    findings.add(new Finding(
                        "CRITICAL",
                        "JWT decoder accepts 'none' algorithm",
                        "Complete authentication bypass — forge any JWT"
                    ));
                }

                if (algorithms.size() > 1 &&
                    algorithms.stream().anyMatch(a -> a.getRequirement() == Requirement.REQUIRED)) {
                    findings.add(new Finding(
                        "HIGH",
                        "JWT decoder accepts multiple algorithms — confusion attack possible",
                        "Algorithm: " + algorithms
                    ));
                }

                // Check validator configuration
                OAuth2TokenValidator<Jwt> validator = extractValidator(nimbusDecoder);

                if (validator == null || validator instanceof JwtTimestampValidator) {
                    findings.add(new Finding(
                        "HIGH",
                        "JWT decoder missing issuer/audience validation",
                        "Tokens from any service with this JWKS will be accepted"
                    ));
                }
            }
        }

        return findings;
    }
}
// White box analysis: read JwtDecoder configuration

public class SpringSecurityJwtAnalyzer {

    public List<Finding> analyzeJwtConfiguration(ApplicationContext context) {
        List<Finding> findings = new ArrayList<>();

        // Get all JwtDecoder beans
        Map<String, JwtDecoder> decoders = context.getBeansOfType(JwtDecoder.class);

        for (Map.Entry<String, JwtDecoder> entry : decoders.entrySet()) {
            JwtDecoder decoder = entry.getValue();

            if (decoder instanceof NimbusJwtDecoder nimbusDecoder) {
                // Check algorithm allowlist
                Set<JWSAlgorithm> algorithms = extractAlgorithms(nimbusDecoder);

                if (algorithms.contains(JWSAlgorithm.NONE)) {
                    findings.add(new Finding(
                        "CRITICAL",
                        "JWT decoder accepts 'none' algorithm",
                        "Complete authentication bypass — forge any JWT"
                    ));
                }

                if (algorithms.size() > 1 &&
                    algorithms.stream().anyMatch(a -> a.getRequirement() == Requirement.REQUIRED)) {
                    findings.add(new Finding(
                        "HIGH",
                        "JWT decoder accepts multiple algorithms — confusion attack possible",
                        "Algorithm: " + algorithms
                    ));
                }

                // Check validator configuration
                OAuth2TokenValidator<Jwt> validator = extractValidator(nimbusDecoder);

                if (validator == null || validator instanceof JwtTimestampValidator) {
                    findings.add(new Finding(
                        "HIGH",
                        "JWT decoder missing issuer/audience validation",
                        "Tokens from any service with this JWKS will be accepted"
                    ));
                }
            }
        }

        return findings;
    }
}

Bypass Pattern 5: CORS Misconfiguration Enabling Cross-Origin Attacks

Spring Security CORS Configuration Vulnerabilities

// VULNERABLE: Reflecting origin with credentials

@Configuration
@EnableWebSecurity
public class SecurityConfig {

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
            .cors(cors -> cors.configurationSource(corsConfigurationSource()))
            .authorizeHttpRequests(auth -> auth
                .anyRequest().authenticated()
            );
        return http.build();
    }

    @Bean
    public CorsConfigurationSource corsConfigurationSource() {
        CorsConfiguration config = new CorsConfiguration();

        // VULNERABLE: Reflects any origin sent in the request
        config.addAllowedOriginPattern("*");  // ← All origins allowed

        // CRITICAL: AllowCredentials=true with wildcard origin
        // This allows attacker-controlled sites to make
        // authenticated requests and read responses
        config.setAllowCredentials(true);      // ← Credentials allowed

        config.addAllowedMethod("*");
        config.addAllowedHeader("*");

        UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
        source.registerCorsConfiguration("/**", config);
        return source;
    }
}
// VULNERABLE: Reflecting origin with credentials

@Configuration
@EnableWebSecurity
public class SecurityConfig {

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
            .cors(cors -> cors.configurationSource(corsConfigurationSource()))
            .authorizeHttpRequests(auth -> auth
                .anyRequest().authenticated()
            );
        return http.build();
    }

    @Bean
    public CorsConfigurationSource corsConfigurationSource() {
        CorsConfiguration config = new CorsConfiguration();

        // VULNERABLE: Reflects any origin sent in the request
        config.addAllowedOriginPattern("*");  // ← All origins allowed

        // CRITICAL: AllowCredentials=true with wildcard origin
        // This allows attacker-controlled sites to make
        // authenticated requests and read responses
        config.setAllowCredentials(true);      // ← Credentials allowed

        config.addAllowedMethod("*");
        config.addAllowedHeader("*");

        UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
        source.registerCorsConfiguration("/**", config);
        return source;
    }
}
// VULNERABLE: Reflecting origin with credentials

@Configuration
@EnableWebSecurity
public class SecurityConfig {

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
            .cors(cors -> cors.configurationSource(corsConfigurationSource()))
            .authorizeHttpRequests(auth -> auth
                .anyRequest().authenticated()
            );
        return http.build();
    }

    @Bean
    public CorsConfigurationSource corsConfigurationSource() {
        CorsConfiguration config = new CorsConfiguration();

        // VULNERABLE: Reflects any origin sent in the request
        config.addAllowedOriginPattern("*");  // ← All origins allowed

        // CRITICAL: AllowCredentials=true with wildcard origin
        // This allows attacker-controlled sites to make
        // authenticated requests and read responses
        config.setAllowCredentials(true);      // ← Credentials allowed

        config.addAllowedMethod("*");
        config.addAllowedHeader("*");

        UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
        source.registerCorsConfiguration("/**", config);
        return source;
    }
}
# Testing CORS misconfiguration:

GET /api/v1/users/profile HTTP/1.1
Host: api.company.com
Origin: <https://attacker.com>
Authorization: Bearer [valid_token]

# VULNERABLE response:
HTTP/1.1 200 OK
Access-Control-Allow-Origin: <https://attacker.com>

# Testing CORS misconfiguration:

GET /api/v1/users/profile HTTP/1.1
Host: api.company.com
Origin: <https://attacker.com>
Authorization: Bearer [valid_token]

# VULNERABLE response:
HTTP/1.1 200 OK
Access-Control-Allow-Origin: <https://attacker.com>

# Testing CORS misconfiguration:

GET /api/v1/users/profile HTTP/1.1
Host: api.company.com
Origin: <https://attacker.com>
Authorization: Bearer [valid_token]

# VULNERABLE response:
HTTP/1.1 200 OK
Access-Control-Allow-Origin: <https://attacker.com>

// SECURE: Explicit allowlist — no reflection, no wildcard with credentials

@Bean
public CorsConfigurationSource corsConfigurationSource() {
    CorsConfiguration config = new CorsConfiguration();

    // Explicit allowlist — never allowedOriginPattern("*") with credentials
    config.setAllowedOrigins(Arrays.asList(
        "<https://app.company.com>",
        "<https://admin.company.com>"
    ));

    config.setAllowCredentials(true);  // Fine with explicit origins

    config.setAllowedMethods(Arrays.asList("GET", "POST", "PUT", "DELETE", "OPTIONS"));
    config.setAllowedHeaders(Arrays.asList(
        "Authorization",
        "Content-Type",
        "X-Requested-With"
    ));

    config.setMaxAge(3600L);

    UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
    source.registerCorsConfiguration("/api/**", config);
    return source;
}
// SECURE: Explicit allowlist — no reflection, no wildcard with credentials

@Bean
public CorsConfigurationSource corsConfigurationSource() {
    CorsConfiguration config = new CorsConfiguration();

    // Explicit allowlist — never allowedOriginPattern("*") with credentials
    config.setAllowedOrigins(Arrays.asList(
        "<https://app.company.com>",
        "<https://admin.company.com>"
    ));

    config.setAllowCredentials(true);  // Fine with explicit origins

    config.setAllowedMethods(Arrays.asList("GET", "POST", "PUT", "DELETE", "OPTIONS"));
    config.setAllowedHeaders(Arrays.asList(
        "Authorization",
        "Content-Type",
        "X-Requested-With"
    ));

    config.setMaxAge(3600L);

    UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
    source.registerCorsConfiguration("/api/**", config);
    return source;
}
// SECURE: Explicit allowlist — no reflection, no wildcard with credentials

@Bean
public CorsConfigurationSource corsConfigurationSource() {
    CorsConfiguration config = new CorsConfiguration();

    // Explicit allowlist — never allowedOriginPattern("*") with credentials
    config.setAllowedOrigins(Arrays.asList(
        "<https://app.company.com>",
        "<https://admin.company.com>"
    ));

    config.setAllowCredentials(true);  // Fine with explicit origins

    config.setAllowedMethods(Arrays.asList("GET", "POST", "PUT", "DELETE", "OPTIONS"));
    config.setAllowedHeaders(Arrays.asList(
        "Authorization",
        "Content-Type",
        "X-Requested-With"
    ));

    config.setMaxAge(3600L);

    UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
    source.registerCorsConfiguration("/api/**", config);
    return source;
}

Bypass Pattern 6: Spring Boot Actuator Exposure

Actuator endpoints are one of the most consistently found critical findings in Spring Boot penetration testing. They provide operational visibility into the running application, and when exposed without authentication, they provide attackers with everything from environment variables to heap dumps.

# VULNERABLE application.properties / application.yml

# Exposes ALL actuator endpoints
management:
  endpoints:
    web:
      exposure:
        include: "*"          # ← Exposes EVERYTHING
  endpoint:
    health:
      show-details: always    # ← Shows database connection details
    env:
      enabled: true           # ← Exposes ALL environment variables (secrets!)
# VULNERABLE application.properties / application.yml

# Exposes ALL actuator endpoints
management:
  endpoints:
    web:
      exposure:
        include: "*"          # ← Exposes EVERYTHING
  endpoint:
    health:
      show-details: always    # ← Shows database connection details
    env:
      enabled: true           # ← Exposes ALL environment variables (secrets!)
# VULNERABLE application.properties / application.yml

# Exposes ALL actuator endpoints
management:
  endpoints:
    web:
      exposure:
        include: "*"          # ← Exposes EVERYTHING
  endpoint:
    health:
      show-details: always    # ← Shows database connection details
    env:
      enabled: true           # ← Exposes ALL environment variables (secrets!)
# What attackers find at each actuator endpoint:

# /actuator — lists all available endpoints
GET /actuator HTTP/1.1
→ {"_links": {
     "health": {...},
     "env": {...},           ← Environment variables
     "heapdump": {...},      ← JVM memory dump
     "mappings": {...},      ← All route mappings
     "beans": {...},         ← All Spring beans
     "configprops": {...},   ← All configuration properties
     "loggers": {...},       ← Log levels (writable)
     "shutdown": {...}       ← Remote shutdown!
   }}

# /actuator/env — ALL environment variables
GET /actuator/env HTTP/1.1
→ {
    "propertySources": [{
      "name": "systemEnvironment",
      "properties": {
        "DATABASE_URL": {"value": "postgresql://admin:prod_pass@db:5432/app"},
        "STRIPE_SECRET_KEY": {"value": "sk_live_xxxx"},
        "JWT_SECRET": {"value": "my-signing-secret"},
        "AWS_SECRET_ACCESS_KEY": {"value": "wJalrXUtnFEMI/K7MDENG"}
      }
    }]
  }
# → Complete secrets exfiltration via a single unauthenticated request

# /actuator/heapdump — JVM heap dump
GET /actuator/heapdump HTTP/1.1
→ Binary heap dump containing:
  - All String objects in memory (including secrets that were assigned to variables)
  - Active session tokens
  - User data currently being processed
  - Database connection pool credentials
  - Any credentials loaded from environment variables at startup

# Parsing the heap dump for secrets:
# jmap -dump:live,format=b,file=heap.hprof <pid>
# Or download from actuator
# Then: grep -a "sk_live_\\|AKIA\\

# What attackers find at each actuator endpoint:

# /actuator — lists all available endpoints
GET /actuator HTTP/1.1
→ {"_links": {
     "health": {...},
     "env": {...},           ← Environment variables
     "heapdump": {...},      ← JVM memory dump
     "mappings": {...},      ← All route mappings
     "beans": {...},         ← All Spring beans
     "configprops": {...},   ← All configuration properties
     "loggers": {...},       ← Log levels (writable)
     "shutdown": {...}       ← Remote shutdown!
   }}

# /actuator/env — ALL environment variables
GET /actuator/env HTTP/1.1
→ {
    "propertySources": [{
      "name": "systemEnvironment",
      "properties": {
        "DATABASE_URL": {"value": "postgresql://admin:prod_pass@db:5432/app"},
        "STRIPE_SECRET_KEY": {"value": "sk_live_xxxx"},
        "JWT_SECRET": {"value": "my-signing-secret"},
        "AWS_SECRET_ACCESS_KEY": {"value": "wJalrXUtnFEMI/K7MDENG"}
      }
    }]
  }
# → Complete secrets exfiltration via a single unauthenticated request

# /actuator/heapdump — JVM heap dump
GET /actuator/heapdump HTTP/1.1
→ Binary heap dump containing:
  - All String objects in memory (including secrets that were assigned to variables)
  - Active session tokens
  - User data currently being processed
  - Database connection pool credentials
  - Any credentials loaded from environment variables at startup

# Parsing the heap dump for secrets:
# jmap -dump:live,format=b,file=heap.hprof <pid>
# Or download from actuator
# Then: grep -a "sk_live_\\|AKIA\\

# What attackers find at each actuator endpoint:

# /actuator — lists all available endpoints
GET /actuator HTTP/1.1
→ {"_links": {
     "health": {...},
     "env": {...},           ← Environment variables
     "heapdump": {...},      ← JVM memory dump
     "mappings": {...},      ← All route mappings
     "beans": {...},         ← All Spring beans
     "configprops": {...},   ← All configuration properties
     "loggers": {...},       ← Log levels (writable)
     "shutdown": {...}       ← Remote shutdown!
   }}

# /actuator/env — ALL environment variables
GET /actuator/env HTTP/1.1
→ {
    "propertySources": [{
      "name": "systemEnvironment",
      "properties": {
        "DATABASE_URL": {"value": "postgresql://admin:prod_pass@db:5432/app"},
        "STRIPE_SECRET_KEY": {"value": "sk_live_xxxx"},
        "JWT_SECRET": {"value": "my-signing-secret"},
        "AWS_SECRET_ACCESS_KEY": {"value": "wJalrXUtnFEMI/K7MDENG"}
      }
    }]
  }
# → Complete secrets exfiltration via a single unauthenticated request

# /actuator/heapdump — JVM heap dump
GET /actuator/heapdump HTTP/1.1
→ Binary heap dump containing:
  - All String objects in memory (including secrets that were assigned to variables)
  - Active session tokens
  - User data currently being processed
  - Database connection pool credentials
  - Any credentials loaded from environment variables at startup

# Parsing the heap dump for secrets:
# jmap -dump:live,format=b,file=heap.hprof <pid>
# Or download from actuator
# Then: grep -a "sk_live_\\|AKIA\\

// SECURE actuator configuration:

@Configuration
public class ActuatorSecurityConfig {

    @Bean
    @Order(1)  // Higher priority than main security chain
    public SecurityFilterChain actuatorSecurityFilterChain(HttpSecurity http) throws Exception {
        http
            .securityMatcher("/actuator/**")
            .authorizeHttpRequests(auth -> auth
                // Only health and info public (for load balancer checks)
                .requestMatchers("/actuator/health", "/actuator/info").permitAll()
                // Everything else requires ACTUATOR_ADMIN role
                .anyRequest().hasRole("ACTUATOR_ADMIN")
            )
            // Use IP allowlisting for additional protection
            .httpBasic(Customizer.withDefaults());

        return http.build();
    }
}
// SECURE actuator configuration:

@Configuration
public class ActuatorSecurityConfig {

    @Bean
    @Order(1)  // Higher priority than main security chain
    public SecurityFilterChain actuatorSecurityFilterChain(HttpSecurity http) throws Exception {
        http
            .securityMatcher("/actuator/**")
            .authorizeHttpRequests(auth -> auth
                // Only health and info public (for load balancer checks)
                .requestMatchers("/actuator/health", "/actuator/info").permitAll()
                // Everything else requires ACTUATOR_ADMIN role
                .anyRequest().hasRole("ACTUATOR_ADMIN")
            )
            // Use IP allowlisting for additional protection
            .httpBasic(Customizer.withDefaults());

        return http.build();
    }
}
// SECURE actuator configuration:

@Configuration
public class ActuatorSecurityConfig {

    @Bean
    @Order(1)  // Higher priority than main security chain
    public SecurityFilterChain actuatorSecurityFilterChain(HttpSecurity http) throws Exception {
        http
            .securityMatcher("/actuator/**")
            .authorizeHttpRequests(auth -> auth
                // Only health and info public (for load balancer checks)
                .requestMatchers("/actuator/health", "/actuator/info").permitAll()
                // Everything else requires ACTUATOR_ADMIN role
                .anyRequest().hasRole("ACTUATOR_ADMIN")
            )
            // Use IP allowlisting for additional protection
            .httpBasic(Customizer.withDefaults());

        return http.build();
    }
}
# SECURE application.yml:
management:
  endpoints:
    web:
      exposure:
        # Only expose health and info publicly
        include: "health,info"
        # In production, consider exposing nothing externally
        # and using management.server.port for internal access only
  server:
    port: 8081  # Separate port for actuator — not exposed externally
  endpoint:
    health:
      show-details: when-authorized  # Only show details to authorized users
    env:
      enabled: false    # Disable sensitive endpoints entirely
    heapdump:
      enabled: false    # Never expose in production
    shutdown:
      enabled: false    # Never enable remote shutdown
# SECURE application.yml:
management:
  endpoints:
    web:
      exposure:
        # Only expose health and info publicly
        include: "health,info"
        # In production, consider exposing nothing externally
        # and using management.server.port for internal access only
  server:
    port: 8081  # Separate port for actuator — not exposed externally
  endpoint:
    health:
      show-details: when-authorized  # Only show details to authorized users
    env:
      enabled: false    # Disable sensitive endpoints entirely
    heapdump:
      enabled: false    # Never expose in production
    shutdown:
      enabled: false    # Never enable remote shutdown
# SECURE application.yml:
management:
  endpoints:
    web:
      exposure:
        # Only expose health and info publicly
        include: "health,info"
        # In production, consider exposing nothing externally
        # and using management.server.port for internal access only
  server:
    port: 8081  # Separate port for actuator — not exposed externally
  endpoint:
    health:
      show-details: when-authorized  # Only show details to authorized users
    env:
      enabled: false    # Disable sensitive endpoints entirely
    heapdump:
      enabled: false    # Never expose in production
    shutdown:
      enabled: false    # Never enable remote shutdown

Bypass Pattern 7: Method Security Not Enabled

The most fundamental @PreAuthorize issue: annotations that exist in the code but never execute because method security isn't enabled.

// VULNERABLE: @PreAuthorize annotations have no effect
// because @EnableMethodSecurity is missing

@Configuration
@EnableWebSecurity
// @EnableMethodSecurity ← MISSING — without this, @PreAuthorize does nothing
public class SecurityConfig {

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
            .authorizeHttpRequests(auth -> auth
                .anyRequest().authenticated()
            );
        return http.build();
    }
}

// Controller — developer believes @PreAuthorize is enforced
@RestController
public class AdminController {

    @GetMapping("/api/admin/users")
    @PreAuthorize("hasRole('ADMIN')")  // ← Does NOTHING without @EnableMethodSecurity
    public ResponseEntity<List<User>> getAllUsers() {
        // Any authenticated user can reach this — @PreAuthorize is silently ignored
        return ResponseEntity.ok(userService.getAllUsers());
    }

    @DeleteMapping("/api/users/{id}")
    @PreAuthorize("hasRole('ADMIN') or #id == authentication.principal.id")
    // ← Also silently ignored — any authenticated user can delete any user
    public ResponseEntity<Void> deleteUser(@PathVariable Long id) {
        userService.deleteUser(id);
        return ResponseEntity.noContent().build();
    }
}
// VULNERABLE: @PreAuthorize annotations have no effect
// because @EnableMethodSecurity is missing

@Configuration
@EnableWebSecurity
// @EnableMethodSecurity ← MISSING — without this, @PreAuthorize does nothing
public class SecurityConfig {

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
            .authorizeHttpRequests(auth -> auth
                .anyRequest().authenticated()
            );
        return http.build();
    }
}

// Controller — developer believes @PreAuthorize is enforced
@RestController
public class AdminController {

    @GetMapping("/api/admin/users")
    @PreAuthorize("hasRole('ADMIN')")  // ← Does NOTHING without @EnableMethodSecurity
    public ResponseEntity<List<User>> getAllUsers() {
        // Any authenticated user can reach this — @PreAuthorize is silently ignored
        return ResponseEntity.ok(userService.getAllUsers());
    }

    @DeleteMapping("/api/users/{id}")
    @PreAuthorize("hasRole('ADMIN') or #id == authentication.principal.id")
    // ← Also silently ignored — any authenticated user can delete any user
    public ResponseEntity<Void> deleteUser(@PathVariable Long id) {
        userService.deleteUser(id);
        return ResponseEntity.noContent().build();
    }
}
// VULNERABLE: @PreAuthorize annotations have no effect
// because @EnableMethodSecurity is missing

@Configuration
@EnableWebSecurity
// @EnableMethodSecurity ← MISSING — without this, @PreAuthorize does nothing
public class SecurityConfig {

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
            .authorizeHttpRequests(auth -> auth
                .anyRequest().authenticated()
            );
        return http.build();
    }
}

// Controller — developer believes @PreAuthorize is enforced
@RestController
public class AdminController {

    @GetMapping("/api/admin/users")
    @PreAuthorize("hasRole('ADMIN')")  // ← Does NOTHING without @EnableMethodSecurity
    public ResponseEntity<List<User>> getAllUsers() {
        // Any authenticated user can reach this — @PreAuthorize is silently ignored
        return ResponseEntity.ok(userService.getAllUsers());
    }

    @DeleteMapping("/api/users/{id}")
    @PreAuthorize("hasRole('ADMIN') or #id == authentication.principal.id")
    // ← Also silently ignored — any authenticated user can delete any user
    public ResponseEntity<Void> deleteUser(@PathVariable Long id) {
        userService.deleteUser(id);
        return ResponseEntity.noContent().build();
    }
}
// Detection in white box analysis:
// Look for @PreAuthorize usage without @EnableMethodSecurity in any @Configuration class

public class MethodSecurityAnalyzer {

    public Finding checkMethodSecurityEnabled(List<ClassInfo> configClasses,
                                               List<ClassInfo> allClasses) {
        // Check if method security is enabled
        boolean methodSecurityEnabled = configClasses.stream()
            .anyMatch(cls ->
                cls.hasAnnotation("EnableMethodSecurity") ||
                cls.hasAnnotation("EnableGlobalMethodSecurity")
            );

        if (!methodSecurityEnabled) {
            // Check if @PreAuthorize annotations exist in codebase
            long preAuthorizeCount = allClasses.stream()
                .flatMap(cls -> cls.getMethods().stream())
                .filter(method -> method.hasAnnotation("PreAuthorize") ||
                                  method.hasAnnotation("Secured") ||
                                  method.hasAnnotation("RolesAllowed"))
                .count();

            if (preAuthorizeCount > 0) {
                return new Finding(
                    "CRITICAL",
                    "Method security annotations present but @EnableMethodSecurity missing",
                    String.format(
                        "%d @PreAuthorize/@Secured annotations have NO EFFECT — " +
                        "any authenticated user bypasses all method-level authorization",
                        preAuthorizeCount
                    )
                );
            }
        }

        return null;
    }
}
// Detection in white box analysis:
// Look for @PreAuthorize usage without @EnableMethodSecurity in any @Configuration class

public class MethodSecurityAnalyzer {

    public Finding checkMethodSecurityEnabled(List<ClassInfo> configClasses,
                                               List<ClassInfo> allClasses) {
        // Check if method security is enabled
        boolean methodSecurityEnabled = configClasses.stream()
            .anyMatch(cls ->
                cls.hasAnnotation("EnableMethodSecurity") ||
                cls.hasAnnotation("EnableGlobalMethodSecurity")
            );

        if (!methodSecurityEnabled) {
            // Check if @PreAuthorize annotations exist in codebase
            long preAuthorizeCount = allClasses.stream()
                .flatMap(cls -> cls.getMethods().stream())
                .filter(method -> method.hasAnnotation("PreAuthorize") ||
                                  method.hasAnnotation("Secured") ||
                                  method.hasAnnotation("RolesAllowed"))
                .count();

            if (preAuthorizeCount > 0) {
                return new Finding(
                    "CRITICAL",
                    "Method security annotations present but @EnableMethodSecurity missing",
                    String.format(
                        "%d @PreAuthorize/@Secured annotations have NO EFFECT — " +
                        "any authenticated user bypasses all method-level authorization",
                        preAuthorizeCount
                    )
                );
            }
        }

        return null;
    }
}
// Detection in white box analysis:
// Look for @PreAuthorize usage without @EnableMethodSecurity in any @Configuration class

public class MethodSecurityAnalyzer {

    public Finding checkMethodSecurityEnabled(List<ClassInfo> configClasses,
                                               List<ClassInfo> allClasses) {
        // Check if method security is enabled
        boolean methodSecurityEnabled = configClasses.stream()
            .anyMatch(cls ->
                cls.hasAnnotation("EnableMethodSecurity") ||
                cls.hasAnnotation("EnableGlobalMethodSecurity")
            );

        if (!methodSecurityEnabled) {
            // Check if @PreAuthorize annotations exist in codebase
            long preAuthorizeCount = allClasses.stream()
                .flatMap(cls -> cls.getMethods().stream())
                .filter(method -> method.hasAnnotation("PreAuthorize") ||
                                  method.hasAnnotation("Secured") ||
                                  method.hasAnnotation("RolesAllowed"))
                .count();

            if (preAuthorizeCount > 0) {
                return new Finding(
                    "CRITICAL",
                    "Method security annotations present but @EnableMethodSecurity missing",
                    String.format(
                        "%d @PreAuthorize/@Secured annotations have NO EFFECT — " +
                        "any authenticated user bypasses all method-level authorization",
                        preAuthorizeCount
                    )
                );
            }
        }

        return null;
    }
}

Bypass Pattern 8: Filter Chain Order and Multiple Security Configurations

The @Order Problem

Spring Boot applications sometimes define multiple SecurityFilterChain beans for different URL namespaces. The order matters: the first chain that matches a request wins. If a less-specific chain matches before a more-specific one, the wrong rules apply.

// VULNERABLE: Incorrect @Order — less specific chain runs first

@Configuration
@EnableWebSecurity
public class SecurityConfig {

    // Chain 1: Public API — Order 2 (lower priority)
    @Bean
    @Order(2)
    public SecurityFilterChain publicChain(HttpSecurity http) throws Exception {
        http
            .securityMatcher("/api/public/**")
            .authorizeHttpRequests(auth -> auth
                .anyRequest().permitAll()
            );
        return http.build();
    }

    // Chain 2: Admin API — Order 1 (should be higher priority)
    // BUT: the securityMatcher("/api/**") is more general than "/api/public/**"
    // When @Order(1) processes first, /api/public/users matches /api/**
    // and gets AUTHENTICATED requirement — blocking the public endpoint

    // WORSE: If Order(1) has permitAll() and matches /api/**:
    // Then /api/admin/users matches Chain 1 (order 1, /api/**)
    // and gets the permitAll() treatment — admin bypass

    @Bean
    @Order(1)
    public SecurityFilterChain adminChain(HttpSecurity http) throws Exception {
        http
            .securityMatcher("/api/**")  // ← Too broad — matches /api/admin/ too
            .authorizeHttpRequests(auth -> auth
                .anyRequest().permitAll()  // ← If this matches admin URLs: bypass!
            );
        return http.build();
    }
}
// VULNERABLE: Incorrect @Order — less specific chain runs first

@Configuration
@EnableWebSecurity
public class SecurityConfig {

    // Chain 1: Public API — Order 2 (lower priority)
    @Bean
    @Order(2)
    public SecurityFilterChain publicChain(HttpSecurity http) throws Exception {
        http
            .securityMatcher("/api/public/**")
            .authorizeHttpRequests(auth -> auth
                .anyRequest().permitAll()
            );
        return http.build();
    }

    // Chain 2: Admin API — Order 1 (should be higher priority)
    // BUT: the securityMatcher("/api/**") is more general than "/api/public/**"
    // When @Order(1) processes first, /api/public/users matches /api/**
    // and gets AUTHENTICATED requirement — blocking the public endpoint

    // WORSE: If Order(1) has permitAll() and matches /api/**:
    // Then /api/admin/users matches Chain 1 (order 1, /api/**)
    // and gets the permitAll() treatment — admin bypass

    @Bean
    @Order(1)
    public SecurityFilterChain adminChain(HttpSecurity http) throws Exception {
        http
            .securityMatcher("/api/**")  // ← Too broad — matches /api/admin/ too
            .authorizeHttpRequests(auth -> auth
                .anyRequest().permitAll()  // ← If this matches admin URLs: bypass!
            );
        return http.build();
    }
}
// VULNERABLE: Incorrect @Order — less specific chain runs first

@Configuration
@EnableWebSecurity
public class SecurityConfig {

    // Chain 1: Public API — Order 2 (lower priority)
    @Bean
    @Order(2)
    public SecurityFilterChain publicChain(HttpSecurity http) throws Exception {
        http
            .securityMatcher("/api/public/**")
            .authorizeHttpRequests(auth -> auth
                .anyRequest().permitAll()
            );
        return http.build();
    }

    // Chain 2: Admin API — Order 1 (should be higher priority)
    // BUT: the securityMatcher("/api/**") is more general than "/api/public/**"
    // When @Order(1) processes first, /api/public/users matches /api/**
    // and gets AUTHENTICATED requirement — blocking the public endpoint

    // WORSE: If Order(1) has permitAll() and matches /api/**:
    // Then /api/admin/users matches Chain 1 (order 1, /api/**)
    // and gets the permitAll() treatment — admin bypass

    @Bean
    @Order(1)
    public SecurityFilterChain adminChain(HttpSecurity http) throws Exception {
        http
            .securityMatcher("/api/**")  // ← Too broad — matches /api/admin/ too
            .authorizeHttpRequests(auth -> auth
                .anyRequest().permitAll()  // ← If this matches admin URLs: bypass!
            );
        return http.build();
    }
}
// SECURE: Most specific matchers in lower-numbered (higher priority) chains

@Configuration
@EnableWebSecurity
public class SecurityConfig {

    // Chain 1: Admin — most specific, highest priority
    @Bean
    @Order(1)
    public SecurityFilterChain adminChain(HttpSecurity http) throws Exception {
        http
            .securityMatcher("/api/admin/**")  // Specific admin namespace
            .authorizeHttpRequests(auth -> auth
                .anyRequest().hasRole("ADMIN")
            )
            .oauth2ResourceServer(oauth2 -> oauth2.jwt(Customizer.withDefaults()));
        return http.build();
    }

    // Chain 2: Authenticated API
    @Bean
    @Order(2)
    public SecurityFilterChain apiChain(HttpSecurity http) throws Exception {
        http
            .securityMatcher("/api/**")  // General API — catches what admin chain doesn't
            .authorizeHttpRequests(auth -> auth
                .requestMatchers("/api/public/**").permitAll()
                .anyRequest().authenticated()
            )
            .oauth2ResourceServer(oauth2 -> oauth2.jwt(Customizer.withDefaults()));
        return http.build();
    }
}
// SECURE: Most specific matchers in lower-numbered (higher priority) chains

@Configuration
@EnableWebSecurity
public class SecurityConfig {

    // Chain 1: Admin — most specific, highest priority
    @Bean
    @Order(1)
    public SecurityFilterChain adminChain(HttpSecurity http) throws Exception {
        http
            .securityMatcher("/api/admin/**")  // Specific admin namespace
            .authorizeHttpRequests(auth -> auth
                .anyRequest().hasRole("ADMIN")
            )
            .oauth2ResourceServer(oauth2 -> oauth2.jwt(Customizer.withDefaults()));
        return http.build();
    }

    // Chain 2: Authenticated API
    @Bean
    @Order(2)
    public SecurityFilterChain apiChain(HttpSecurity http) throws Exception {
        http
            .securityMatcher("/api/**")  // General API — catches what admin chain doesn't
            .authorizeHttpRequests(auth -> auth
                .requestMatchers("/api/public/**").permitAll()
                .anyRequest().authenticated()
            )
            .oauth2ResourceServer(oauth2 -> oauth2.jwt(Customizer.withDefaults()));
        return http.build();
    }
}
// SECURE: Most specific matchers in lower-numbered (higher priority) chains

@Configuration
@EnableWebSecurity
public class SecurityConfig {

    // Chain 1: Admin — most specific, highest priority
    @Bean
    @Order(1)
    public SecurityFilterChain adminChain(HttpSecurity http) throws Exception {
        http
            .securityMatcher("/api/admin/**")  // Specific admin namespace
            .authorizeHttpRequests(auth -> auth
                .anyRequest().hasRole("ADMIN")
            )
            .oauth2ResourceServer(oauth2 -> oauth2.jwt(Customizer.withDefaults()));
        return http.build();
    }

    // Chain 2: Authenticated API
    @Bean
    @Order(2)
    public SecurityFilterChain apiChain(HttpSecurity http) throws Exception {
        http
            .securityMatcher("/api/**")  // General API — catches what admin chain doesn't
            .authorizeHttpRequests(auth -> auth
                .requestMatchers("/api/public/**").permitAll()
                .anyRequest().authenticated()
            )
            .oauth2ResourceServer(oauth2 -> oauth2.jwt(Customizer.withDefaults()));
        return http.build();
    }
}

The Complete Spring Security Penetration Testing Checklist For White Box Audits

Security Area

Validation Check

Why It Matters

WebSecurityCustomizer

Does web.ignoring() include any path that serves data?

Can create full authentication bypass

WebSecurityCustomizer

Does web.ignoring() include any /api/** paths?

APIs may be removed from all security enforcement

WebSecurityCustomizer

Does web.ignoring() include /actuator/** beyond health/info?

Can expose secrets, diagnostics, or admin access

WebSecurityCustomizer

Are all ignoring patterns documented and justified?

Prevents accidental silent exposure

SecurityFilterChain

Is anyRequest().authenticated() or denyAll() the catch-all?

Prevents unprotected routes

SecurityFilterChain

Are wildcard matchers using ** for nested paths rather than *?

Avoids URL pattern bypasses

SecurityFilterChain

Are all API versions covered by authorization rules?

Prevents version-specific auth gaps

SecurityFilterChain

Is filter chain @Order correct with most specific matchers first?

Prevents chain precedence bypasses

SecurityFilterChain

Are multiple filter chains isolated correctly with securityMatcher?

Prevents incorrect rule application

Method Security

Is @EnableMethodSecurity enabled?

Without it, annotations may do nothing

Method Security

Are all @PreAuthorize SpEL expressions logically sound?

Prevents authorization logic bypass

Method Security

Are overriding methods explicitly protected?

Security annotations may not inherit

Method Security

Is hasRole() vs hasAuthority() used consistently?

Prevents broken role enforcement

Method Security

Do @Secured role names match stored role format?

Avoids silent authorization failures

JWT / OAuth2

Is the algorithm allowlist restricted to one algorithm?

Prevents algorithm confusion attacks

JWT / OAuth2

Is the none algorithm explicitly rejected?

Prevents complete auth bypass

JWT / OAuth2

Is issuer validation configured correctly?

Stops token trust abuse

JWT / OAuth2

Is audience validation configured for this API?

Prevents cross-service token abuse

JWT / OAuth2

Is token expiry validation enforced?

Stops expired token reuse

JWT / OAuth2

Are multiple JwtDecoder beans causing confusion?

Can weaken validation logic

CORS

Is wildcard origin avoided with credentials enabled?

Prevents cross-origin account compromise

CORS

Is an explicit origin allowlist enforced?

Reduces trust boundary failures

CORS

Are CORS rules applied before auth filters correctly?

Prevents inconsistent security behavior

CORS

Is CORS configuration scoped appropriately by path?

Limits unnecessary exposure

Actuator

Is actuator exposure limited to health and info?

Minimizes sensitive attack surface

Actuator

Is /actuator/env disabled or restricted?

Prevents secrets exposure

Actuator

Is /actuator/heapdump disabled in production?

Prevents credential extraction

Actuator

Is /actuator/shutdown disabled?

Prevents remote service disruption

Actuator

Does actuator run on a separate internal port?

Adds isolation for admin functions

CSRF

Is CSRF disabled only for stateless JWT APIs where appropriate?

Prevents incorrect blanket disabling

CSRF

Is CSRF protection enabled for session-based auth?

Prevents request forgery attacks

CSRF

Are CSRF tokens validated correctly for forms?

Ensures anti-CSRF controls work

Real Finding: webSecurityCustomizer Bypass in Production Spring Boot App

Field

Details

Finding ID

FIND-2026-SS-001

Severity

Critical (CVSS 4.0: 9.8)

Category

Broken Authentication — Spring Security Misconfiguration

Confidence

Confirmed exploitation with unauthenticated admin data access

Affected File

src/main/java/com/company/config/SecurityConfig.java

Affected Lines

47–54

Root Vulnerability

web.ignoring() excluded /api/v2/** from the Spring Security filter chain, bypassing all authentication and authorization enforcement

Impact

Entire /api/v2/ namespace exposed without authentication

Confirmed Exposed Endpoints

GET /api/v2/admin/users (94,000 user records), GET /api/v2/admin/export, POST /api/v2/admin/users, GET /api/v2/config/environment, DELETE /api/v2/users/{id}

Proof of Exploit

Unauthenticated requests returned 200 OK, including admin user records and environment secrets

Attack Result

Authentication bypass, admin access, secret exposure, account manipulation

Root Cause

Developer used web.ignoring() for performance troubleshooting, assuming it behaved like permitAll(). It removed endpoints from all security processing entirely

Immediate Remediation

Remove /api/v2/** from webSecurityCustomizer().ignoring() immediately

Secure Replacement

Protect /api/v2/admin/** with hasRole('ADMIN'), require authentication for /api/v2/**, keep anyRequest().authenticated() as catch-all

Additional Corrective Action

Investigate performance issue directly rather than weakening security controls

Compliance Impact

SOC 2 CC6.1 Failed; PCI-DSS Requirement 6.2 Failed; GDPR Article 32 Risk Exposure

Business Risk

Potential unauthorized admin access, bulk data exfiltration, privilege abuse, regulatory exposure

Vulnerable Configuration

@Beanpublic WebSecurityCustomizer webSecurityCustomizer() {    return (web) -> web.ignoring()        .requestMatchers(            new AntPathRequestMatcher("/api/v2/**")        );}
@Beanpublic WebSecurityCustomizer webSecurityCustomizer() {    return (web) -> web.ignoring()        .requestMatchers(            new AntPathRequestMatcher("/api/v2/**")        );}
@Beanpublic WebSecurityCustomizer webSecurityCustomizer() {    return (web) -> web.ignoring()        .requestMatchers(            new AntPathRequestMatcher("/api/v2/**")        );}

Secure Replacement

.authorizeHttpRequests(auth -> auth    .requestMatchers("/api/v2/admin/**").hasRole("ADMIN")    .requestMatchers("/api/v2/**").authenticated()    .anyRequest().authenticated())
.authorizeHttpRequests(auth -> auth    .requestMatchers("/api/v2/admin/**").hasRole("ADMIN")    .requestMatchers("/api/v2/**").authenticated()    .anyRequest().authenticated())
.authorizeHttpRequests(auth -> auth    .requestMatchers("/api/v2/admin/**").hasRole("ADMIN")    .requestMatchers("/api/v2/**").authenticated()    .anyRequest().authenticated())

Why This Finding Matters

This was not a framework flaw. It was a single configuration decision that removed an entire API namespace from the security model. That is exactly why white box security review and automated penetration testing catch issues scanners routinely miss.

Risk Dimension

Rating

Exploitability

Very High

Privileges Required

None

Data Exposure Risk

Critical

Detection Difficulty

Medium

Business Impact

Severe

The Framework Didn't Misconfigure Itself

Spring Security is a well-designed framework with strong defaults. The authentication bypasses in this guide don't come from bugs in the framework, they come from configuration decisions made by developers who understood their intent but not the precise semantics of what they wrote.

web.ignoring() is the most consequential example: developers who understand it as "these paths are public" don't understand that "these paths don't exist in the security model" is a fundamentally different statement. You can't add authentication requirements to a path that's been removed from security processing, @PreAuthorize doesn't apply, method security doesn't apply, nothing applies. The URL is outside the security context entirely.

Finding these misconfigurations from the outside, through black box testing alone, is unreliable. Some bypasses are visible (unauthenticated 200 responses where 401 is expected). Many aren't (a path that requires authentication but whose @PreAuthorize is silently unenforced). The only methodology that finds all of them is reading the configuration: the SecurityFilterChain beans, the WebSecurityCustomizer beans, the @EnableMethodSecurity presence, the JWT decoder algorithm allowlist, the CORS configuration, the actuator exposure settings.

That's what CodeAnt AI does in every white box engagement, reads every security configuration in the repository, traces every authorization rule to its outcome, and confirms every bypass with a working proof-of-concept.

To learn more, book a 30-minute scoping call. Testing starts within 24.


FAQs

What Is Spring Security Penetration Testing?

How Does Automated Penetration Testing Improve Spring Security Testing?

How Can I Improve My Codebase Security In Spring Boot Applications?

What Are The Key Features Of A Code Security Platform For Java Applications?

What Spring Security Misconfigurations Cause The Most Critical Vulnerabilities?

Table of Contents

Start Your 14-Day Free Trial

AI code reviews, security, and quality trusted by modern engineering teams. No credit card required!

Share blog: