Safeguard
AppSec

spring-boot-starter-security: Secure Defaults and Common Mistakes

What actually happens when you add spring-boot-starter-security to your build, the defaults it turns on, and the configuration mistakes that quietly undo them.

Marcus Chen
DevSecOps Engineer
7 min read

Adding spring-boot-starter-security to your build immediately locks down every endpoint in your application behind HTTP Basic and form login, with a single user account whose random password is printed to the startup log. That is the whole pitch of the starter: one dependency, deny-by-default. Most Spring Security incidents I have debugged were not framework bugs. They were teams dismantling those defaults one permitAll() at a time until the deny-by-default posture was gone and nobody noticed.

This post covers what the spring-boot-starter-security dependency actually gives you, and the handful of mistakes that show up in almost every code review.

What the Starter Pulls In

The starter itself is nearly empty. It is an aggregator POM:

<dependency>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-security</artifactId>
</dependency>

Behind it come spring-security-config and spring-security-web (plus spring-security-core transitively, and Spring AOP for method security). Boot's auto-configuration detects them on the classpath and activates SecurityAutoConfiguration, which is what flips your application from "everything open" to "everything authenticated" without a single line of your code.

That classpath-triggered behavior cuts both ways. I have seen a Spring Boot security dependency arrive transitively through an internal shared library, silently wrapping an internal batch service in Basic auth and breaking its health checks. If mvn dependency:tree | grep spring-security surprises you, fix the surprise before you fix the 401s.

The Defaults You Get for Free

Out of the box, with zero configuration, the starter gives you:

  • Authentication required for every request, including static resources and error pages
  • A generated user: username user, password printed once at startup (Using generated security password: ...)
  • CSRF protection on state-changing requests
  • Security headers: X-Content-Type-Options: nosniff, X-Frame-Options: DENY, Cache-Control on secured responses, and HSTS when serving HTTPS
  • Session fixation protection (session ID rotates at login)
  • BCrypt as the default password encoding via DelegatingPasswordEncoder

These defaults are genuinely good, which is why the correct mental model for configuration is subtractive: you are carving explicit exceptions out of a locked-down baseline, not building security up from nothing.

Mistake 1: The Overly Broad permitAll

The classic. A developer needs /actuator/health open for the load balancer and writes:

http.authorizeHttpRequests(auth -> auth
    .requestMatchers("/actuator/**").permitAll()
    .anyRequest().authenticated()
);

That one wildcard just exposed /actuator/env, /actuator/heapdump, and anything else management exposes. Heap dumps contain credentials, session tokens, and whatever else was in memory. Open exactly what you need:

.requestMatchers("/actuator/health", "/actuator/info").permitAll()

Matcher order matters too: rules are evaluated top-down and the first match wins. A permitAll() placed above a more specific hasRole("ADMIN") rule silently wins. Put specific rules first, broad rules last, and keep anyRequest().authenticated() as the final line, always.

Mistake 2: Disabling CSRF Because a Test Failed

Somewhere in most codebases there is a commit titled "fix 403" that contains http.csrf(csrf -> csrf.disable()). Disabling CSRF is legitimate for a stateless API authenticated purely by bearer tokens, because there is no session cookie for a cross-site request to ride. It is not legitimate for anything using cookie-based sessions, which includes every form-login app and most server-rendered admin UIs.

The honest decision procedure: if the browser sends your auth automatically (cookies), you need CSRF protection. If the client attaches it explicitly (an Authorization header), you can turn it off for those endpoints.

Mistake 3: Hand-Rolled Regex Matchers

Custom request matchers are where authorization bypasses live. Spring Security itself had a critical one here: CVE-2022-22978, an authorization bypass in RegexRequestMatcher where patterns containing . could be bypassed using encoded newline characters. It scored CVSS 9.8 and affected Spring Security 5.4.10, 5.5.6, 5.6.3 and older; fixes landed in 5.4.11, 5.5.7, 5.6.4, and 5.7.

Two takeaways. First, patch cadence on your security framework is not optional; this is exactly the kind of advisory an SCA scanner should page you about the day it publishes, because tools like Safeguard can match spring-security-web versions transitively even when the starter hides them from your pom. Second, prefer the built-in path matchers (requestMatchers("/api/**")) over regex. The built-ins are the well-trodden path; regex matchers are where both your bugs and the framework's bugs concentrate.

Mistake 4: Shipping the Generated Password Mindset to Production

The generated user password is a development convenience, not an auth strategy, yet it is startling how many internal tools go live with spring.security.user.name and spring.security.user.password set in a properties file, checked into git, shared in a wiki. Single shared credentials mean no audit trail, no revocation story, and a secret with the lifespan of "forever".

For anything beyond a prototype, wire a real identity source: OAuth2/OIDC against your IdP via spring-boot-starter-oauth2-resource-server or -oauth2-client, or at minimum a UserDetailsService backed by your user store with BCrypt-hashed passwords. If a properties-file credential must exist temporarily, inject it from a secret manager and set an expiry date on the ticket that created it.

Mistake 5: Copy-Pasting Pre-5.7 Configuration

A huge amount of Spring Security advice on the internet extends WebSecurityConfigurerAdapter. That class was deprecated in Spring Security 5.7 and removed in 6.0, which shipped with Spring Boot 3. Modern configuration is a SecurityFilterChain bean:

@Bean
SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
    http
        .authorizeHttpRequests(auth -> auth
            .requestMatchers("/public/**").permitAll()
            .anyRequest().authenticated())
        .oauth2ResourceServer(oauth2 -> oauth2.jwt(withDefaults()));
    return http.build();
}

The danger of stale snippets is not just compile errors; it is that half-migrated configurations sometimes end up with two filter chains or a chain that matches nothing, and the failure mode of Spring Security misconfiguration is frequently "open", discovered only when someone probes it. If you want a safety net beyond code review, a DAST scan against a staging deployment will tell you which endpoints actually respond without credentials, which is the ground truth your config intends to express.

Keeping the Dependency Itself Healthy

Because the starter is version-managed by Spring Boot, your Spring Security version is effectively your Boot version. That means:

  • Upgrade Boot patch releases promptly; they carry Spring Security patch bumps.
  • Do not pin spring-security-* versions manually in dependencyManagement unless you are consciously overriding for a CVE, and remove the pin at the next Boot upgrade.
  • Track Boot's support lifecycle. Running a Boot line that has left open-source support means the next Spring Security advisory has no free fix for you, whatever your scanner says today.

FAQ

What does spring-boot-starter-security do by default?

It secures every endpoint with HTTP Basic and form login, creates a single user account with a random password logged at startup, enables CSRF protection, adds security response headers, and rotates the session ID on login. All of this activates from the dependency's presence on the classpath alone.

Which dependencies does spring-boot-starter-security include?

It aggregates spring-security-config and spring-security-web, which bring in spring-security-core transitively, plus Spring AOP for method-level security annotations like @PreAuthorize. The versions are managed by your Spring Boot version.

How do I permit public endpoints without weakening everything else?

List the exact paths with requestMatchers(...).permitAll(), keep specific rules above broad ones, and end the chain with anyRequest().authenticated(). Avoid wildcards that cover more than intended, /actuator/** being the canonical example.

Is it safe to disable CSRF in Spring Security?

Only for stateless endpoints authenticated by an explicit header such as a bearer token. If authentication rides on a session cookie, disabling CSRF re-opens the exact attack class the default exists to stop.

Never miss an update

Weekly insights on software supply chain security, delivered to your inbox.