JUN0.DEV
JUN0.DEV

Understanding Exception Handling in Spring Security Filters

Published on
  • avatarJunyoung Yang
GitHubDon-zo/GRITRepository for GRIT, a real-time video study platform

While implementing JWT authentication in GRIT, I ran into a problem where an expired token did not return the common error response that had been agreed on with the frontend. I expected @RestControllerAdvice to catch the exception and return a JSON error response.

But in reality, instead of the custom error response, Tomcat's default 500 error page was returned. Even though it was an authentication failure, the response format did not match the rest of the API, and the frontend could not handle the error in the common way. At first, I naturally thought that the common exception handling code was wrong. But while following the logs, I realized that the exception was not reaching the controller at all.

The cause was the place where the exception happened. Token validation was not happening inside a Spring MVC controller. It was happening earlier, inside the servlet filter.

This post is a record of deciding where to handle exceptions raised inside a JWT authentication filter.

Tomcat error page

Problem

Exceptions that happen inside a controller can be handled by @RestControllerAdvice. But a JWT validation filter runs before the request reaches the DispatcherServlet.

In other words, the exception handling flow I expected and the actual place where the exception happened were different.

After running into this issue, I first checked these points.

  • At which stage does the exception happen?
  • Does the exception reach @RestControllerAdvice?
  • Where does Spring Security expect authentication failures to be handled?
  • Where is it natural to create the custom error response format?

The problem was not simply whether I could catch the exception. It was about where an exception happened and where it should be handled.

First Approach

One common solution is to pass exceptions raised in a servlet filter to Spring's controller advice through HandlerExceptionResolver.

This approach has clear benefits. The error response format agreed on with the frontend can be managed in one place, and it becomes easier to keep controller exceptions and authentication filter exceptions in the same response shape.

I considered this approach first. But during implementation, another issue appeared. The application failed to start, and the ObjectMapper bean that had been used in the project was not injected into the filter side as expected.

At the time, the project was using Spring Boot 4.0. Since Jackson 3 was adopted by default, the package structure had changed. Instead of the existing com.fasterxml.jackson-based ObjectMapper, I had to look at the tools.jackson-based JsonMapper.

Because of this, I rechecked whether pushing the exception into the controller side was really the right fit for the current project structure.

Cause Check

I do not think using HandlerExceptionResolver is wrong by itself. Spring Security also has structures for passing exceptions to the next handling point, and managing error responses in one place can be useful.

However, the exception I was handling happened before an authentication object was created, during the JWT validation filter stage. In this case, rather than forcing it into the MVC side, it felt more natural for the filter to create the authentication failure response.

Even if some response serialization code became duplicated, I chose to handle the problem at the same stage where it occurred.

Filter and MVC layer

Approach

I separated authentication failure handling in the filter from normal controller exception handling.

The direction was:

  • Exceptions from controller and service flow are handled by @RestControllerAdvice.
  • JWT validation failures inside the filter are handled in the filter or in the Spring Security authentication failure flow.
  • The response format is kept similar, but the handling path is not forced into one place.

The response shape still had to look the same from the frontend's point of view, even if the internal handling path was different.

A simplified example looks like this.

try {
    String token = resolveToken(request);
    Authentication authentication = jwtTokenProvider.getAuthentication(token);
    SecurityContextHolder.getContext().setAuthentication(authentication);

    filterChain.doFilter(request, response);
} catch (JwtAuthenticationException e) {
    writeAuthenticationErrorResponse(response, e);
}

The response writer creates the same kind of JSON response that the frontend expects.

private void writeAuthenticationErrorResponse(
        HttpServletResponse response,
        JwtAuthenticationException exception
) throws IOException {
    response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
    response.setContentType(MediaType.APPLICATION_JSON_VALUE);
    response.setCharacterEncoding(StandardCharsets.UTF_8.name());

    ErrorResponse errorResponse = ErrorResponse.of(
            "AUTHENTICATION_FAILED",
            exception.getMessage()
    );

    jsonMapper.writeValue(response.getWriter(), errorResponse);
}

The exact implementation can change depending on the project, but the main point is the same. If the exception happens before the MVC flow, I should not assume that @RestControllerAdvice will handle it.

Implementation Check

While applying this, I checked the Spring Security configuration together.

@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
    return http
            .csrf(AbstractHttpConfigurer::disable)
            .sessionManagement(session ->
                    session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
            )
            .exceptionHandling(exceptionHandling ->
                    exceptionHandling.authenticationEntryPoint(customAuthenticationEntryPoint)
            )
            .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class)
            .build();
}

For authentication errors that Spring Security itself handles, an AuthenticationEntryPoint can create the response. For errors that are raised directly in a custom JWT filter, I had to decide whether to pass them to that flow or write the response from the filter.

The goal was not to make every exception pass through the same class. I needed to understand the execution order and place the handling logic where it fit naturally.

Checkpoints

This issue reminded me that writing code that works quickly and organizing it according to the framework flow are separate problems.

There is still some duplicated serialization logic between the servlet filter and Spring MVC response handling. But in this project, I chose not to mix the roles of each stage too much.

This issue also made me check the difference between the servlet container and the Spring container again. Instead of applying a solution from a blog post as-is, I now first check whether it fits the current project structure and the place where the exception actually happens.

The points I kept were:

  • Check where the exception occurs before deciding where to handle it.
  • Do not assume that @RestControllerAdvice catches every exception in a Spring application.
  • Treat servlet filters and MVC controllers as different execution stages.
  • Keep the response format consistent for the client, even if the internal handling path differs.
  • Avoid forcing all exceptions into one handler if it makes the framework flow unnatural.

Takeaway

The core of this issue was not "how do I catch this exception?" It was "where did this exception happen, and where is it natural to handle it?"

A JWT authentication filter runs before the controller. So if I rely only on controller exception handling, the response flow can become mismatched. Authentication exceptions that happen in a filter need to be handled based on Spring Security's flow and the servlet filter execution order.