// filter chain · authentication · authorization · OAuth2 resource server · JWT · method security · senior → principal
DelegatingFilterProxy (a standard servlet filter registered with the servlet container), which delegates to FilterChainProxy. FilterChainProxy holds one or more SecurityFilterChain beans — each with a request matcher and an ordered list of security filters.
On every request: the chain is traversed. Each filter handles one concern (CSRF, session management, authentication, authorization). The final filter, FilterSecurityInterceptor (or AuthorizationFilter in modern config), enforces the access rules configured in HttpSecurity.
You configure the chain via SecurityFilterChain bean: java @Bean public SecurityFilterChain api(HttpSecurity http) throws Exception {
return http
.authorizeHttpRequests(a -> a
.requestMatchers("/public/**").permitAll()
.anyRequest().authenticated())
.oauth2ResourceServer(o -> o.jwt(Customizer.withDefaults()))
.build();
}
When a request carries credentials, an authentication filter (e.g., UsernamePasswordAuthenticationFilter, BearerTokenAuthenticationFilter) extracts them and calls AuthenticationManager.authenticate(Authentication token).
ProviderManager (the default AuthenticationManager) delegates to a list of AuthenticationProvider implementations until one succeeds or all fail.
- DaoAuthenticationProvider + UserDetailsService: loads the user by username,
verifies the password with PasswordEncoder, returns a populated Authentication.
- JwtAuthenticationProvider: validates a JWT signature and expiry, maps claims to
GrantedAuthority objects.
On success, the populated Authentication object is stored in SecurityContextHolder.
SecurityContextHolder is the central store for the current principal. By default it uses a ThreadLocal, so the context is available anywhere in the same thread without explicit passing.
The lifecycle per request: 1. SecurityContextPersistenceFilter (or SecurityContextHolderFilter) loads the context
from the session (or starts a new empty one for stateless APIs).
2. Authentication filters populate it on successful authentication. 3. The context is available throughout the request thread. 4. After the response is sent, the filter clears the ThreadLocal to prevent leaks
across thread pool reuse.
Access the principal: SecurityContextHolder.getContext().getAuthentication(). In Spring MVC controllers: @AuthenticationPrincipal injects the current user directly.
PasswordEncoder interface. The standard implementation is BCryptPasswordEncoder — bcrypt is adaptive (the work factor can be increased as hardware gets faster) and includes a random salt per hash, so two users with the same password produce different hashes.
Never store plaintext passwords. Never use MD5 or SHA-1 (unsalted, fast — trivially cracked by rainbow tables or GPU brute force).
The DelegatingPasswordEncoder (returned by PasswordEncoderFactories.createDelegatingPasswordEncoder()) prefixes hashes with the algorithm ID ({bcrypt}, {argon2}, etc.), enabling hash migration without invalidating existing passwords — add the new encoder, old hashes continue to work, users re-hash on next login.
SecurityFilterChain via authorizeHttpRequests(). Rules are evaluated in order — first match wins.
java .authorizeHttpRequests(a -> a
.requestMatchers("/admin/**").hasRole("ADMIN")
.requestMatchers(HttpMethod.GET, "/api/**").hasAuthority("read:data")
.anyRequest().authenticated())
Method-level authorization: enabled with @EnableMethodSecurity. Annotations on service methods: - @PreAuthorize("hasRole('ADMIN')") — checked before the method runs (Spring SpEL). - @PostAuthorize("returnObject.owner == authentication.name") — checked after, on the return value. - @PreFilter / @PostFilter — filter collections before/after method execution.
Method security also uses AOP proxies — self-calls bypass the proxy and skip the check.
spring-boot-starter-oauth2-resource-server and configure:
yaml spring:
security:
oauth2:
resourceserver:
jwt:
issuer-uri: https://auth.example.com
Spring Security auto-configures BearerTokenAuthenticationFilter which extracts the Authorization: Bearer <token> header, validates the JWT signature against the JWKS endpoint (fetched from issuer-uri/.well-known/openid-configuration), verifies expiry and issuer, and populates SecurityContext with a JwtAuthenticationToken.
Custom claims → authorities: implement JwtAuthenticationConverter to map JWT claims (e.g., roles, scope) to GrantedAuthority objects.
http.csrf(csrf -> csrf.disable()).
CORS: browsers block cross-origin requests unless the server sends correct Access-Control-Allow-* headers. Configure in Spring Security (not just @CrossOrigin) so CORS pre-flight (OPTIONS) requests are handled before the security filter chain rejects them as unauthenticated: http.cors(Customizer.withDefaults()) + a CorsConfigurationSource bean.
Session management: for stateless APIs, use http.sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS)). This prevents Spring Security from creating or using HTTP sessions — every request must carry its own token.
http.cors(Customizer.withDefaults()) in HttpSecurity, not just via @CrossOrigin on controllers. Spring Security's CORS filter runs before authentication filters when configured this way.
hasRole("ADMIN") automatically prepends ROLE_ and checks for authority ROLE_ADMIN. hasAuthority("ROLE_ADMIN") checks the exact string. Both work if your authorities follow the ROLE_ convention. If you control the authority strings (e.g., OAuth2 scopes like read:orders), use hasAuthority("read:orders") — hasRole would check for ROLE_read:orders. Mixing the two on the same app creates confusing bugs where access is denied despite the authority appearing in the token.
SecurityContextHolder uses a ThreadLocal by default. When @Async dispatches to another thread, the security context is not copied — the async thread sees an empty context. Any code calling SecurityContextHolder.getContext().getAuthentication() in an async method returns null. Fix: configure DelegatingSecurityContextAsyncTaskExecutor as the @Async executor, or set strategy to MODE_INHERITABLETHREADLOCAL. Same issue applies to CompletableFuture.supplyAsync() — always propagate explicitly.
@Transactional. Calling a @PreAuthorize-annotated method from within the same bean bypasses the proxy and skips the authorization check. This is a silent security hole — no exception, no log, the method runs as if the annotation wasn't there. Fix: move the annotated method to a separate Spring bean so the call goes through the proxy.
permitAll() means the access decision allows unauthenticated requests — it does not skip authentication filters. If your JWT filter throws an exception for a malformed or expired token, that exception propagates even for permitAll() paths. A public endpoint that receives an invalid JWT still returns 401. Fix: configure your JWT filter to ignore missing or malformed tokens on public paths (don't throw, just don't set the context), or check Authentication in the filter and only reject actually invalid tokens that are present.
NimbusJwtDecoder caches the JWKS (public keys) from the issuer-uri. When the authorization server rotates keys, the cached key set is stale — new tokens signed with the new key fail validation with 401. The decoder refreshes the cache periodically (default: when a key ID in the token doesn't match any cached key, it triggers a fetch). Configure jwkSetUri directly and set NimbusJwtDecoder.withJwkSetUri(uri).cache(...) to tune the cache policy if your authorization server rotates keys frequently.
| SecurityContextHolderFilter | Loads SecurityContext from the repository (session or no-op for stateless). Clears it after the response is sent. |
| CorsFilter | Handles CORS pre-flight (OPTIONS) and adds Access-Control-* headers. Must run before authentication filters to allow unauthenticated OPTIONS. |
| CsrfFilter | Validates the CSRF token on state-changing requests (POST/PUT/DELETE). Disabled for stateless JWT APIs. |
| LogoutFilter | Handles logout requests. Clears SecurityContext, invalidates session, triggers LogoutHandler chain. |
| UsernamePasswordAuthenticationFilter | For form-based login. Extracts username/password, calls AuthenticationManager, stores Authentication on success. |
| BearerTokenAuthenticationFilter | For OAuth2 Resource Server. Extracts Bearer token from Authorization header, validates JWT, populates SecurityContext. |
| BasicAuthenticationFilter | For HTTP Basic auth. Extracts Base64-encoded credentials, calls AuthenticationManager. |
| AnonymousAuthenticationFilter | If no Authentication has been set by earlier filters, sets an AnonymousAuthenticationToken. Ensures SecurityContext is never null. |
| ExceptionTranslationFilter | Catches AuthenticationException (→ 401) and AccessDeniedException (→ 403) and converts them to HTTP error responses. |
| AuthorizationFilter | Final access decision. Evaluates the authorizeHttpRequests() rules against the current Authentication and request. |
| @EnableMethodSecurity | Required on a @Configuration class to activate @PreAuthorize, @PostAuthorize, @PreFilter, @PostFilter. Replaces @EnableGlobalMethodSecurity (deprecated in Spring Security 5.6). |
| @PreAuthorize("expr") | Evaluates SpEL before method execution. Access authentication, method arguments, beans. Most flexible annotation. Fails with AccessDeniedException. |
| @PostAuthorize("returnObject...") | Evaluates SpEL after method execution against the return value. Use for ownership checks: @PostAuthorize("returnObject.ownerId == authentication.name"). |
| @PreFilter("filterObject...") | Filters a collection argument before the method runs. Removes elements the caller is not allowed to access. |
| @PostFilter("filterObject...") | Filters the returned collection. Caller only sees elements they have access to. |
| @Secured({"ROLE_ADMIN"}) | Simple role check. Less flexible than @PreAuthorize — no SpEL. Use @PreAuthorize unless you need @Secured for backward compatibility. |
| @AuthenticationPrincipal | In MVC controller method parameters: injects the current principal (UserDetails, Jwt, or custom type) directly. Avoids SecurityContextHolder.getContext() boilerplate. |
| authorizeHttpRequests() | Define URL-based access rules. First matching rule wins. Use requestMatchers(pattern) not antMatchers (deprecated in Boot 3). |
| csrf(csrf -> csrf.disable()) | Disable CSRF protection. Required for stateless JWT REST APIs. Never disable for session-based form apps. |
| cors(Customizer.withDefaults()) | Enable CORS handling via a CorsConfigurationSource bean. CORS filter runs before auth filters — handles pre-flight OPTIONS correctly. |
| sessionManagement(s -> s.sessionCreationPolicy(STATELESS)) | Prevent Spring Security from creating or using HTTP sessions. Each request must be independently authenticated. |
| oauth2ResourceServer(o -> o.jwt(...)) | Configure JWT Bearer token validation. Auto-discovers JWKS from issuer-uri. Customize with JwtAuthenticationConverter for claims-to-authorities mapping. |
| formLogin(Customizer.withDefaults()) | Enable form-based login. Not for REST APIs. Configures UsernamePasswordAuthenticationFilter. |
| httpBasic(Customizer.withDefaults()) | Enable HTTP Basic auth. Appropriate for service-to-service or test environments. Not for production user-facing APIs. |
| exceptionHandling(e -> e.authenticationEntryPoint(...)) | Customize the 401 response for unauthenticated requests. Default sends WWW-Authenticate header. For REST APIs, return JSON. |
| addFilterBefore(filter, Class) | Insert a custom filter before a specific filter in the chain. Use for custom token extraction or pre-processing. |
| Dimension | Spring Security | Custom JWT Filter | API Gateway (Kong / AWS ALB) | Keycloak / Auth0 |
|---|---|---|---|---|
| Who validates tokens | Application (per-service, Spring Security filter) | Application (per-service, custom code) | Gateway (once, before routing) | External auth server (authorization server role only) |
| Granularity | Method-level (@PreAuthorize), URL-level, Spring context-aware | URL-level or method-level via custom code | URL-level, route-level only; no method security | Manages users/roles; Spring Security or gateway enforces them |
| Spring integration | Deep — works with @AuthenticationPrincipal, SpEL, test support | Manual — no framework integration, manual context population | None — strips auth before service sees it | Spring Security acts as the OAuth2 client/resource server consuming Keycloak tokens |
| Ops burden | Low — library, no infra | Low — library, but you own all logic | High — gateway cluster to operate and scale | High — Keycloak cluster to run, update, backup |
| Test support | First-class — @WithMockUser, @WithUserDetails, SecurityMockMvcRequestPostProcessors | Manual — inject mock context | Hard to test security rules without a running gateway | Testcontainers Keycloak for integration tests |
| Multi-service consistency | Per-service config — can diverge | Per-service code — can diverge | Centralized — consistent across all routed services | Centralized at auth server; each service still needs its own enforcement |
| Best for | Spring Boot services needing fine-grained, context-aware access control | Simple services with minimal auth needs and no desire for framework magic | Enforcing network-level policies (rate limiting, auth) before traffic reaches services | User management, SSO, federated identity; paired with Spring Security for enforcement |
Every HTTP request passes through DelegatingFilterProxy (a standard servlet filter), which delegates to FilterChainProxy. FilterChainProxy matches the request against configured SecurityFilterChain beans (by URL pattern or other matchers) and passes the request through that chain's ordered filters.
For a JWT-secured API, the key filters in order: 1. SecurityContextHolderFilter — loads or creates an empty SecurityContext. 2. CorsFilter — adds Access-Control-* headers (handles OPTIONS pre-flight). 3. CsrfFilter — validates CSRF token (disabled for stateless APIs). 4. BearerTokenAuthenticationFilter — extracts Authorization: Bearer <token>,
calls AuthenticationManager, which calls JwtAuthenticationProvider, which
validates the JWT and populates SecurityContext.
5. AnonymousAuthenticationFilter — sets an anonymous token if still unauthenticated. 6. ExceptionTranslationFilter — catches AuthenticationException (→ 401) and
AccessDeniedException (→ 403).
7. AuthorizationFilter — evaluates authorizeHttpRequests() rules against the
current Authentication. If denied, throws AccessDeniedException.
If all filters pass, the request reaches the DispatcherServlet and your controller.
BearerTokenAuthenticationFilter sees no authentication. A filter after AuthorizationFilter only runs on authorized requests. Multiple SecurityFilterChain beans can match different URL patterns with different configurations — e.g., one chain for /api/** (stateless JWT) and another for /admin/** (form login with sessions). FilterChainProxy uses the first matching chain per request.spring-boot-starter-oauth2-resource-server. Configure the issuer:
yaml spring:
security:
oauth2:
resourceserver:
jwt:
issuer-uri: https://auth.example.com
Spring Security auto-discovers the JWKS endpoint from issuer-uri/.well-known/openid-configuration, configures a NimbusJwtDecoder, and wires BearerTokenAuthenticationFilter.
SecurityFilterChain configuration: java @Bean public SecurityFilterChain api(HttpSecurity http) throws Exception {
return http
.csrf(csrf -> csrf.disable())
.sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(a -> a
.requestMatchers("/public/**").permitAll()
.anyRequest().authenticated())
.oauth2ResourceServer(o -> o.jwt(Customizer.withDefaults()))
.build();
}
To map JWT claims to authorities (e.g., a roles claim): java @Bean public JwtAuthenticationConverter jwtAuthConverter() {
JwtGrantedAuthoritiesConverter conv = new JwtGrantedAuthoritiesConverter();
conv.setAuthoritiesClaimName("roles");
conv.setAuthorityPrefix("ROLE_");
JwtAuthenticationConverter jac = new JwtAuthenticationConverter();
jac.setJwtGrantedAuthoritiesConverter(conv);
return jac;
}issuer-uri auto-discovery approach is clean for production but requires network access at startup. For environments where the auth server isn't reachable during service startup (cold environments, CI), configure jwk-set-uri directly: spring.security.oauth2.resourceserver.jwt.jwk-set-uri: https://auth.example.com/.well-known/jwks.json. This skips the discovery call.
For multi-issuer setups (tokens from multiple auth servers), implement a custom AuthenticationManagerResolver<HttpServletRequest> that selects the appropriate JwtDecoder based on the token's iss claim or a request header.evil.com can make a user's browser send a POST to bank.com — if bank.com uses session cookies for auth, the request is authenticated. The CSRF token (a random value in a form field or header, not a cookie) prevents this — the attacker can't read the CSRF token from a different origin.
When to disable: for stateless APIs using JWT Bearer tokens in Authorization headers. Bearer tokens are not automatically sent by browsers — the attacker can't forge an authenticated request. Disable: http.csrf(csrf -> csrf.disable()).
Never disable for session-based apps that use cookies for authentication.
CORS: configure via http.cors(Customizer.withDefaults()) + a CorsConfigurationSource bean. This ensures the CorsFilter runs before authentication filters, so OPTIONS pre-flight requests (which carry no credentials) are handled correctly:
java @Bean public CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration config = new CorsConfiguration();
config.setAllowedOrigins(List.of("https://app.example.com"));
config.setAllowedMethods(List.of("GET","POST","PUT","DELETE","OPTIONS"));
config.setAllowedHeaders(List.of("Authorization","Content-Type"));
config.setAllowCredentials(true);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", config);
return source;
}allowedOrigins("*")) doesn't expose you to CSRF because CSRF requires a browser. But it does allow any browser-based JavaScript on any site to make authenticated requests to your API — which is a different risk if your API uses cookies. For JWT Bearer APIs with no cookies, allowedOrigins("*") is acceptable for truly public APIs. For anything that should only be called from your own frontend, restrict to your domain explicitly.@PreAuthorize is a method security annotation that evaluates a Spring Expression Language (SpEL) expression before the method runs. If the expression returns false, AccessDeniedException is thrown (→ 403).
Enable with @EnableMethodSecurity on a @Configuration class.
Available in the SpEL context: - authentication — the current Authentication object - principal — authentication.getPrincipal() - hasRole('ADMIN') — checks for ROLE_ADMIN authority - hasAuthority('read:orders') — checks exact authority string - #variableName — references method parameter by name
java @PreAuthorize("hasRole('ADMIN') or #userId == authentication.name") public UserProfile getProfile(String userId) { ... }
Limitations: - Works via AOP proxy — self-calls within the same bean bypass the check silently. - Requires Spring context — testing the method directly without Spring bypasses security. - SpEL evaluation adds overhead on every call — for hot paths, consider HTTP-level rules.@PostAuthorize is underused but valuable for ownership checks where you can't determine access without loading the resource:
java @PostAuthorize("returnObject.ownerId == authentication.name") public Order getOrder(Long orderId) { ... }
The method loads the order from DB, then the expression checks if the current user owns it — avoids a separate ownership query. The cost: the DB query runs even if access is denied. For high-frequency endpoints, pre-check ownership with a specialized query in the service layer instead. @PostFilter filters returned collections — but loading 10,000 records to filter down to 10 is wasteful; use @PreAuthorize with a query that incorporates the ownership clause.@WebMvcTest with security enabled (default): the security filter chain runs during MockMvc tests. Without credentials, secured endpoints return 401/403.
@WithMockUser: creates a mock authenticated user with specified roles: java @Test @WithMockUser(roles = "ADMIN") void adminEndpointReturns200() throws Exception {
mockMvc.perform(get("/admin/dashboard"))
.andExpect(status().isOk());
}
@WithUserDetails("username"): loads an actual UserDetails from UserDetailsService — tests with real user data.
SecurityMockMvcRequestPostProcessors: for JWT resource servers where @WithMockUser doesn't work (no session): java mockMvc.perform(get("/api/orders")
.with(jwt().authorities(new SimpleGrantedAuthority("ROLE_USER"))
.jwt(j -> j.claim("sub", "user123"))))
.andExpect(status().isOk());@PreAuthorize method security, the same principle applies — test that the check fires, not just that the method works when authorized.Full configuration for a stateless JWT resource server: ```java @Configuration @EnableMethodSecurity public class SecurityConfig {
@Bean public SecurityFilterChain api(HttpSecurity http) throws Exception { return http .csrf(csrf -> csrf.disable()) .cors(Customizer.withDefaults()) .sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) .authorizeHttpRequests(a -> a .requestMatchers("/actuator/health").permitAll() .requestMatchers("/api/public/**").permitAll() .anyRequest().authenticated()) .oauth2ResourceServer(o -> o .jwt(j -> j.jwtAuthenticationConverter(jwtAuthConverter())) .authenticationEntryPoint((req, res, ex) -> { res.setContentType("application/json"); res.setStatus(401); res.getWriter().write("{\"error\":\"Unauthorized\"}"); })) .build(); }
@Bean public JwtAuthenticationConverter jwtAuthConverter() { JwtGrantedAuthoritiesConverter conv = new JwtGrantedAuthoritiesConverter(); conv.setAuthoritiesClaimName("roles"); conv.setAuthorityPrefix("ROLE_"); JwtAuthenticationConverter jac = new JwtAuthenticationConverter(); jac.setJwtGrantedAuthoritiesConverter(conv); return jac; } } ```
authenticationEntryPoint above returns a raw JSON string — better to return a proper ProblemDetail object serialized via Jackson. For token expiry: JwtDecoder rejects expired tokens with a JwtValidationException. Distinguish between "token expired" (the client should refresh) and "token invalid" (the client must re-authenticate) in your 401 response body so clients can react differently. Add error_code: TOKEN_EXPIRED vs error_code: INVALID_TOKEN to the response.hasRole("ADMIN") checks if the current user has an authority named ROLE_ADMIN. Spring Security automatically prepends ROLE_ to the argument.
hasAuthority("ROLE_ADMIN") checks for the exact authority string ROLE_ADMIN. No prefix is added.
Functionally, hasRole("ADMIN") == hasAuthority("ROLE_ADMIN") when your authorities follow the ROLE_ convention.
When to use which: - Use hasRole("ADMIN") for role-based access following the ROLE_ convention. - Use hasAuthority("read:orders") for permission-based scopes (e.g., OAuth2 scopes)
where the string is not a role and does not start with ROLE_.
Mixing them creates subtle bugs — hasRole("read:orders") checks for ROLE_read:orders, which never matches a scope authority.
ROLE_ADMIN) vs permission-based (orders:write) vs scope-based (read:orders from OAuth2) are three different models. Permission-based is more granular but more complex to manage. For a mature authorization model, consider: roles aggregate permissions, permissions map to specific operations. Spring Security's GrantedAuthority is just a string — the semantics are entirely yours to define. Make the convention explicit and documented so every service interprets authorities consistently.Work through the filter chain systematically:
1. Enable security debug logging: logging.level.org.springframework.security=DEBUG.
Security logs show every filter decision, the current Authentication, and the
access decision for each request.
Check the access rule order: authorizeHttpRequests() rules match first-fit.
A catch-all anyRequest().authenticated() before your permitAll() rules means
everything is secured. Rules must go from most specific to most general.
Verify JWT decoder config: if using OAuth2 resource server, confirm the issuer-uri
or jwk-set-uri is reachable from the service. A network issue at startup may
have caused the JwtDecoder bean to fail silently.
Check @EnableMethodSecurity: if method security was added, a missing role
on an endpoint that used to have none causes 403 (not 401).
Multiple SecurityFilterChain beans: if you have more than one, verify which
chain matches the URL in question. Add a securityMatcher() to each to make
matching explicit.
DEBUG temporarily for a specific session or IP via the /actuator/loggers endpoint, reproduce the issue, then restore. Add a security audit log (via ApplicationEventPublisher + AuthenticationSuccessEvent, AbstractAuthorizationEvent) that logs authentication events to your SIEM — this is separate from the debug filter chain output and always on.CorsConfigurationSource bean and wiring it into Spring Security via http.cors().
java @Bean public CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration config = new CorsConfiguration();
config.setAllowedOrigins(List.of("https://app.example.com"));
config.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "OPTIONS"));
config.setAllowedHeaders(List.of("Authorization", "Content-Type", "X-Requested-With"));
config.setExposedHeaders(List.of("X-Total-Count", "Link"));
config.setAllowCredentials(true);
config.setMaxAge(3600L);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", config);
return source;
}
In SecurityFilterChain: http.cors(Customizer.withDefaults()) — picks up the CorsConfigurationSource bean automatically.
Why Spring Security must handle CORS (not just @CrossOrigin): OPTIONS pre-flight requests carry no credentials. If the CsrfFilter or authentication filter runs first, the OPTIONS request is rejected before the CORS headers are written. http.cors() inserts the CorsFilter before the auth filters.allowedOrigins("*") and allowCredentials(true) cannot be combined — browsers reject responses that send both. If you need allowCredentials(true) (for session cookies or Authorization header via fetch credentials: 'include'), you must explicitly list allowed origins. For development, use a profile-specific CORS config that allows localhost:3000 in dev profile only — never merge a wildcard CORS config into production.tenant_id (or similar) as a JWT claim. The JwtAuthenticationConverter maps it onto the Authentication object:
java jac.setJwtGrantedAuthoritiesConverter(jwt -> {
String tenantId = jwt.getClaimAsString("tenant_id");
List<GrantedAuthority> authorities = extractRoles(jwt, tenantId);
return authorities;
});
Step 2 — tenant-aware @PreAuthorize: inject the tenant from the JWT into SpEL: java @PreAuthorize("authentication.token.claims['tenant_id'] == #tenantId and hasRole('ADMIN')") public void adminAction(String tenantId) { ... }
Step 3 — data isolation: use a TenantContextHolder (populated from the JWT in a filter) that Hibernate's CurrentTenantIdentifierResolver reads to apply row-level security or switch database schemas per tenant.
Step 4 — role hierarchy per tenant: RoleHierarchy bean defines which roles imply others. For per-tenant hierarchies, resolve the hierarchy dynamically based on the current tenant context.@PreAuthorize checks must always include tenant context. A missing tenant check is a tenant isolation failure — not just a permissions bug. Consider a custom AccessDeniedHandler that logs cross-tenant access attempts explicitly as a security event to your SIEM. Test tenant isolation explicitly: create two tenants with the same role, confirm tenant A cannot access tenant B's resources even with a valid token.SecurityContextHolder uses ThreadLocal by default. Async threads don't inherit it. Fix: configure DelegatingSecurityContextAsyncTaskExecutor:
java @Bean public Executor taskExecutor() {
return new DelegatingSecurityContextAsyncTaskExecutor(
new ThreadPoolTaskExecutor() {{ initialize(); }});
}
Spring WebFlux: ThreadLocal doesn't work in reactive pipelines (each operator may run on a different thread). Spring Security uses Reactor Context instead — ReactiveSecurityContextHolder. The SecurityContext is stored in the subscriber context and flows through the reactive chain automatically.
Access in WebFlux: java ReactiveSecurityContextHolder.getContext()
.map(SecurityContext::getAuthentication)
.flatMap(auth -> process(auth));
@PreAuthorize works in WebFlux via ReactiveMethodSecurityExpressionHandler — enabled automatically with @EnableReactiveMethodSecurity.SecurityContextHolder.getContext() directly (utility classes, legacy helpers, Hibernate interceptors) fails silently in WebFlux — it returns an empty context. Audit all security context access patterns before migrating. Replace SecurityContextHolder calls with ReactiveSecurityContextHolder and propagate via the reactive chain. This is a larger refactor than it appears — security context access tends to be scattered through service and utility code.Spring Security does not include rate limiting out of the box. Implementation options: Option 1 — Spring Security filter + Bucket4j (token bucket algorithm): ```java @Component public class RateLimitFilter extends OncePerRequestFilter { private final Bucket4jRateLimiter rateLimiter; // per-IP or per-user buckets
@Override
protected void doFilterInternal(HttpServletRequest req, ...) {
String key = extractKey(req); // IP or authenticated userId
if (!rateLimiter.tryConsume(key)) {
response.setStatus(429);
return;
}
filterChain.doFilter(req, response);
}
} `` Register:http.addFilterBefore(rateLimitFilter, UsernamePasswordAuthenticationFilter.class).
**Option 2 — Brute-force on login**: overrideAuthenticationEventPublisherto listen forAuthenticationFailureBadCredentialsEvent. Track failures per username/IP in Redis. After N failures in a window, lock the account (UserDetails.isAccountNonLocked()`) or add a delay.
Option 3 — API gateway: implement rate limiting in Kong, AWS WAF, or Envoy before traffic reaches the service. Preferred for rate limits that apply across multiple services.
SecurityFilterChain beans. Chain 1 matches /api/v2/** (new clients, stateless JWT). Chain 2 matches everything else (existing clients, form login + sessions). Both chains coexist. New clients use JWT; old clients continue using sessions.
Phase 2 — issue JWT alongside sessions (optional transition aid): After successful form login, generate a JWT and return it in the response body. Clients can start storing and using it.
Phase 3 — migrate clients: Update each client (frontend, mobile, service) to use JWT Bearer tokens. Monitor session creation rate in Actuator metrics — when it drops to zero, all clients have migrated.
Phase 4 — remove session chain: Remove Chain 2. Disable session creation globally. Clean up HttpSession infrastructure.X-User-Id, X-User-Roles). Services trust the headers — no JWT validation overhead per service. Clean separation: one place to update token validation logic.
Risk: services must trust that the gateway enforces authentication. A path that bypasses the gateway reaches services with no auth. Every internal service-to-service call must go through the gateway or a trusted mesh — or re-validate a service account token.
Distributed in each service (authentication + authorization): Each service validates JWT and enforces its own access rules. More resilient — no gateway SPOF for auth. Fine-grained control: method-level @PreAuthorize with business logic. Consistent JWT validation via a shared library (internal Spring Security starter).
Hybrid (recommended at scale): gateway handles rate limiting, mTLS termination, and coarse-grained auth (is the token valid?). Each service enforces fine-grained authorization with Spring Security. Service-to-service calls use short-lived service tokens (OAuth2 client credentials) validated by each service independently.java @Bean public WebClient serviceClient(OAuth2AuthorizedClientManager manager) {
ServletOAuth2AuthorizedClientExchangeFilterFunction oauth2 =
new ServletOAuth2AuthorizedClientExchangeFilterFunction(manager);
oauth2.setDefaultClientRegistrationId("order-service");
return WebClient.builder().apply(oauth2.oauth2Configuration()).build();
}
mTLS (stronger): Each service has a client certificate issued by an internal CA (Vault PKI). The called service validates the client cert. Spring Boot + Tomcat: configure clientAuth=want or need. Extract the client subject from X509Certificate in a custom filter. Combined mTLS + JWT: mTLS proves service identity at the transport layer; JWT carries claims at the application layer.issuer-uri points to Auth0. Spring Security auto-discovers JWKS. SessionCreationPolicy.STATELESS and csrf.disable() for all routes.JwtGrantedAuthoritiesConverter reads the roles claim, prefixes each with ROLE_. A roles: ["admin", "user"] claim becomes ROLE_ADMIN, ROLE_USER as GrantedAuthority objects./actuator/health and /api/v1/public/** → permitAll(). anyRequest() → authenticated(). Specific admin routes protected via @PreAuthorize("hasRole('ADMIN')") on service methods.AuthenticationEntryPoint returning ProblemDetail JSON for 401. Implement AccessDeniedHandler returning ProblemDetail JSON for 403. Neither should redirect — clients are not browsers expecting a login page.http.cors(Customizer.withDefaults()) + CorsConfigurationSource bean allowing https://app.example.com. allowCredentials(false) since auth is via Bearer header, not cookies.Authorization: Bearer <client_credentials_token>. The same resource server validates them. Service tokens carry a service role, not user or admin.antMatchers in Spring Boot 3 — deprecated, replaced by requestMatchersauthorizeHttpRequests rules in wrong order — anyRequest().authenticated() before permitAll() rules means permitAll() is never reachedSessionCreationPolicy.STATELESS — sessions + no CSRF = CSRF vulnerabilitySecurityConfig class to a new package outside the base @SpringBootApplication package means @ComponentScan no longer finds it. The SecurityFilterChain bean is never registered. Spring Security's default configuration applies — which in Spring Boot 3 is httpBasic + formLogin + all endpoints secured... but without the custom bean, the oauth2ResourceServer configuration is gone.application-staging.yml may disable security (spring.security.enabled=false or a custom flag) for integration testing convenience. Production does not have this flag.SecurityFilterChain bean appears in the context. If not, the config class isn't loaded.@SpringBootApplication(scanBasePackages = {"com.example.app", "com.example.security"}).@WebMvcTest + mockMvc.perform(get("/admin/dashboard")).andExpect(status().isUnauthorized()). This test must run in CI against the staging profile — it would have caught this.spring.security.enabled=false from staging: security should be enabled in all environments. Use a test user with limited permissions instead of disabling security.spring.security.enabled=false in any non-local environment — a misconfigured flag disabling security in staging is one env-var away from productionsecurityMatcher("/api/v2/**") → stateless JWT resource server for new clients. Chain 2 — matches everything else → form login + session. Both chains coexist for 6 months./api/v2/**. Sessions are no longer created for web traffic./api/v1/**. New app versions use JWT + /api/v2/**. Monitor session creation rate in Actuator metrics — http.sessions.active declining indicates migration progress.authorizeHttpRequests() rules into a shared lambda method called by both chains. This prevents rules diverging over the 6-month period as features are added.spring-session-data-redis, and remove Redis session storage.