Spring Security โ Complete Guide
Spring Security is the de-facto standard for securing Spring-based applications. It provides comprehensive authentication, authorization, and protection against common exploits out of the box.
What Is Spring Security?โ
Spring Security is a powerful and highly customizable authentication and access-control framework for Java applications. It is the standard for securing Spring applications โ both servlet-based (Spring MVC) and reactive (WebFlux).
Key idea: Security is a cross-cutting concern. Spring Security provides a declarative, filter-based architecture that keeps security logic separate from business logic.
๐ถ Beginner Concept: The "Nightclub" Analogyโ
Security always boils down to two distinct steps:
- Authentication (Who are you?): This is the Bouncer at the front door checking your ID. He verifies you are who you say you are and gives you a wristband.
- Authorization (What are you allowed to do?): This is the VIP Lounge Guard inside the club. Just because you got through the front door (Authentication) doesn't mean you can walk into the VIP area. The guard checks if your wristband has "VIP" printed on it (Authorization/Roles).
Spring Security handles both the Bouncer (Authentication Filters) and the VIP Guard (Authorization Filters) automatically before your code even runs.
Why Use Spring Security?โ
Problems It Solvesโ
| Security Need | How Spring Security Addresses It |
|---|---|
| Authentication (who are you?) | Supports form login, HTTP Basic, OAuth2, SAML, LDAP, JWT, and custom providers |
| Authorization (what can you do?) | URL-based and method-level access control with roles and permissions |
| CSRF protection | Enabled by default for stateful applications |
| Session management | Session fixation protection, concurrent session control |
| Password storage | BCryptPasswordEncoder and other secure hashing algorithms |
| CORS | Configurable cross-origin resource sharing |
| Security headers | X-Content-Type-Options, X-Frame-Options, HSTS, etc. |
How Does Spring Security Work?โ
The Security Filter Chainโ
Spring Security works through a filter chain that intercepts every HTTP request before it reaches your controllers.
HTTP Request
โ
โผ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ SecurityFilterChain โ
โ โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ โ
โ โ CorsFilter โ โ
โ โ CsrfFilter โ โ
โ โ AuthenticationFilter โ โ
โ โ ExceptionTranslationFilter โโโโผโโ Catches Security Exceptions
โ โ AuthorizationFilter โโโโผโโ (Replaced FilterSecurityInterceptor in Spring Sec 6)
โ โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ
โผ
DispatcherServlet (Spring MVC)
โ
โผ
Controller / Endpoint
Authentication Architectureโ
AuthenticationFilter
โ
โผ
AuthenticationManager
โ
โผ
AuthenticationProvider (can have multiple)
โ
โผ
UserDetailsService (loads user data)
โ
โผ
PasswordEncoder (verifies password)
โ
โผ
SecurityContext (stores authenticated principal)
End-to-End Authentication Workflowโ
The complete end-to-end workflow, combining the filter chain and authentication architecture, operates as follows:
- Client sends an HTTP Request.
- DelegatingFilterProxy intercepts the servlet request and delegates it to the Spring-managed FilterChainProxy.
- FilterChainProxy determines which SecurityFilterChain to invoke based on the request URL.
- The SecurityFilterChain contains multiple filters, eventually executing an Authentication Filter (e.g.,
UsernamePasswordAuthenticationFilter). - The Authentication Filter extracts credentials from the request and creates an unauthenticated Authentication Object (e.g., an authentication token).
- This unauthenticated object is passed to the AuthenticationManager (the default implementation is ProviderManager).
- The ProviderManager delegates to one or more AuthenticationProviders (e.g.,
DaoAuthenticationProvider). - The AuthenticationProvider uses a UserDetailsService (often a DAO accessing a database) to load the user's record.
- The
UserDetailsServicereturns a UserDetails object (containing the stored password and authorities). - The AuthenticationProvider validates the credentials (e.g., checking if the password matches). If successful, it creates a fully populated, authenticated Authentication Object and returns it back to the
ProviderManager, which returns it back to theAuthentication Filter. - Finally, the Authentication Filter stores this authenticated object in the SecurityContextHolder (Spring Context Holder) where it can be used for authorization decisions later in the filter chain or in your application code.
๐ง Senior Deep Dive: The ThreadLocal Architectureโ
When the AuthenticationFilter successfully logs a user in, it stores the identity in the SecurityContextTracker. But how does a completely random Controller method know who is making the request without passing a User object through 15 layers of method arguments?
Spring uses a Java ThreadLocal.
Because Tomcat assigns exactly one Dedicated Thread per incoming HTTP Request, Spring binds the SecurityContext specifically to that one running Thread.
Any method, anywhere in the code, can statically call SecurityContextHolder.getContext().getAuthentication() and it will retrieve the exact user belonging only to the current HTTP Request Thread. When the HTTP request finishes and the response is sent back, the FilterChain explicitly calls SecurityContextHolder.clearContext() to wipe the ThreadLocal clean before Tomcat recycles the Thread for the next user. If it fails to clear, you end up with massive security breaches where User B suddenly sees User A's private data!
Authorization Architecture: FilterSecurityInterceptor vs. AuthorizationFilterโ
Once a user is authenticated, Spring Security must decide if they are allowed to access a specific resource. The mechanism for this changed significantly between Spring Security 5 and Spring Security 6.
The Legacy Way (Spring Security 5 and below): FilterSecurityInterceptorโ
For years, the FilterSecurityInterceptor was the final and most crucial filter in the chain. Its job was to enforce HTTP authorization rules.
Here is how FilterSecurityInterceptor worked internally:
- Intercept the Request: It stops the incoming HTTP request just before it reaches the
DispatcherServlet. - Retrieve Authentication: It grabs the current
Authenticationobject from theSecurityContextHolder. - Lookup Metadata: It consults the
FilterInvocationSecurityMetadataSourceto look up the required roles/authorities for the current request URL (e.g., "Does/api/adminrequireROLE_ADMIN?"). - Make Access Decision: It passes the
Authentication, the request object, and the required authorities to theAccessDecisionManager. - Vote: The
AccessDecisionManageruses a series ofAccessDecisionVotercomponents to vote (GRANT, DENY, or ABSTAIN) on whether the user should be allowed in. - Result: If access is denied, it throws an
AccessDeniedException(caught by theExceptionTranslationFilter). If granted, the filter chain proceeds to the Controller.
The Modern Way (Spring Security 6+): AuthorizationFilterโ
In Spring Security 6.0, FilterSecurityInterceptor, AccessDecisionManager, and AccessDecisionVoter were officially deprecated and replaced by a simpler, more performant component: the AuthorizationFilter.
It uses the new AuthorizationManager API instead of voters:
- It intercepts the request.
- It delegates directly to an
AuthorizationManager<HttpServletRequest>(typicallyRequestMatcherDelegatingAuthorizationManager). - The manager checks the URL rules configured in your
SecurityFilterChainand returns anAuthorizationDecision. - If the decision is negative, it throws an
AccessDeniedException.
Note: While FilterSecurityInterceptor is legacy, understanding it is critical for maintaining older Spring Boot 2.x codebases and for senior-level system architecture interviews.
Exception Handling: The ExceptionTranslationFilterโ
A critical component of the filter chain is the ExceptionTranslationFilter. Its sole responsibility is to catch and handle Spring Security exceptions thrown by the authorization filters (FilterSecurityInterceptor / AuthorizationFilter) or by method-level security (AOP).
How It Worksโ
The ExceptionTranslationFilter acts as a try-catch block around the remaining filter chain. It listens for two specific types of exceptions:
AuthenticationException(401 Unauthorized): Thrown when a user provides invalid credentials or accesses a protected resource without being authenticated.AccessDeniedException(403 Forbidden): Thrown when an authenticated user attempts to access a resource they do not have the required roles/permissions for.
The Exception Resolution Flowโ
ExceptionTranslationFilter catches Exception
โ
โโโโบ Is it an AuthenticationException?
โ โโโโบ YES: Clear SecurityContext โ Call AuthenticationEntryPoint (Returns 401)
โ
โโโโบ Is it an AccessDeniedException?
โโโโบ Is the user Anonymous (not logged in)?
โ โโโโบ YES: Call AuthenticationEntryPoint (Returns 401 to force login)
โ
โโโโบ Is the user fully Authenticated?
โโโโบ YES: Call AccessDeniedHandler (Returns 403 Forbidden)
Customizing Security Responses (REST APIs)โ
By default, Spring Security might return a white-label HTML error page. For REST APIs, provide custom JSON responses:
1. Custom AccessDeniedHandler (403 Forbidden)โ
@Component
public class CustomAccessDeniedHandler implements AccessDeniedHandler {
@Override
public void handle(HttpServletRequest request, HttpServletResponse response,
AccessDeniedException accessDeniedException) throws IOException {
response.setStatus(HttpServletResponse.SC_FORBIDDEN);
response.setContentType("application/json");
response.getWriter().write("{\"error\": \"Forbidden\", \"message\": \"You do not have permission to access this resource.\"}");
}
}
2. Custom AuthenticationEntryPoint (401 Unauthorized)โ
@Component
public class CustomAuthenticationEntryPoint implements AuthenticationEntryPoint {
@Override
public void commence(HttpServletRequest request, HttpServletResponse response,
AuthenticationException authException) throws IOException {
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
response.setContentType("application/json");
response.getWriter().write("{\"error\": \"Unauthorized\", \"message\": \"Authentication is required.\"}");
}
}
3. Spring Security Exceptions vs. @ControllerAdviceโ
A common issue backend engineers face is trying to catch AccessDeniedException using @RestControllerAdvice and realizing it doesn't work for URL-level security.
Why? Filters execute before the DispatcherServlet. @ControllerAdvice only catches exceptions thrown inside the DispatcherServlet. If the AuthorizationFilter throws the exception, the Advice never sees it. (However, if a @PreAuthorize annotation on a Controller method throws it, the Advice WILL catch it because it happens inside the servlet).
Configuration (Spring Boot 3.x / Spring Security 6)โ
Custom SecurityFilterChainโ
@Configuration
@EnableWebSecurity
@EnableMethodSecurity // Replaces @EnableGlobalMethodSecurity
public class SecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.csrf(csrf -> csrf.disable()) // Disable for stateless APIs
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/auth/**", "/public/**").permitAll()
.requestMatchers("/api/admin/**").hasRole("ADMIN")
.anyRequest().authenticated()
)
.exceptionHandling(ex -> ex
.authenticationEntryPoint(new CustomAuthenticationEntryPoint())
.accessDeniedHandler(new CustomAccessDeniedHandler())
);
return http.build();
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
}
Method-Level Securityโ
Beyond URL-based rules, Spring Security supports fine-grained access control on individual methods using Spring Expression Language (SpEL).
Annotationsโ
@Service
public class OrderService {
@PreAuthorize("hasRole('ADMIN') or #userId == authentication.principal.id")
public Order getOrder(Long userId, Long orderId) {
// Only admins or the owning user can access
}
@PostAuthorize("returnObject.owner == authentication.name")
public Order findOrder(Long orderId) {
// Result is checked after method execution
}
}
Interview Questions (Backend Engineer Focus)โ
Q1: What is the ExceptionTranslationFilter and what does it do?โ
It is a filter that sits just above the authorization filters. It acts as a try-catch block for the rest of the filter chain. It translates AuthenticationException into a call to the AuthenticationEntryPoint (triggering a 401), and translates AccessDeniedException into a call to the AccessDeniedHandler (triggering a 403 Forbidden).
Q2: What was the role of the FilterSecurityInterceptor, and what replaced it?โ
In Spring Security 5 and older, FilterSecurityInterceptor was the final filter responsible for HTTP authorization. It extracted the Authentication object, looked up the required authorities for the URL, and delegated to an AccessDecisionManager (which used Voters) to determine if access should be granted. In Spring Security 6, it was deprecated and replaced by the simpler AuthorizationFilter and the AuthorizationManager API.
Q3: An authenticated user with role USER calls an endpoint protected by @PreAuthorize("hasRole('ADMIN')"). What exception is thrown, and who catches it?โ
An AccessDeniedException is thrown. Because @PreAuthorize relies on AOP around the controller, the exception originates inside the DispatcherServlet. Therefore, a @RestControllerAdvice (GlobalExceptionHandler) can catch it. If no @ControllerAdvice handles it, the exception bubbles up and is eventually caught by the ExceptionTranslationFilter, which delegates it to the AccessDeniedHandler to return a 403 status.
Q4: Why won't my @ControllerAdvice catch exceptions thrown inside a custom JWT filter?โ
@ControllerAdvice only intercepts exceptions thrown within the Spring MVC DispatcherServlet lifecycle. Custom JWT filters execute in the Servlet Filter Chain before the request ever reaches the DispatcherServlet. To handle filter exceptions, you must either configure an AuthenticationEntryPoint, or use a HandlerExceptionResolver to manually forward the exception to the advice context.
Q5: How do you implement JWT authentication in Spring Security?โ
- Create a
JwtTokenProviderutility to generate and validate JWTs. - Implement a
OncePerRequestFilterthat extracts the token from theAuthorization: Bearerheader. - Validate the token, extract the username, load the
UserDetails, and instantiate aUsernamePasswordAuthenticationToken. - Set this token into the
SecurityContextHolder. - Register this filter in the
SecurityFilterChainbefore theUsernamePasswordAuthenticationFilter.
SecurityContext in Async โ The Silent Bugโ
By default, SecurityContextHolder uses MODE_THREADLOCAL โ the security context is not copied to new threads. Any @Async method or thread pool task loses the authenticated user.
@Service
public class ReportService {
@Async
public void generateReport(Long userId) {
// โ SecurityContext is EMPTY here โ @Async runs on a different thread
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
String currentUser = auth.getName(); // NullPointerException or anonymous
}
}
Fix 1: DelegatingSecurityContextAsyncTaskExecutorโ
@Configuration
@EnableAsync
public class AsyncSecurityConfig implements AsyncConfigurer {
@Override
public Executor getAsyncExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(10);
executor.setMaxPoolSize(50);
executor.initialize();
// Wraps executor to copy SecurityContext to child threads
return new DelegatingSecurityContextAsyncTaskExecutor(executor);
}
}
Fix 2: Change SecurityContext Strategy Globallyโ
// In application startup (use cautiously โ affects all thread spawning)
SecurityContextHolder.setStrategyName(
SecurityContextHolder.MODE_INHERITABLETHREADLOCAL // Copies to child threads
);
Caution with
INHERITABLETHREADLOCAL: Thread pool workers may inherit the wrong context if the thread is reused from a pool.DelegatingSecurityContextAsyncTaskExecutoris the safer, per-task approach.
Fix 3: Pass authentication explicitlyโ
@Async
public void generateReport(Long userId, Authentication auth) {
SecurityContextHolder.getContext().setAuthentication(auth);
// ... process
SecurityContextHolder.clearContext(); // Always clean up!
}
OAuth2 Authorization Code + PKCE Flowโ
PKCE (Proof Key for Code Exchange) prevents authorization code interception โ mandatory for public clients (SPAs, mobile apps).
Client Auth Server Resource Server
โ โ โ
โโโ 1. Generate code_verifier โโโโโโโ โ
โ code_challenge = SHA256(verifier) โ
โ โ โ
โโโ 2. GET /authorize โโโโโโโโโโโโโโบโ โ
โ ?response_type=code โ โ
โ &code_challenge=<hash> โ โ
โ &code_challenge_method=S256 โ โ
โ โ โ
โโโ 3. Redirect with code โโโโโโโโโโโ โ
โ โ โ
โโโ 4. POST /token โโโโโโโโโโโโโโโโโบโ โ
โ code=<auth_code> โ โ
โ code_verifier=<plain_text> โ (Server verifies SHA256 โ
โ โ matches stored challenge) โ
โโโ 5. Access Token + Refresh โโโโโโโ โ
โ โ โ
โโโ 6. GET /api/data Bearer โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโบโ
โโโ 7. Protected Resource โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
Spring Boot Resource Server (Spring Security 6.x):
@Configuration
@EnableWebSecurity
public class ResourceServerConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.oauth2ResourceServer(oauth2 -> oauth2
.jwt(jwt -> jwt
.jwtAuthenticationConverter(jwtAuthConverter())
)
)
.authorizeHttpRequests(auth -> auth
.requestMatchers("/public/**").permitAll()
.anyRequest().authenticated()
);
return http.build();
}
@Bean
public JwtAuthenticationConverter jwtAuthConverter() {
JwtGrantedAuthoritiesConverter authoritiesConverter = new JwtGrantedAuthoritiesConverter();
authoritiesConverter.setAuthoritiesClaimName("roles"); // Custom claim
authoritiesConverter.setAuthorityPrefix("ROLE_");
JwtAuthenticationConverter converter = new JwtAuthenticationConverter();
converter.setJwtGrantedAuthoritiesConverter(authoritiesConverter);
return converter;
}
}
Advanced Editorial Pass: Security Architecture Beyond Defaultsโ
Senior Design Focusโ
- Decouple Security from Controllers: Use
@PreAuthorizeon the service layer rather than the web layer to ensure security is enforced regardless of the entry point (HTTP, GraphQL, or internal events). - Global Exception Consistency: Ensure that your
AccessDeniedHandler,AuthenticationEntryPoint, and@ControllerAdviceall return the exact same JSON error payload structure (e.g., RFC 7807 Problem Details for HTTP APIs). - Migration Strategy: When migrating legacy Spring applications to Spring Boot 3, factor in the architectural shift from
AccessDecisionManager/Voterclasses to the newAuthorizationManagerinterfaces.