Mastering Two-Tier Exception Handling in Spring Boot: A Complete Guide
Ever wondered why sometimes your Spring Boot exceptions are caught by @RestControllerAdvice and other times they're not? Or why your custom AccessDeniedHandler returns 403 before your controller even executes? I spent hours debugging this mystery until I understood Spring Boot's two-tier exception handling architecture. Let me save you the confusion. π―
The Confusion That Started It Allβ
Here's what happened: I had a perfectly working exception handler for AccessDeniedException in my @RestControllerAdvice:
@ExceptionHandler(AccessDeniedException.class)
public ResponseEntity<ErrorResponse> handleAccessDenied(AccessDeniedException ex) {
return ResponseEntity.status(HttpStatus.FORBIDDEN)
.body(new ErrorResponse(403, "Access denied!"));
}
But when I tested a restricted endpoint, this handler never got called. Instead, my CustomAccessDeniedHandler was returning the response. What's going on? π€
The revelation: Spring Boot has TWO separate exception handling layers, and understanding when each one kicks in is crucial.
The Big Picture: Two-Tier Architectureβ
Our application uses a two-tier exception handling architecture:
- Filter Level (Spring Security) - Handles authorization failures before controllers
- Controller Level (Spring MVC) - Handles business logic exceptions during controller execution
Think of it like airport security:
- Filter Level = TSA checkpoint (catches issues before you board)
- Controller Level = In-flight safety (handles issues during the journey)
Visual Architecture Flowβ
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β HTTP REQUEST β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β SPRING SECURITY FILTER CHAIN β
β β
β βββββββββββββββββββββββββββββββββββββββββββββββββββββββ β
β β 1. JwtAuthenticationFilter β β
β β - Extract JWT token from Authorization header β β
β β - Validate token β β
β β - Set authentication in SecurityContext β β
β βββββββββββββββββββββββββββββββββββββββββββββββββββββββ β
β β β
β βββββββββββββββββββββββββββββββββββββββββββββββββββββββ β
β β 2. Authorization Filter β β
β β - Check @PreAuthorize annotations β β
β β - Check SecurityConfig URL patterns β β
β β - Validate user has required role/permissions β β
β βββββββββββββββββββββββββββββββββββββββββββββββββββββββ β
β β β
β β ACCESS DENIED? β
β β β
β βββββββββββββββββββββββββββββββββββββββββββββββββββββββ β
β β CustomAccessDeniedHandler (FILTER LEVEL) β β
β β β
Returns: 403 with custom JSON message β β
β βββββββββββββββββββββββββββββββββββββββββββββββββββββββ β
β β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β (Only if authorized)
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β SPRING MVC / CONTROLLER LAYER β
β β
β βββββββββββββββββββββββββββββββββββββββββββββββββββββββ β
β β Controller Method Executes β β
β β - Business logic β β
β β - Service calls β β
β β - Data validation β β
β βββββββββββββββββββββββββββββββββββββββββββββββββββββββ β
β β β
β β EXCEPTION THROWN? β
β β β
β βββββββββββββββββββββββββββββββββββββββββββββββββββββββ β
β β GlobalExceptionHandler (CONTROLLER LEVEL) β β
β β β
Returns: Custom status code with ErrorResponse β β
β βββββββββββββββββββββββββββββββββββββββββββββββββββββββ β
β β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β JSON RESPONSE β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
Layer 1: Controller-Level Exception Handlingβ
The Familiar Territory: @RestControllerAdviceβ
This is what most Spring Boot developers know and love. When you throw an exception in your controller or service, @RestControllerAdvice catches it and returns a nice error response.
Location: src/main/java/com/ecommerce/app/exception/GlobalExceptionHandler.java
Technology: @RestControllerAdvice + @ExceptionHandler
Purpose: Catches exceptions thrown during controller/service execution
Implementationβ
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(InvalidInputException.class)
public ResponseEntity<ErrorResponse> handleInvalidInput(InvalidInputException ex) {
ErrorResponse error = new ErrorResponse(
HttpStatus.BAD_REQUEST.value(),
ex.getMessage()
);
return ResponseEntity.badRequest().body(error);
}
@ExceptionHandler(UserAlreadyExistsException.class)
public ResponseEntity<ErrorResponse> handleUserAlreadyExists(UserAlreadyExistsException ex) {
ErrorResponse error = new ErrorResponse(
HttpStatus.CONFLICT.value(), // 409 Conflict
ex.getMessage()
);
return ResponseEntity.status(HttpStatus.CONFLICT).body(error);
}
@ExceptionHandler(InvalidCredentialsException.class)
public ResponseEntity<ErrorResponse> handleInvalidCredentials(InvalidCredentialsException ex) {
ErrorResponse error = new ErrorResponse(
HttpStatus.UNAUTHORIZED.value(), // 401 Unauthorized
ex.getMessage()
);
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(error);
}
@ExceptionHandler(AccessDeniedException.class)
public ResponseEntity<ErrorResponse> handleAccessDenied(AccessDeniedException ex) {
ErrorResponse error = new ErrorResponse(
HttpStatus.FORBIDDEN.value(), // 403 Forbidden
"Access denied. You do not have the required permissions."
);
return ResponseEntity.status(HttpStatus.FORBIDDEN).body(error);
}
@ExceptionHandler(RuntimeException.class)
public ResponseEntity<ErrorResponse> handleRuntimeException(RuntimeException ex) {
ErrorResponse error = new ErrorResponse(
HttpStatus.INTERNAL_SERVER_ERROR.value(),
ex.getMessage()
);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(error);
}
}
Exception Types Handledβ
| Exception | HTTP Status | Use Case |
|---|---|---|
InvalidInputException | 400 Bad Request | Missing or invalid input data |
UserAlreadyExistsException | 409 Conflict | Email/username already exists |
InvalidCredentialsException | 401 Unauthorized | Wrong email/password |
AccessDeniedException | 403 Forbidden | Manual access denial in code |
RuntimeException | 500 Internal Server Error | Catch-all for unexpected errors |
How It Works: Simple and Cleanβ
Controllers just throw exceptions - no need to handle them manually:
@PostMapping("/register")
public ResponseEntity<RegisterResponse> register(@RequestBody RegisterRequest request) {
// Validation - GlobalExceptionHandler catches this
if (request.getUsername() == null || request.getUsername().trim().isEmpty()) {
throw new InvalidInputException("Username is required");
// β Caught by GlobalExceptionHandler β
}
// Business logic - GlobalExceptionHandler catches this too
if (userRepository.findByEmail(request.getEmail()).isPresent()) {
throw new UserAlreadyExistsException("Email already registered");
// β Caught by GlobalExceptionHandler β
}
// Success path
return ResponseEntity.ok(response);
}
Response Formatβ
All controller-level exceptions return the ErrorResponse DTO:
public class ErrorResponse {
private int status;
private String message;
private String timestamp;
}
Example Response:
{
"status": 400,
"message": "Username is required",
"timestamp": "2025-10-21T08:30:45.123Z"
}
Layer 2: Filter-Level Exception Handlingβ
The Hidden Layer: CustomAccessDeniedHandlerβ
This is where things get interesting. Filter-level exceptions occur BEFORE your controller even exists in the execution flow.
Location: src/main/java/com/ecommerce/app/security/CustomAccessDeniedHandler.java
Technology: AccessDeniedHandler interface (Spring Security)
Purpose: Catches Spring Security authorization failures that happen in the filter chain
The Key Insight π‘β
When Spring Security denies access at the filter level:
- β Your controller method never executes
- β
@RestControllerAdvicenever sees the exception - β
CustomAccessDeniedHandlerhandles it directly
Implementationβ
@Component
public class CustomAccessDeniedHandler implements AccessDeniedHandler {
private final ObjectMapper objectMapper = new ObjectMapper();
@Override
public void handle(HttpServletRequest request,
HttpServletResponse response,
AccessDeniedException accessDeniedException)
throws IOException, ServletException {
// Set response status to 403 Forbidden
response.setStatus(HttpServletResponse.SC_FORBIDDEN);
response.setContentType("application/json");
// Create custom error response
Map<String, Object> errorResponse = new HashMap<>();
errorResponse.put("status", 403);
errorResponse.put("message", "Access denied. You do not have the required permissions to access this resource.");
// Write JSON response
response.getWriter().write(objectMapper.writeValueAsString(errorResponse));
}
}
Security Configuration Integrationβ
The handler must be registered in SecurityConfig.java:
@Configuration
@EnableWebSecurity
@EnableMethodSecurity
public class SecurityConfig {
@Autowired
private CustomAccessDeniedHandler customAccessDeniedHandler;
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
// ... other configuration ...
// Configure exception handling for access denied (403)
.exceptionHandling(exception -> exception
.accessDeniedHandler(customAccessDeniedHandler)
);
return http.build();
}
}
When Does It Trigger?β
1. @PreAuthorize annotation failures
@PreAuthorize("hasRole('ADMIN')") // β USER tries to access
@DeleteMapping("/users/{id}")
public ResponseEntity<?> deleteUser() {
// β NEVER REACHED - CustomAccessDeniedHandler returns 403
}
2. SecurityConfig URL pattern failures
// In SecurityConfig:
.requestMatchers("/api/admin/**").hasRole("ADMIN")
// USER tries to access /api/admin/users
// β CustomAccessDeniedHandler returns 403 before controller
Response Formatβ
Filter-level exceptions return raw JSON (NOT ErrorResponse DTO):
{
"status": 403,
"message": "Access denied. You do not have the required permissions to access this resource."
}
Why not use ErrorResponse DTO? Because we're outside Spring MVC context - we're in the filter chain where DTOs aren't available yet!
The Critical Comparison: When to Use Which Layer?β
| Aspect | Controller-Level | Filter-Level |
|---|---|---|
| Handler | GlobalExceptionHandler | CustomAccessDeniedHandler |
| Technology | @RestControllerAdvice | AccessDeniedHandler interface |
| Execution Point | β° After authorization, during controller | β° Before controller, during security filters |
| Catches | Explicit throws in controller/service | Spring Security authorization failures |
| Priority | π₯ Second line of defense | π₯ First line of defense |
| Response Format | ErrorResponse DTO | Raw JSON Map |
| Use Cases | Business logic errors, validation | @PreAuthorize failures, URL patterns |
| Controller Access? | β Yes - exception from controller | β No - controller never executes |
Real-World Scenarios: Understanding the Flowβ
Scenario 1: Invalid User Input (Controller-Level)β
The Setup:
// AuthService.java
public RegisterResponse registerUser(RegisterRequest request) {
if (request.getUsername() == null || request.getUsername().trim().isEmpty()) {
throw new InvalidInputException("Username is required");
}
// ...
}
// AuthController.java
@PostMapping("/register")
public ResponseEntity<RegisterResponse> register(@RequestBody RegisterRequest request) {
RegisterResponse response = authService.registerUser(request);
return ResponseEntity.ok(response);
}
The Request:
POST /api/auth/register
{
"username": "",
"email": "test@example.com",
"password": "password123"
}
The Response (via GlobalExceptionHandler):
{
"status": 400,
"message": "Username is required",
"timestamp": "2025-10-21T08:30:45.123Z"
}
The Flow:
- β Request passes through security filters (public endpoint)
- β Controller method executes
- β Service called
- β Service throws
InvalidInputException - β Exception propagates to GlobalExceptionHandler
- β Returns 400 Bad Request
Scenario 2: Email Already Exists (Controller-Level)β
The Setup:
// AuthService.java
public RegisterResponse registerUser(RegisterRequest request) {
if (userRepository.findByEmail(request.getEmail()).isPresent()) {
throw new UserAlreadyExistsException("Email already registered");
}
// ...
}
The Request:
POST /api/auth/register
{
"username": "john_doe",
"email": "existing@example.com",
"password": "password123"
}
The Response (via GlobalExceptionHandler):
{
"status": 409,
"message": "Email already registered",
"timestamp": "2025-10-21T08:30:45.123Z"
}
The Flow:
- β Request passes security filters
- β Controller executes
- β Service checks database
- β Email exists! Throws
UserAlreadyExistsException - β GlobalExceptionHandler catches it
- β Returns 409 Conflict
Scenario 3: @PreAuthorize Failure (Filter-Level) β‘β
This is where it gets interesting!
The Setup:
// UserController.java
@PreAuthorize("hasRole('USER')") // β Requires USER role
@GetMapping("/user-only")
public ResponseEntity<Map<String, String>> userOnly() {
return ResponseEntity.ok(Map.of(
"message", "Welcome! This endpoint is for USERs only",
"role", "USER"
));
}
The Request (with ADMIN token):
GET /api/user/user-only
Authorization: Bearer <admin-jwt-token>
The Response (via CustomAccessDeniedHandler):
{
"status": 403,
"message": "Access denied. You do not have the required permissions to access this resource."
}
The Flow:
- β Request enters Spring Security filters
- β JwtAuthenticationFilter validates token - ADMIN token is valid
- β Authorization filter checks
@PreAuthorize("hasRole('USER')") - β ADMIN does not have USER role - ACCESS DENIED
- β CustomAccessDeniedHandler returns 403
- β Controller method NEVER EXECUTES
The Critical Point: Notice the controller never runs! This is filter-level security in action.
Scenario 4: URL Pattern Restriction (Filter-Level) β‘β
The Setup:
// SecurityConfig.java
.requestMatchers("/api/admin/**").hasRole("ADMIN")
// AdminController.java
@DeleteMapping("/users/{id}") // β /api/admin/users/{id}
public ResponseEntity<DeleteResponse> deleteUser(@PathVariable Long id) {
// This code will NEVER run if user lacks ADMIN role
}
The Request (with USER token):
DELETE /api/admin/users/2
Authorization: Bearer <user-jwt-token>
The Response (via CustomAccessDeniedHandler):
{
"status": 403,
"message": "Access denied. You do not have the required permissions to access this resource."
}
The Flow:
- β Request enters filters
- β JWT validated - USER token is valid
- β SecurityConfig pattern check:
/api/admin/**requires ADMIN - β User has USER role, not ADMIN - ACCESS DENIED
- β CustomAccessDeniedHandler returns 403
- β Controller NEVER REACHED
Scenario 5: User Not Found (Controller-Level)β
The Setup:
// AdminController.java
@DeleteMapping("/users/{id}")
public ResponseEntity<DeleteResponse> deleteUser(@PathVariable Long id) {
User user = userRepository.findById(id)
.orElseThrow(() -> new RuntimeException("User not found with id: " + id));
userRepository.deleteById(id);
return ResponseEntity.ok(response);
}
The Request (with ADMIN token):
DELETE /api/admin/users/999
Authorization: Bearer <admin-jwt-token>
The Response (via GlobalExceptionHandler):
{
"status": 500,
"message": "User not found with id: 999",
"timestamp": "2025-10-21T08:30:45.123Z"
}
The Flow:
- β Passes security filters - ADMIN role authorized
- β Controller method executes
- β User lookup fails
- β Throws
RuntimeException - β GlobalExceptionHandler catches it
- β Returns 500 Internal Server Error
Scenario 6: Invalid Login Credentials (Controller-Level)β
The Setup:
// AuthService.java
public LoginResponse loginUser(LoginRequest request) {
User user = userRepository.findByEmail(request.getEmail())
.orElseThrow(() -> new InvalidCredentialsException("Invalid email or password"));
if (!passwordEncoder.matches(request.getPassword(), user.getPassword())) {
throw new InvalidCredentialsException("Invalid email or password");
}
// ...
}
The Request:
POST /api/auth/login
{
"email": "user@example.com",
"password": "wrongpassword"
}
The Response (via GlobalExceptionHandler):
{
"status": 401,
"message": "Invalid email or password",
"timestamp": "2025-10-21T08:30:45.123Z"
}
The Flow:
- β Passes filters (login is public)
- β Controller executes
- β Service finds user
- β Password doesn't match
- β Throws
InvalidCredentialsException - β GlobalExceptionHandler catches it
- β Returns 401 Unauthorized
Best Practices: Choosing the Right Layerβ
β Use Filter-Level (CustomAccessDeniedHandler) For:β
- Spring Security authorization failures
@PreAuthorize/@PostAuthorizeviolations- SecurityConfig URL pattern enforcement
- Anything that prevents controller execution
β Use Controller-Level (GlobalExceptionHandler) For:β
- Business logic errors
- Data validation failures
- Database constraint violations
- Custom application exceptions
- Anything thrown during controller execution
Exception Design: Good vs Badβ
β Good: Specific and Descriptiveβ
throw new UserAlreadyExistsException("Email already registered: " + email);
β Bad: Generic and Unhelpfulβ
throw new RuntimeException("Error");
Consistent Response Formatsβ
Controller-Level: ErrorResponse DTOβ
public class ErrorResponse {
private int status;
private String message;
private String timestamp;
// Constructors, getters, setters
}
Benefits:
- β Consistent structure
- β Easy to parse on frontend
- β
Extensible (add fields like
path,errors[], etc.)
Filter-Level: Raw JSONβ
Map<String, Object> errorResponse = new HashMap<>();
errorResponse.put("status", 403);
errorResponse.put("message", "Access denied...");
Why raw JSON?
- We're outside Spring MVC context
- DTOs not available in filter chain
- Must manually construct with ObjectMapper
Security Considerationsβ
β Never Expose:β
- Stack traces in production
- Internal database errors
- Sensitive system information
- Implementation details
β Always:β
- Log detailed errors server-side
- Return generic messages to client
- Use appropriate HTTP status codes
- Sanitize error messages
Example:
// β BAD - Exposes internals
throw new RuntimeException("SQLException: Duplicate entry 'test@test.com' for key 'users.email'");
// β
GOOD - Generic but helpful
throw new UserAlreadyExistsException("Email already registered");
Testing Both Layersβ
Testing Controller-Level Exceptionsβ
@Test
void shouldReturn400WhenUsernameIsEmpty() {
RegisterRequest request = new RegisterRequest();
request.setUsername(""); // Invalid
mockMvc.perform(post("/api/auth/register")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.message").value("Username is required"));
}
Testing Filter-Level Exceptionsβ
@Test
void shouldReturn403WhenUserTriesToAccessAdminEndpoint() {
String userToken = generateUserToken();
mockMvc.perform(delete("/api/admin/users/1")
.header("Authorization", "Bearer " + userToken))
.andExpect(status().isForbidden())
.andExpect(jsonPath("$.message")
.value("Access denied. You do not have the required permissions to access this resource."));
}
Quick Reference Cheat Sheetβ
When GlobalExceptionHandler Catches It:β
β
throw new InvalidInputException()
β
throw new UserAlreadyExistsException()
β
throw new InvalidCredentialsException()
β
throw new RuntimeException()
β
Any exception thrown in controller/service
When CustomAccessDeniedHandler Catches It:β
β
@PreAuthorize("hasRole('ADMIN')") - user lacks role
β
SecurityConfig: .requestMatchers("/admin/**").hasRole("ADMIN")
β
Spring Security authorization failures
β
Before controller execution
Decision Tree:β
Did the exception occur BEFORE the controller executed?
β
ββ YES β Filter-Level (CustomAccessDeniedHandler)
β Examples: @PreAuthorize, URL patterns
β
ββ NO β Controller-Level (GlobalExceptionHandler)
Examples: Business logic, validation, database errors
Summary: The Power of Two-Tier Architectureβ
Our exception handling system provides:
β Comprehensive Coverage - Errors caught at both filter and controller levels β Consistent Responses - Uniform JSON format across all error types β Defense in Depth - Multiple layers of error handling β Clear Separation - Security errors vs. business logic errors β Developer Friendly - Just throw exceptions, framework handles the rest β Production Ready - Secure, informative error messages
The Key Takeaway π―β
Controllers focus on business logic and throw exceptions - the framework handles the rest automatically through our two-tier exception handling system:
- Filter Level catches security/authorization issues before controllers
- Controller Level catches business logic issues during execution
Understanding this separation is the key to debugging authentication and authorization issues in Spring Boot applications.
What's Next?β
Now that you understand the two-tier architecture, you might want to explore:
- Custom exception types for specific business domains
- Internationalization (i18n) for error messages
- Structured logging for exception tracking
- Monitoring and alerting for production errors
- Request/response logging in filters
Have questions or suggestions? Drop them in the comments below! π
Found this helpful? Share it with your fellow Spring Boot developers! π
