StackWise

Spring Security Fundamentals

The security filter chain, authentication vs authorization, SecurityFilterChain configuration, password encoding, method security and CSRF/CORS.

intermediate 3 min readLesson 14 of 23

Authentication vs authorization

  • Authentication — who are you? Verifying identity (password, token, certificate).
  • Authorization — what may you do? Checking permissions for a request or method.

Spring Boot 4 ships Spring Security 7. Adding spring-boot-starter-security immediately secures every endpoint: all requests need authentication, a login form and HTTP Basic are enabled, and a random password is printed at startup. You then replace those defaults with your own SecurityFilterChain.

Architecture: a chain of filters

flowchart LR
  R[HTTP request] --> D[DelegatingFilterProxy]
  D --> F[FilterChainProxy]
  F --> C{"Which SecurityFilterChain<br/>matches the URL?"}
  C --> S1["/api/** chain<br/>CORS → CSRF → BearerToken → ExceptionTranslation → Authorization"]
  C --> S2["/** chain<br/>CSRF → FormLogin → Session → Authorization"]
  S1 --> DS[DispatcherServlet → controller]
  S2 --> DS

Spring Security is a Chain of Responsibility: a servlet filter delegates to one of your SecurityFilterChains, each a list of filters. Authentication filters populate the SecurityContext (held in a ThreadLocal via SecurityContextHolder); the AuthorizationFilter near the end decides whether the request may proceed.

flowchart LR
  AF["Authentication filter<br/>(extracts credentials)"] --> AM[AuthenticationManager]
  AM --> AP1["DaoAuthenticationProvider<br/>UserDetailsService + PasswordEncoder"]
  AM --> AP2["JwtAuthenticationProvider"]
  AP1 --> SC[("SecurityContext<br/>Authentication + authorities")]
  AP2 --> SC

Configuring a SecurityFilterChain

@Configuration
@EnableWebSecurity
@EnableMethodSecurity                                   // enables @PreAuthorize
class SecurityConfig {

    @Bean
    SecurityFilterChain api(HttpSecurity http) throws Exception {
        return http
            .securityMatcher("/api/**")
            .authorizeHttpRequests(auth -> auth
                .requestMatchers(HttpMethod.GET, "/api/products/**").permitAll()
                .requestMatchers("/api/admin/**").hasRole("ADMIN")
                .requestMatchers(HttpMethod.POST, "/api/orders").hasAuthority("SCOPE_orders:write")
                .anyRequest().authenticated())
            .oauth2ResourceServer(oauth -> oauth.jwt(Customizer.withDefaults()))   // stateless JWT auth
            .sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
            .csrf(csrf -> csrf.disable())                  // OK for stateless token APIs only
            .cors(Customizer.withDefaults())
            .build();
    }

    @Bean
    SecurityFilterChain actuator(HttpSecurity http) throws Exception {
        return http
            .securityMatcher(EndpointRequest.toAnyEndpoint())
            .authorizeHttpRequests(auth -> auth
                .requestMatchers(EndpointRequest.to("health", "info")).permitAll()
                .anyRequest().hasRole("OPS"))
            .httpBasic(Customizer.withDefaults())
            .build();
    }
}

Users and passwords

For username/password login, provide a UserDetailsService and a PasswordEncoder:

@Bean
PasswordEncoder passwordEncoder() {
    return PasswordEncoderFactories.createDelegatingPasswordEncoder();   // bcrypt by default, upgradeable
}

@Bean
UserDetailsService users(UserRepository repo) {
    return username -> repo.findByEmail(username)
        .map(u -> User.withUsername(u.getEmail())
                      .password(u.getPasswordHash())            // e.g. "{bcrypt}$2a$10$..."
                      .roles(u.getRoles().toArray(String[]::new))
                      .build())
        .orElseThrow(() -> new UsernameNotFoundException(username));
}

Roles vs authorities

An Authentication carries a list of GrantedAuthority strings. A role is just an authority with the ROLE_ prefix: hasRole("ADMIN") checks for ROLE_ADMIN. JWT scopes become SCOPE_… authorities by default.

Method security

@Service
class AccountService {
    @PreAuthorize("hasRole('ADMIN') or #accountId == authentication.name")
    public Account get(String accountId) { ... }

    @PreAuthorize("hasAuthority('SCOPE_payments:write')")
    public Transfer transfer(TransferCommand cmd) { ... }

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

Method security uses AOP proxies — the same self-invocation caveat as @Transactional applies.

CSRF and CORS

  • CSRF protects cookie/session-based apps from cross-site forged requests. Keep it enabled for browser apps with sessions; it’s safe to disable for stateless APIs authenticated by an Authorization header (browsers don’t attach that header automatically).
  • CORS controls which other origins a browser lets call your API. Configure it explicitly:
@Bean
CorsConfigurationSource corsConfigurationSource() {
    var config = new CorsConfiguration();
    config.setAllowedOrigins(List.of("https://app.shop.example"));
    config.setAllowedMethods(List.of("GET", "POST", "PATCH", "DELETE"));
    config.setAllowedHeaders(List.of("Authorization", "Content-Type"));
    var source = new UrlBasedCorsConfigurationSource();
    source.registerCorsConfiguration("/api/**", config);
    return source;
}

Accessing the current user

@GetMapping("/api/me")
MeResponse me(@AuthenticationPrincipal Jwt jwt) {
    return new MeResponse(jwt.getSubject(), jwt.getClaimAsString("email"));
}

Elsewhere: SecurityContextHolder.getContext().getAuthentication(). Note the context is thread-bound — with @Async or custom executors, propagate it (e.g. DelegatingSecurityContextExecutor; Spring Boot’s task executors and virtual-thread setup can do this for you).

Finished this lesson?

Mark it complete to track your progress through Spring Boot 4.