Mastering Spring Security Exception Handling with @ExceptionHandler: A Comprehensive Guide
If you’ve ever built a REST API with Spring Boot and Spring Security, you’ve likely encountered the default, and often unhelpful, error responses. An unauthenticated request might get a login page redirect, and a forbidden request might return a clunky HTML error page. For a modern JSON-based API, this is a jarring and unprofessional user experience. The key to crafting clean, consistent, and secure error responses lies in mastering Spring Security’s exception handling flow.
While Spring MVC offers the elegant @ControllerAdvice and @ExceptionHandler mechanism for centralized exception management, a common point of confusion is why it doesn’t “just work” for security exceptions out of the box. The reason lies in the architecture of Spring Security’s filter chain, which processes requests before they even reach the DispatcherServlet where @ControllerAdvice operates.
This comprehensive guide will demystify this process. We will explore the default behavior, understand the core security exception handling components, and then bridge the gap to create a unified, global exception handling strategy using @ControllerAdvice. By the end, you’ll be able to handle any security exception gracefully, providing clear, consistent JSON error responses to your API clients.
The Problem: Default Spring Security Behavior
Out of the box, Spring Security is configured with sensible defaults primarily aimed at traditional, session-based web applications. However, these defaults are often unsuitable for stateless REST APIs.
Authentication Failures (401 Unauthorized)
When an unauthenticated user attempts to access a protected resource, Spring Security throws an AuthenticationException. The default handler, LoginUrlAuthenticationEntryPoint, typically performs a 302 redirect to a /login page. An API client, like a JavaScript frontend or a mobile app, doesn’t know how to handle a redirect to an HTML login form; it expects a clear 401 Unauthorized status with a JSON body explaining the error.
Authorization Failures (403 Forbidden)
When an authenticated user tries to access a resource for which they lack the necessary permissions (e.g., a ‘USER’ trying to access an ‘/admin’ endpoint), Spring Security throws an AccessDeniedException. The default handler, AccessDeniedHandlerImpl, responds by sending a standard HTTP 403 Forbidden error, often rendering a basic “Whitelabel Error Page” in HTML. Again, this is unhelpful for an API client that needs a structured JSON response.
This dissonance between the expected API behavior (JSON, status codes) and the default framework behavior (HTML, redirects) leads to a poor developer experience for your API consumers and can break client-side applications.
The Foundation: `AuthenticationEntryPoint` and `AccessDeniedHandler`
Before we can integrate @ExceptionHandler, we must first understand the two primary interfaces Spring Security provides for customizing its core exception handling flow. These interfaces are your first port of call for intercepting security exceptions at the filter chain level.
Understanding `AuthenticationEntryPoint`
The AuthenticationEntryPoint is a crucial interface invoked whenever an unauthenticated request attempts to access a protected resource, resulting in an AuthenticationException. Its job is to “commence” the authentication scheme.
Its single method is:
void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException;
You can create a custom implementation to override the default redirect behavior. For a REST API, a proper implementation would set the response status to 401 and write a custom JSON error message to the response body.
Understanding `AccessDeniedHandler`
The AccessDeniedHandler is invoked when an authenticated and recognized user attempts an action that their granted authorities do not permit, resulting in an AccessDeniedException.
Its single method is:
void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException, ServletException;
A custom implementation for a REST API would typically set the response status to 403 Forbidden and, like the entry point, return a structured JSON error message.
Configuring Custom Handlers
You register these custom implementations within your security configuration class. This gives you direct control over the error responses generated by the security filter chain itself.
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
// ... other configurations
.exceptionHandling(exceptions -> exceptions
.authenticationEntryPoint(new CustomAuthenticationEntryPoint())
.accessDeniedHandler(new CustomAccessDeniedHandler())
);
return http.build();
}
}
While effective, this approach can lead to scattered logic. If you already have a global exception handler using @ControllerAdvice for your application’s business logic exceptions, you now have two separate mechanisms for handling errors. The goal is to unify them.
Bridging the Gap: Connecting Security Exceptions to `@ControllerAdvice`
The core challenge is that exceptions like AuthenticationException and AccessDeniedException are thrown and handled within the Spring Security filter chain, which executes *before* the request reaches Spring MVC’s DispatcherServlet. Since @ControllerAdvice is a Spring MVC feature, it never gets a chance to see these exceptions.
The solution is elegant: we create custom implementations of AuthenticationEntryPoint and AccessDeniedHandler whose sole purpose is to delegate the exception handling forward to the central MVC mechanism. We can achieve this using the HandlerExceptionResolver.
Step 1: Create a Delegating `AuthenticationEntryPoint`
We’ll create a new entry point that doesn’t write a response itself. Instead, it uses an injected HandlerExceptionResolver to resolve the exception. This action effectively re-throws the exception into the Spring MVC context, making it visible to our @ControllerAdvice.
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.AuthenticationEntryPoint;
import org.springframework.stereotype.Component;
import org.springframework.web.servlet.HandlerExceptionResolver;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import java.io.IOException;
@Component("delegatedAuthenticationEntryPoint")
public class DelegatedAuthenticationEntryPoint implements AuthenticationEntryPoint {
@Autowired
@Qualifier("handlerExceptionResolver")
private HandlerExceptionResolver resolver;
@Override
public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException)
throws IOException {
resolver.resolveException(request, response, null, authException);
}
}
Notice we use @Qualifier("handlerExceptionResolver") to ensure we get the primary resolver that Spring Boot auto-configures.
Step 2: Create a Delegating `AccessDeniedHandler`
We apply the exact same pattern for the AccessDeniedHandler.
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.web.access.AccessDeniedHandler;
import org.springframework.stereotype.Component;
import org.springframework.web.servlet.HandlerExceptionResolver;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import java.io.IOException;
@Component("delegatedAccessDeniedHandler")
public class DelegatedAccessDeniedHandler implements AccessDeniedHandler {
@Autowired
@Qualifier("handlerExceptionResolver")
private HandlerExceptionResolver resolver;
@Override
public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException)
throws IOException {
resolver.resolveException(request, response, null, accessDeniedException);
}
}
Step 3: Update the Security Configuration
Finally, we wire our new delegating components into our security configuration. We can inject them directly since we’ve marked them as @Component.
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Autowired
@Qualifier("delegatedAuthenticationEntryPoint")
AuthenticationEntryPoint authEntryPoint;
@Autowired
@Qualifier("delegatedAccessDeniedHandler")
AccessDeniedHandler accessDeniedHandler;
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.csrf(csrf -> csrf.disable())
// ... other configurations like authorization rules
.exceptionHandling(exceptions -> exceptions
.authenticationEntryPoint(authEntryPoint)
.accessDeniedHandler(accessDeniedHandler)
);
return http.build();
}
}
With this setup, any security exception will now be passed along to the DispatcherServlet, ready to be handled by our global exception handler.
Building the Global Exception Handler with `@RestControllerAdvice`
Now for the final piece of the puzzle. We can create a single class annotated with @RestControllerAdvice to handle all exceptions—from security to business logic—in one place. The @RestControllerAdvice annotation is a convenience that combines @ControllerAdvice and @ResponseBody, ensuring our handler methods return response bodies, perfect for a REST API.
Creating a Standard Error Response DTO
Before writing the handler, it’s a best practice to define a consistent error response format. This makes your API predictable and easier for clients to consume.
public class ApiError {
private long timestamp;
private int status;
private String error;
private String message;
private String path;
// Constructors, Getters, and Setters
}
The `@RestControllerAdvice` Implementation
Now, we create the handler class and add methods annotated with @ExceptionHandler for each specific exception type we want to handle.
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.authentication.BadCredentialsException;
import org.springframework.security.core.AuthenticationException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.context.request.WebRequest;
import java.time.Instant;
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(AccessDeniedException.class)
public ResponseEntity<ApiError> handleAccessDenied(AccessDeniedException ex, WebRequest request) {
ApiError error = new ApiError(
Instant.now().toEpochMilli(),
HttpStatus.FORBIDDEN.value(),
"Forbidden",
"You do not have permission to access this resource.",
request.getDescription(false).substring(4) // remove "uri="
);
return new ResponseEntity<>(error, HttpStatus.FORBIDDEN);
}
@ExceptionHandler(BadCredentialsException.class)
public ResponseEntity<ApiError> handleBadCredentials(BadCredentialsException ex, WebRequest request) {
ApiError error = new ApiError(
Instant.now().toEpochMilli(),
HttpStatus.UNAUTHORIZED.value(),
"Unauthorized",
"Invalid username or password.",
request.getDescription(false).substring(4)
);
return new ResponseEntity<>(error, HttpStatus.UNAUTHORIZED);
}
// A more generic handler for other authentication issues
@ExceptionHandler(AuthenticationException.class)
public ResponseEntity<ApiError> handleAuthenticationException(AuthenticationException ex, WebRequest request) {
ApiError error = new ApiError(
Instant.now().toEpochMilli(),
HttpStatus.UNAUTHORIZED.value(),
"Unauthorized",
ex.getMessage(), // Or a generic message
request.getDescription(false).substring(4)
);
return new ResponseEntity<>(error, HttpStatus.UNAUTHORIZED);
}
// Catch-all for any other exception
@ExceptionHandler(Exception.class)
public ResponseEntity<ApiError> handleGenericException(Exception ex, WebRequest request) {
// Important: Log the full exception for debugging
// log.error("Unhandled exception occurred", ex);
ApiError error = new ApiError(
Instant.now().toEpochMilli(),
HttpStatus.INTERNAL_SERVER_ERROR.value(),
"Internal Server Error",
"An unexpected error occurred. Please try again later.",
request.getDescription(false).substring(4)
);
return new ResponseEntity<>(error, HttpStatus.INTERNAL_SERVER_ERROR);
}
}
This single class now provides clean, consistent, and informative JSON responses for both security failures and general application errors, achieving our goal of a truly unified exception handling strategy.
Conclusion
Effectively handling exceptions is a hallmark of a robust and professional API. While Spring Security’s default behavior can be confusing in a REST context, its architecture is flexible enough to accommodate a clean, centralized solution. By understanding the distinct roles of the security filter chain and the Spring MVC DispatcherServlet, we can create a powerful bridge between them.
The key takeaway is the delegation pattern: use custom implementations of AuthenticationEntryPoint and AccessDeniedHandler to forward security exceptions to the HandlerExceptionResolver. This makes them visible to a global @RestControllerAdvice, allowing you to manage all your application’s error responses—security, validation, and business logic—in one consistent, maintainable, and elegant location.