Skills for creating, testing, reviewing, migrating and guarding Spring Boot 4 projects with Java 25: project scaffolding with architecture patterns, Boot 4 test templates, code review, Boot 3 to 4 migration, and a team harness of build gates and agent hooks.
93%
Does it follow best practices?
Run evals on this skill
Adds up to 20 points to the overall score
Low
Low-risk findings worth noting
Organized by OWASP Top 10:2025. Targets Spring Boot 4 / Spring Security 7. Report exploitability and the affected entry point, not only the rule.
| Flag | Fix |
|---|---|
Privileged endpoint with no rule in the filter chain and no @PreAuthorize | Deny by default (anyRequest().authenticated()), add role/authority checks |
Object fetched by ID with no ownership check (IDOR): repository.findById(id) for the current user's data | Check ownership: @PreAuthorize("#userId == authentication.principal.id"), or query by (id, ownerId) |
| URL rules only; services callable from other entry points (messaging, schedulers) | Method security (@EnableMethodSecurity) on services |
| Client-controlled role or tenant fields bound into entities | Bind request DTOs, not entities; set owner/tenant on the server |
SSRF. Flag any outbound call to a URL or host that comes from the request.
InetAddress.isLoopbackAddress(), isSiteLocalAddress(), isLinkLocalAddress(), isAnyLocalAddress()), plus 169.254.169.254. Check every resolved address, and connect to the checked address so DNS rebinding cannot swap it. String prefix checks like host.startsWith("172.16.") miss 172.17-172.31, IPv6, and encoded forms.| Flag | Fix |
|---|---|
management.endpoints.web.exposure.include=* in production | Expose health (and metrics behind auth) only |
management.endpoint.health.show-details=always | when-authorized |
spring.h2.console.enabled=true outside local profiles | Disable |
CORS allowedOrigins("*") with credentials, or @CrossOrigin everywhere | Explicit origins in one CorsConfigurationSource |
Security headers disabled (headers(h -> h.disable())) | Keep Spring Security defaults; add CSP for browser apps; HSTS behind TLS |
Stack traces or exception messages in responses (ex.getMessage() in a 500) | ProblemDetail with a generic message; log details on the server; server.error.include-stacktrace=never (default) |
| Flag | Fix |
|---|---|
| Plain-text, MD5, SHA-1, or unsalted SHA-256 passwords | PasswordEncoderFactories.createDelegatingPasswordEncoder() (bcrypt by default, {id} prefix allows later upgrades), or Argon2 |
new Random() for tokens, reset codes, IDs that must be secret | SecureRandom, 32+ bytes, Base64URL |
Secrets in source or application.properties | Environment variables or a secret manager |
| HTTP between services that carry credentials or personal data | TLS |
| Passwords, tokens, or full request bodies in logs | Log identifiers only |
For key derivation (not passwords), see java-25-features.md (javax.crypto.KDF, HKDF).
| Flag | Fix |
|---|---|
String concatenation into JPQL/SQL ("... where name = '" + name + "'"), including nativeQuery = true and JdbcClient.sql(...) | Bind parameters (:name, ?) |
| User input as a sort property or column name | Map to an allowlist of properties before Sort.by(...) |
MongoDB: a request Map/Document used as query criteria, or a JSON query built by string concatenation (new BasicQuery("{name: '" + n + "'}")) | Typed parameters: Criteria.where("name").is(name) with a String is safe; @Query("{ 'name': ?0 }") binds safely |
Runtime.exec / ProcessBuilder with user input | Avoid; if needed, fixed command plus argument list, never a shell string |
| SpEL or template expressions built from user input | Never evaluate user input |
Unescaped output in Thymeleaf (th:utext) | th:text |
@RateLimiter). In-memory counters do not work across instances.Secure, HttpOnly, SameSite.server.servlet.session.timeout).ObjectInputStream.readObject() on untrusted data.activateDefaultTyping, enableUnsafeDefaultTyping). If polymorphic types are needed, use a PolymorphicTypeValidator limited to app packages.AuthenticationSuccessEvent / AbstractAuthenticationFailureEvent / AuthorizationDeniedEvent; listen to them instead of logging in controllers.| Flag | Fix |
|---|---|
catch (Exception e) { return true; } around an authorization or validation check | Fail closed: deny on error |
| Swallowed exceptions in security filters or token validation | Log and reject |
| Exception details returned to clients | Generic ProblemDetail; details only in server logs |
| Missing timeouts on outbound calls | Connect and read timeouts (spring.http.clients.*), so failures do not hang threads |
| Partial updates left after an exception | Transaction boundaries around multi-step writes |
@Configuration
@EnableMethodSecurity
class SecurityConfig {
// Stateless JSON API with bearer tokens: only requests under /api/**
@Bean
@Order(1)
SecurityFilterChain api(HttpSecurity http) throws Exception {
http
.securityMatcher("/api/**")
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/public/**").permitAll()
.requestMatchers("/api/admin/**").hasRole("ADMIN")
.anyRequest().authenticated())
.sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.csrf(csrf -> csrf.disable()) // no cookies carry credentials here
.oauth2ResourceServer(o -> o.jwt(Customizer.withDefaults()));
return http.build();
}
// Fallback for every other path. Without it, requests outside /api/**
// match no chain and Spring Security does not protect them.
@Bean
@Order(2)
SecurityFilterChain fallback(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests(auth -> auth
.requestMatchers("/actuator/health/**").permitAll()
.anyRequest().denyAll());
return http.build();
}
@Bean
PasswordEncoder passwordEncoder() {
return PasswordEncoderFactories.createDelegatingPasswordEncoder();
}
}Review rules:
csrf.disable() together with form login or session cookies.AntPathRequestMatcher and MvcRequestMatcher are gone (use requestMatchers("/path/**") or PathPatternRequestMatcher), and the .and() chaining DSL is gone (use lambda customizers). Old code does not compile.@EnableWebSecurity is optional in Boot. @EnableMethodSecurity already enables @PreAuthorize/@PostAuthorize (prePostEnabled defaults to true).securityMatcher(...) chain with no catch-all chain. Requests that match no chain bypass Spring Security entirely. Give scoped chains @Order and add a last chain without a matcher that ends in anyRequest().authenticated() or denyAll(). If there is only one chain, drop the matcher and use requestMatchers(...) inside authorizeHttpRequests.spring-boot-starter-security-test (@WithMockUser, SecurityMockMvcRequestPostProcessors.jwt()).spring-boot-starter-oauth2-resource-server, spring.security.oauth2.resourceserver.jwt.issuer-uri or jwk-set-uri) over hand-written JWT parsing. It validates signature, expiry, and issuer.Jwts.parser().verifyWith(key).build().parseSignedClaims(token). Flag the pre-0.12 parserBuilder()/setSigningKey()/parseClaimsJws() API.exp, iss, and aud. Reject alg: none. Access tokens are short-lived (minutes); refresh tokens are revocable.localStorage for browser apps when an HttpOnly cookie (with CSRF protection) is possible.Critical:
SecurityFilterChain (a catch-all chain exists when others use securityMatcher)DelegatingPasswordEncoder/bcrypt/Argon2; no secrets in source or logsImportant:
AntPathRequestMatcher, no .and())