Spring Security
Security is not a single annotation—it is a servlet filter chain that runs before DispatcherServlet, establishes who the caller is, and decides what they may do. This chapter covers filter architecture, authentication providers, JWT and OAuth2, method security, and Boot 3's lambda DSL.
Security filter chain architecture
Spring Security integrates as a Servlet Filter—not as MVC interceptor. Every HTTP request passes through a chain of security filters before reaching DispatcherServlet.
Delegation stack
- DelegatingFilterProxy — registered in Servlet container (Boot auto-registers as springSecurityFilterChain); delegates to Spring bean
- FilterChainProxy — selects which SecurityFilterChain matches the request (multiple chains supported)
- SecurityFilterChain — ordered list of Spring Security filters for matched requests
sequenceDiagram participant C as Client participant DFP as DelegatingFilterProxy participant FCP as FilterChainProxy participant SCF as Security filters participant DS as DispatcherServlet C->>DFP: HTTP request DFP->>FCP: delegate FCP->>SCF: run filter chain SCF->>SCF: authenticate and authorize SCF->>DS: chain.doFilter DS-->>C: response
Key filters (conceptual order)
Exact order varies by configuration; these are the filters you debug most often:
| Filter | Role |
|---|---|
| SecurityContextHolderFilter | Loads/clears SecurityContext per request (replaces legacy PersistenceFilter in Security 6) |
| LogoutFilter | Handles /logout |
| UsernamePasswordAuthenticationFilter | Form login POST — extracts username/password, calls AuthenticationManager |
| BasicAuthenticationFilter | HTTP Basic Authorization: Basic … |
| BearerTokenAuthenticationFilter | OAuth2 Resource Server JWT bearer tokens (Boot 3) |
| ExceptionTranslationFilter | Maps AuthenticationException → login redirect / 401; AccessDeniedException → 403 |
| AuthorizationFilter | Replaces FilterSecurityInterceptor in Security 6 — enforces authorizeHttpRequests rules |
Boot 3 registers a default SecurityFilterChain bean when spring-boot-starter-security is on classpath. Your @Bean SecurityFilterChain replaces it. Multiple chains use @Order — lower value = higher priority matching.
Authentication = who are you. Authorization = what may you do. Filters handle authentication; AuthorizationFilter checks access rules before the controller runs.
SecurityContext & SecurityContextHolder
The authenticated user's identity and authorities live in SecurityContext, accessed via SecurityContextHolder—typically ThreadLocal-bound for servlet requests.
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
String username = auth.getName();
Collection<? extends GrantedAuthority> authorities = auth.getAuthorities();
Object principal = auth.getPrincipal(); // UserDetails or JWT claims object
| Strategy | Storage | Use case |
|---|---|---|
| MODE_THREADLOCAL (default) | ThreadLocal | Servlet request per thread — standard MVC |
| MODE_INHERITABLETHREADLOCAL | Inheritable ThreadLocal | Child threads inherit context (use carefully) |
| MODE_GLOBAL | Static singleton | Testing only — never in production |
@Async methods run on different threads—SecurityContextHolder context is lost unless you use DelegatingSecurityContextAsyncTaskExecutor or pass principal explicitly. Same issue with reactive WebFlux—use ReactiveSecurityContextHolder.
Authentication
Authentication proves identity. Spring Security delegates to pluggable providers through a single facade.
sequenceDiagram participant F as AuthFilter participant AM as AuthenticationManager participant AP as AuthenticationProvider participant UDS as UserDetailsService participant PE as PasswordEncoder F->>AM: authenticate token AM->>AP: supports and authenticate AP->>UDS: loadUserByUsername UDS-->>AP: UserDetails AP->>PE: matches raw password hash AP-->>AM: authenticated Authentication AM-->>F: success set SecurityContext
| Component | Role |
|---|---|
| AuthenticationManager | Facade — delegates to one or more AuthenticationProviders |
| AuthenticationProvider | Implements specific auth type (DAO, LDAP, JWT, etc.) |
| UserDetailsService | Loads user by username — you implement against DB/LDAP |
| UserDetails | Username, password hash, authorities, account flags |
| PasswordEncoder | One-way hash verification — never store plaintext passwords |
@Service
class DatabaseUserDetailsService implements UserDetailsService {
private final UserRepository userRepository;
DatabaseUserDetailsService(UserRepository userRepository) {
this.userRepository = userRepository;
}
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
return userRepository.findByEmail(username)
.map(u -> User.builder()
.username(u.getEmail())
.password(u.getPasswordHash())
.roles(u.getRoles().toArray(new String[0]))
.disabled(!u.isActive())
.build())
.orElseThrow(() -> new UsernameNotFoundException(username));
}
}
PasswordEncoder
Store only password hashes. Spring Security 6 defaults to BCrypt; Argon2 is preferred for greenfield high-security apps.
| Algorithm | Class | Recommendation |
|---|---|---|
| BCrypt | BCryptPasswordEncoder | Default, battle-tested — strength 10–12 (2^10–2^12 rounds) |
| Argon2 | Argon2PasswordEncoder | PHC winner — memory-hard; OWASP recommended for new systems |
| PBKDF2 | Pbkdf2PasswordEncoder | FIPS environments; slower than Argon2 on GPUs |
@Bean
PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder(12);
}
// Registration — hash before save
String hash = passwordEncoder.encode(rawPassword);
user.setPasswordHash(hash);
// Login — AuthenticationProvider calls passwordEncoder.matches(raw, hash)
Never use NoOpPasswordEncoder or MD5/SHA1 for passwords. Don't implement custom crypto—use the framework encoders with appropriate strength parameters.
Form login, HTTP Basic & Remember-Me
Built-in authentication mechanisms for browser apps and simple API clients.
@Bean
SecurityFilterChain formLoginChain(HttpSecurity http) throws Exception {
return http
.authorizeHttpRequests(auth -> auth
.requestMatchers("/login", "/css/**").permitAll()
.anyRequest().authenticated()
)
.formLogin(form -> form
.loginPage("/login")
.successHandler((req, res, authentication) -> {
res.sendRedirect("/dashboard");
})
.failureHandler((req, res, ex) -> {
res.sendRedirect("/login?error");
})
)
.build();
}
HTTP Basic
Sends credentials on every request via Authorization: Basic base64(user:pass). Simple for tools/scripts; prefer tokens for production APIs.
http.httpBasic(Customizer.withDefaults());
Remember-Me
Persistent cookie after "remember me" checkbox — uses signed token or persistent token service. Less common in modern token-based SPAs; understand for legacy apps.
http.rememberMe(remember -> remember
.key("unique-and-secret-remember-me-key")
.tokenValiditySeconds(86400 * 14)
.userDetailsService(userDetailsService)
);
Enterprise browser apps: form login + session cookie + CSRF. Mobile/SPA/API: stateless JWT or OAuth2 — disable session creation (SessionCreationPolicy.STATELESS).
Authorization
After authentication, Spring Security evaluates whether the request is allowed—by URL pattern, HTTP method, or later by method-level annotations.
@Bean
SecurityFilterChain apiSecurity(HttpSecurity http) throws Exception {
return http
.securityMatcher("/api/**")
.authorizeHttpRequests(auth -> auth
.requestMatchers(HttpMethod.GET, "/api/public/**").permitAll()
.requestMatchers("/api/admin/**").hasRole("ADMIN")
.requestMatchers("/api/orders/**").hasAnyAuthority("orders:read", "orders:write")
.anyRequest().authenticated()
)
.sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.oauth2ResourceServer(oauth2 -> oauth2.jwt(Customizer.withDefaults()))
.build();
}
Roles vs authorities
| Concept | Format | Usage |
|---|---|---|
| Role | ROLE_ADMIN stored; hasRole("ADMIN") adds prefix automatically | Coarse-grained — admin, user, ops |
| Authority | Exact string — orders:write | Fine-grained permissions — hasAuthority("orders:write") |
hasRole("ADMIN") checks for ROLE_ADMIN — don't double-prefix: storing ROLE_ROLE_ADMIN or calling hasAuthority("ADMIN") when you meant role.
Method security
URL rules aren't enough—enforce permissions on service methods where business logic lives. Enabled with @EnableMethodSecurity (Boot 3).
@Configuration
@EnableMethodSecurity(prePostEnabled = true, securedEnabled = true)
class MethodSecurityConfig {}
@Service
class OrderService {
@PreAuthorize("hasAuthority('orders:read') or #customerId == authentication.name")
public OrderDto getOrder(long orderId, String customerId) { /* ... */ }
@PreAuthorize("hasRole('ADMIN')")
public void deleteOrder(long orderId) { /* ... */ }
@PostAuthorize("returnObject.owner == authentication.name")
public OrderDto getAndVerifyOwner(long orderId) { /* ... */ }
}
| Annotation | When evaluated | SpEL |
|---|---|---|
| @PreAuthorize | Before method | Yes — hasRole, #param |
| @PostAuthorize | After method | Yes — returnObject, #result |
| @Secured | Before method | No — simple role names |
| @RolesAllowed (JSR-250) | Before method | No — standard annotation |
Common SpEL expressions
| Expression | Meaning |
|---|---|
| isAuthenticated() | User is logged in (not anonymous) |
| hasRole('ADMIN') | Has ROLE_ADMIN authority |
| hasAuthority('orders:write') | Exact authority match |
| principal.username | Access principal properties |
| #orderId | Method parameter reference |
| @authz.check(authentication, #id) | Custom bean — complex rules |
Boot 2: @EnableGlobalMethodSecurity(prePostEnabled = true). Boot 3 / Security 6: @EnableMethodSecurity — old annotation removed.
JWT authentication
Stateless APIs authenticate via signed JSON Web Tokens in the Authorization: Bearer header—no server session.
JWT structure
header.payload.signature — Base64URL-encoded segments. Payload contains claims (sub, exp, roles); signature verifies integrity with shared secret or public key.
sequenceDiagram participant C as Client participant F as JwtAuthFilter participant V as JwtValidator participant SCH as SecurityContextHolder C->>F: Bearer eyJhbG... F->>V: parse and verify signature V-->>F: Jwt claims F->>SCH: set Authentication F->>F: filterChain.doFilter
Custom JWT filter
@Component
class JwtAuthenticationFilter extends OncePerRequestFilter {
private final JwtService jwtService;
private final UserDetailsService userDetailsService;
@Override
protected void doFilterInternal(
HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain
) throws ServletException, IOException {
String header = request.getHeader(HttpHeaders.AUTHORIZATION);
if (header == null || !header.startsWith("Bearer ")) {
filterChain.doFilter(request, response);
return;
}
String token = header.substring(7);
try {
String username = jwtService.extractUsername(token);
if (username != null && SecurityContextHolder.getContext().getAuthentication() == null) {
UserDetails user = userDetailsService.loadUserByUsername(username);
if (jwtService.isTokenValid(token, user)) {
var auth = new UsernamePasswordAuthenticationToken(user, null, user.getAuthorities());
auth.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
SecurityContextHolder.getContext().setAuthentication(auth);
}
}
} catch (JwtException ex) {
SecurityContextHolder.clearContext();
response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "Invalid token");
return;
}
filterChain.doFilter(request, response);
}
}
Spring Boot OAuth2 Resource Server (preferred for JWT)
spring:
security:
oauth2:
resourceserver:
jwt:
issuer-uri: https://auth.acme.com/realms/production
Refresh token pattern
- Access token — short-lived (5–15 min), sent on API calls
- Refresh token — long-lived, stored securely, exchanged for new access token at /auth/refresh
- Rotate refresh tokens on use (detect theft); revoke on logout
Algorithm confusion — attacker changes alg to HS256 and signs with public key. Always validate expected algorithm. None algorithm — reject tokens with alg: none. localStorage — XSS steals tokens; prefer httpOnly Secure cookies for browser apps, or memory-only for SPAs with short-lived tokens.
Use established libraries (Nimbus, Spring Resource Server) — don't hand-roll JWT parsing. Validate exp, iss, aud, and clock skew tolerance.
OAuth2 & OpenID Connect
Delegate authentication to an identity provider. Your app becomes OAuth2 Client (login) and/or Resource Server (API token validation).
OAuth2 roles
| Role | Who |
|---|---|
| Resource Owner | End user (owns the data) |
| Client | Your application requesting access |
| Authorization Server | Keycloak, Okta, Auth0 — issues tokens |
| Resource Server | Your API — validates tokens, serves protected resources |
Grant types (modern usage)
| Grant | Use case | Notes |
|---|---|---|
| Authorization Code + PKCE | SPAs, mobile, user login | Only recommended public client flow — PKCE replaces implicit |
| Client Credentials | Service-to-service (no user) | Machine clients with client_id + secret |
| Refresh Token | Obtain new access token | Always with rotation in production |
OAuth2 Client — login with Keycloak/Okta
spring:
security:
oauth2:
client:
registration:
keycloak:
client-id: order-service
client-secret: ${KEYCLOAK_CLIENT_SECRET}
scope: openid,profile,email
authorization-grant-type: authorization_code
redirect-uri: "{baseUrl}/login/oauth2/code/{registrationId}"
provider:
keycloak:
issuer-uri: https://auth.acme.com/realms/production
@Service
class DownstreamClient {
private final WebClient webClient;
OrderDto fetchOrder(
@RegisteredOAuth2AuthorizedClient("keycloak") OAuth2AuthorizedClient client,
long orderId
) {
String token = client.getAccessToken().getTokenValue();
return webClient.get()
.uri("/orders/{id}", orderId)
.headers(h -> h.setBearerAuth(token))
.retrieve()
.bodyToMono(OrderDto.class)
.block();
}
}
Resource Server — JWT validation vs introspection
| Approach | How | When |
|---|---|---|
| JWT (local) | Verify signature with JWKS from issuer | Default — fast, no network per request |
| Opaque token introspection | POST token to /oauth2/introspect | Revocable opaque tokens; higher latency |
Keycloak/Okta: configure client scopes for roles or custom claims mapped to GrantedAuthority via JwtAuthenticationConverter. Sync groups to app permissions at token conversion time—not on every DB hit.
CSRF, CORS & security headers
Defense in depth beyond authentication—protect browser clients from cross-site attacks and harden HTTP responses.
CSRF — Cross-Site Request Forgery
Attacker tricks a logged-in user's browser into submitting a request to your site with the user's session cookie. Spring's synchronizer token pattern requires a CSRF token (header or form field) matching server-side session state.
@Bean
SecurityFilterChain statelessApi(HttpSecurity http) throws Exception {
return http
.csrf(csrf -> csrf.disable()) // OK when no session cookie auth
.sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(auth -> auth.anyRequest().authenticated())
.oauth2ResourceServer(oauth2 -> oauth2.jwt(Customizer.withDefaults()))
.build();
}
| App type | CSRF |
|---|---|
| Session + cookie browser app | Enable — Spring default |
| JWT Bearer stateless API | Disable — no cookie auth to exploit |
| SPA + session cookie | Enable + expose CSRF token via endpoint; send in header |
Security headers
Spring Security configures defaults; customize via headers() DSL:
| Header | Purpose |
|---|---|
| X-Content-Type-Options: nosniff | Prevent MIME sniffing attacks |
| X-Frame-Options: DENY | Clickjacking protection — or CSP frame-ancestors |
| Strict-Transport-Security | Force HTTPS (HSTS) — enable when TLS terminated at app or trusted proxy |
| Content-Security-Policy | Restrict script/style/load sources — strongest XSS mitigation |
http.headers(headers -> headers
.contentSecurityPolicy(csp -> csp
.policyDirectives("default-src 'self'; frame-ancestors 'none'; script-src 'self'")
)
.frameOptions(HeadersConfigurer.FrameOptionsConfig::deny)
.httpStrictTransportSecurity(hsts -> hsts
.includeSubDomains(true)
.maxAgeInSeconds(31536000)
)
);
CORS in security filter chain
MVC WebMvcConfigurer.addCorsMappings may not apply to security-blocked preflight requests—configure CORS on HttpSecurity:
@Bean
CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration config = new CorsConfiguration();
config.setAllowedOrigins(List.of("https://app.acme.com"));
config.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "OPTIONS"));
config.setAllowedHeaders(List.of("*"));
config.setAllowCredentials(true);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/api/**", config);
return source;
}
@Bean
SecurityFilterChain chain(HttpSecurity http, CorsConfigurationSource cors) throws Exception {
return http.cors(c -> c.configurationSource(cors)).build();
}
Disabling CSRF on session-based apps is a critical vulnerability. Enabling CSRF on APIs that use only Bearer tokens breaks clients unnecessarily—match CSRF policy to auth mechanism.
Spring Security 6 (Boot 3) changes
Security 6 is a breaking cleanup—deprecated APIs removed, lambda DSL mandatory, clearer defaults for modern apps.
| Boot 2 / Security 5 | Boot 3 / Security 6 |
|---|---|
| Extend WebSecurityConfigurerAdapter | Removed — define @Bean SecurityFilterChain |
| authorizeRequests() | authorizeHttpRequests() |
| antMatchers() | requestMatchers() |
| Chaining .and() | Lambda DSL — each config block is lambda |
| @EnableGlobalMethodSecurity | @EnableMethodSecurity |
| FilterSecurityInterceptor | AuthorizationFilter |
| javax.* imports | jakarta.* |
@Configuration
@EnableWebSecurity
@EnableMethodSecurity
class SecurityConfig {
@Bean
SecurityFilterChain defaultChain(HttpSecurity http) throws Exception {
return http
.authorizeHttpRequests(auth -> auth
.requestMatchers("/actuator/health").permitAll()
.requestMatchers("/api/public/**").permitAll()
.anyRequest().authenticated()
)
.formLogin(Customizer.withDefaults())
.httpBasic(Customizer.withDefaults())
.csrf(Customizer.withDefaults())
.build();
}
@Bean
PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
}
Security 6 requires Java 17+. SecurityFilterChain beans are composed in order; use @Order(1) for higher-priority chains (e.g. actuator on separate matcher). Default password encoder is DelegatingPasswordEncoder supporting multiple prefix formats ({bcrypt}, {argon2}).
Migration story: component-based security configuration replaces inheritance; lambda DSL prevents incomplete configs; method security annotation rename; path matching uses requestMatchers with MVC's PathPattern parser.
Spring Security 6.0 shipped with Spring Boot 3.0 (Nov 2022). Security 6.2+ adds improved OAuth2 and one-time token login support. Spring Security 7 will align with Boot 4 / Spring Framework 7.