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
SecurityContextearly 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) { ... }| Concern | Setting | Note |
|---|---|---|
| CSRF | Disabled for token APIs, enabled for cookie sessions | If a browser sends the credential automatically, you need CSRF protection |
| CORS | cors(Customizer.withDefaults()) plus a CorsConfigurationSource | Never combine allowedOrigins("*") with credentials |
| Password storage | BCryptPasswordEncoder | Strength 10-12; re-hash on login when you raise it |
| Error responses | 401 for missing/invalid credentials, 403 for insufficient authority | Returning 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.ymlcommitted 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.
Related
REST controllers and dependency injection Observability with Actuator, logging and metrics
Last refreshed 2026-09-18.