Security

Add the starter

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

Every endpoint is now protected, and a generated password is printed at startup. That default is a reminder to configure it, not a setup.

A filter chain

@Configuration
@EnableWebSecurity
public class SecurityConfig {

    @Bean
    SecurityFilterChain api(HttpSecurity http) throws Exception {
        return http
            .authorizeHttpRequests(auth -> auth
                .requestMatchers("/actuator/health", "/public/**").permitAll()
                .requestMatchers(HttpMethod.GET, "/api/books/**").hasRole("USER")
                .requestMatchers("/api/**").hasRole("ADMIN")
                .anyRequest().authenticated())
            .csrf(csrf -> csrf.disable())        // stateless API with tokens only
            .httpBasic(Customizer.withDefaults())
            .build();
    }
}

Rules are matched top to bottom — put the specific ones first.

Do not disable CSRF blindly

Turn it off only for stateless APIs authenticated by a token or basic auth. A session-cookie app needs CSRF protection.

Users

For a demo:

@Bean
UserDetailsService users(PasswordEncoder encoder) {
    return new InMemoryUserDetailsManager(
        User.withUsername("ann").password(encoder.encode("secret")).roles("ADMIN").build());
}

@Bean
PasswordEncoder passwordEncoder() {
    return PasswordEncoderFactories.createDelegatingPasswordEncoder();
}

In production, back UserDetailsService with your database — or do not manage passwords at all and use OAuth2/OIDC.

JWT resource server

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-oauth2-resource-server</artifactId>
</dependency>
spring.security.oauth2.resourceserver.jwt.issuer-uri=https://auth.example.com/realms/demo
http.oauth2ResourceServer(oauth -> oauth.jwt(Customizer.withDefaults()));

Spring validates signature, issuer and expiry, and populates the Authentication.

Method security

@Configuration
@EnableMethodSecurity
class MethodSecurityConfig {}
@PreAuthorize("hasRole('ADMIN')")
public void delete(long id) {  }

@PreAuthorize("#username == authentication.name")
public Profile profile(String username) {  }

@PostAuthorize("returnObject.owner == authentication.name")
public Document load(long id) {  }

The current user

@GetMapping("/me")
String me(Authentication auth) {
    return auth.getName();
}

@GetMapping("/claims")
Map<String, Object> claims(@AuthenticationPrincipal Jwt jwt) {
    return jwt.getClaims();
}

Checklist

  • HTTPS everywhere; server.ssl.* or TLS at the proxy
  • passwords hashed with bcrypt/argon2 — never encrypted, never plain
  • secrets from the environment, not from application.properties
  • deny by default: anyRequest().authenticated() as the last rule
  • keep error responses vague: no “unknown user” vs “wrong password”
  • dependencies patched — check with ./mvnw versions:display-dependency-updates

★ Exercises

  1. Add the security starter and log in with the generated password.
  2. Configure two users with roles USER and ADMIN.
  3. Allow anonymous GET /api/books, require ADMIN for DELETE.
  4. Protect a service method with @PreAuthorize and test the denial.
  5. Add /actuator/health to the public matchers — why is that one usually fine?