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 SecurityContext ↓ UsernamePasswordAuthenticationFilter // Handles form login (if configured) ↓ BasicAuthenticationFilter // Handles HTTP Basic auth (if configured) ↓ BearerTokenAuthenticationFilter // Handles JWT/OAuth2 (if configured) ↓ ExceptionTranslationFilter // Handles security exceptions ↓ FilterSecurityInterceptor // Enforces authorization rules ← KEY ↓ DispatcherServlet // Routes to controllers ↓ Controller method executes
// The security filter chain execution order: // (Simplified — actual chain has ~15+ filters) HTTP Request ↓ SecurityContextPersistenceFilter // Loads/saves SecurityContext ↓ UsernamePasswordAuthenticationFilter // Handles form login (if configured) ↓ BasicAuthenticationFilter // Handles HTTP Basic auth (if configured) ↓ BearerTokenAuthenticationFilter // Handles JWT/OAuth2 (if configured) ↓ ExceptionTranslationFilter // Handles security exceptions ↓ FilterSecurityInterceptor // Enforces authorization rules ← KEY ↓ DispatcherServlet // Routes to controllers ↓ Controller method executes
// The security filter chain execution order: // (Simplified — actual chain has ~15+ filters) HTTP Request ↓ SecurityContextPersistenceFilter // Loads/saves SecurityContext ↓ UsernamePasswordAuthenticationFilter // Handles form login (if configured) ↓ BasicAuthenticationFilter // Handles HTTP Basic auth (if configured) ↓ BearerTokenAuthenticationFilter // Handles JWT/OAuth2 (if configured) ↓ ExceptionTranslationFilter // Handles security exceptions ↓ FilterSecurityInterceptor // Enforces authorization rules ← KEY ↓ DispatcherServlet // Routes to controllers ↓ Controller 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 | Can create full authentication bypass |
WebSecurityCustomizer | Does | APIs may be removed from all security enforcement |
WebSecurityCustomizer | Does | Can expose secrets, diagnostics, or admin access |
WebSecurityCustomizer | Are all ignoring patterns documented and justified? | Prevents accidental silent exposure |
SecurityFilterChain | Is | Prevents unprotected routes |
SecurityFilterChain | Are wildcard matchers using | Avoids URL pattern bypasses |
SecurityFilterChain | Are all API versions covered by authorization rules? | Prevents version-specific auth gaps |
SecurityFilterChain | Is filter chain | Prevents chain precedence bypasses |
SecurityFilterChain | Are multiple filter chains isolated correctly with | Prevents incorrect rule application |
Method Security | Is | Without it, annotations may do nothing |
Method Security | Are all | Prevents authorization logic bypass |
Method Security | Are overriding methods explicitly protected? | Security annotations may not inherit |
Method Security | Is | Prevents broken role enforcement |
Method Security | Do | Avoids silent authorization failures |
JWT / OAuth2 | Is the algorithm allowlist restricted to one algorithm? | Prevents algorithm confusion attacks |
JWT / OAuth2 | Is the | 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 | 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 | Prevents secrets exposure |
Actuator | Is | Prevents credential extraction |
Actuator | Is | 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 |
|
Affected Lines | 47–54 |
Root Vulnerability |
|
Impact | Entire |
Confirmed Exposed Endpoints |
|
Proof of Exploit | Unauthenticated requests returned |
Attack Result | Authentication bypass, admin access, secret exposure, account manipulation |
Root Cause | Developer used |
Immediate Remediation | Remove |
Secure Replacement | Protect |
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:











