Securing the API with Spring Security

The filter chain, stateless JWT resource servers, method security, password hashing, and the CORS and CSRF decisions you must make.

The filter chain

@Configuration
@EnableMethodSecurity
class SecurityConfig {

  @Bean
  SecurityFilterChain api(HttpSecurity http) throws Exception {
    http
      .securityMatcher("/api/**")
      .csrf(csrf -> csrf.disable())          // stateless, token-authenticated
      .sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
      .authorizeHttpRequests(a -> a
          .requestMatchers("/api/public/**").permitAll()
          .requestMatchers(HttpMethod.GET, "/api/books/**").hasAuthority("SCOPE_books.read")
          .anyRequest().authenticated())
      .oauth2ResourceServer(o -> o.jwt(j -> j.jwtAuthenticationConverter(converter())));
    return http.build();
  }

  @Bean
  PasswordEncoder passwords() { return new BCryptPasswordEncoder(); }
}
  • Security is a chain of filters: authentication, then authorization, then your controller. Anything that clears the SecurityContext early breaks everything after it.
  • With JWT the server validates a signature, so it is stateless and scales horizontally - but tokens cannot be revoked before they expire, so keep lifetimes short and pair them with refresh tokens.
  • Rules are evaluated top to bottom. Put specific matchers before anyRequest().
💡
Never store a password with a fast hash. BCryptPasswordEncoder (strength 10-12) or Argon2PasswordEncoder are the defaults to reach for; MD5 and SHA-256 are not password hashing.

Method security and ownership checks

@PreAuthorize("hasRole('ADMIN') or #ownerId == authentication.name")
public Order getOrder(String ownerId, Long orderId) { ... }

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

@PreAuthorize("@perm.canEdit(authentication, #id)")
public void update(Long id, UpdateDto dto) { ... }
ConcernSettingNote
CSRFDisabled for token APIs, enabled for cookie sessionsIf a browser sends the credential automatically, you need CSRF protection
CORScors(Customizer.withDefaults()) plus a CorsConfigurationSourceNever combine allowedOrigins("*") with credentials
Password storageBCryptPasswordEncoderStrength 10-12; re-hash on login when you raise it
Error responses401 for missing/invalid credentials, 403 for insufficient authorityReturning 403 for an unauthenticated caller tells an attacker the resource exists

Role checks alone are not authorization. Check ownership too: hasRole('USER') says the caller is logged in, not that the order belongs to them.

Hardening and secrets

spring:
  security:
    oauth2:
      resourceserver:
        jwt:
          issuer-uri: https://login.example.com/realms/main
          # or jwk-set-uri for a static key source

management:
  endpoints:
    web:
      exposure:
        include: health,info
  endpoint:
    health:
      show-details: when-authorized
  • Keep signing keys and client secrets in a vault or environment injection, never in application.yml committed to git.
  • Validate the issuer and audience, not just the signature - any token from the same key can otherwise be replayed.
  • Actuator endpoints bypass your controller security unless the management port or the filter chain covers them.

FAQ

Session or JWT for a browser app?
Sessions stored server-side with an httpOnly cookie are simpler to revoke and safer against token theft. JWTs suit service-to-service calls and mobile clients where there is no cookie jar. Do not use JWTs merely because it is fashionable.
Why do I get 403 instead of 401?
Likely the request was treated as authenticated but lacked authority, or an exception handler replaced the response. Check the filter chain order and make sure the authentication entry point is not overridden by a custom error controller.

REST controllers and dependency injection Observability with Actuator, logging and metrics

Last refreshed 2026-09-18.