Htmx authentication error handling

Posted at — Oct 4, 2022
Taming Thymeleaf cover
Interested in learning more about Thymeleaf? Check out my book Taming Thymeleaf. The book combines all of my Thymeleaf knowledge into an easy to follow step-by-step guide.

Using htmx is a great way to make your Spring Boot with Thymeleaf web application dynamic without page refreshes. Since there are no page refreshes, but transparent AJAX calls going on, it is important to put in error handling code to ensure a good user experience in case something goes wrong. This blog post shows how to do this.

As an example, we will create a simple webpage with a button that will load a random Chuck Norris joke from https://api.chucknorris.io/. The webpage is secured with a very basic Spring Security configuration.

The <body> part of the Thymeleaf template looks like this:

<body>
<h1>Auth Error Handling</h1>
<button hx:get="@{/jokes/random}"
        hx-target="#joke">Get new joke
</button>
<div id="joke" class="joke-parent"></div>
<script type="text/javascript" th:src="@{/webjars/htmx.org/dist/htmx.min.js}"></script>
</body>

The hx:get works with a Thymeleaf expression because the htmx-spring-boot-thymeleaf library is on the classpath of the sample project.

Without any special configuration, this is how it works:

  1. Spring Security shows a default login page when trying to access the application:

    htmx auth error 1
  2. After login, the home page is shown with the button to get a new random joke:

    htmx auth error 2
  3. Pressing the button loads a new joke through htmx:

    htmx auth error 3
  4. If we log out in a different tab and try the button again, we get this result:

    htmx auth error 4

    Note how the login page is suddenly embedded into our application.

The reason this happens is easily understood if we follow the flow that happens:

  1. The user presses the button

  2. A request is done by htmx to the /jokes/random endpoint

  3. Spring Security notices that the user is not logged on, so it sends a 302 redirect to /login back to the browser.

  4. Htmx follows the redirect (technically it’s browser that does this and it’s transparent to htmx) and receives the HTML of the login page.

  5. Htmx swaps whatever HTML it receives into the current page, leading to the login page embedded in our application.

Let’s fix this :-)

We need a way to tell htmx to do a full page reload. This can be done by adding HX-Refresh: true to the to response headers.

However, we can’t add this header to the redirect because htmx will never see that header. We need to send a 403 Forbidden with the header so that htmx can react to it.

Spring Security has the AuthenticationEntryPoint which controls the behaviour of what happens when an authentication flow is started. There are 2 implementations that are important here:

  • LoginUrlAuthenticationEntryPoint: This is the redirect-to-login-page behaviour which is active by default

  • Http403ForbiddenEntryPoint: This is an implementation that returns 403 Forbidden response always.

Let’s create our own AuthenticationEntryPoint that re-uses Http403ForbiddenEntryPoint and adds the header to force htmx into a full page refresh:

import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.AuthenticationEntryPoint;
import org.springframework.security.web.authentication.Http403ForbiddenEntryPoint;

import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;

public class HxRefreshHeaderAuthenticationEntryPoint implements AuthenticationEntryPoint { (1)

    private final AuthenticationEntryPoint forbiddenEntryPoint;

    public HxRefreshHeaderAuthenticationEntryPoint() {
        this.forbiddenEntryPoint = new Http403ForbiddenEntryPoint(); (2)
    }

    @Override
    public void commence(HttpServletRequest request,
                         HttpServletResponse response,
                         AuthenticationException authException) throws IOException, ServletException {
        response.addHeader("HX-Refresh", "true"); (3)
        forbiddenEntryPoint.commence(request, response, authException); (4)
    }
}
1 We need to implement the AuthenticationEntryPoint.
2 Create an instance of Http403ForbiddenEntryPoint to delegate to
3 Set the HX-Refresh: true response header to force htmx to do a full page refresh.
4 Delegate the work of returning the 403 Forbidden response to the Http403ForbiddenEntryPoint

Finally, we configure Spring Security to use our custom AuthenticationEntryPoint:

@Configuration
public class WebSecurityConfiguration {

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http.authorizeRequests(registry -> registry.mvcMatchers("/**").authenticated())
            .formLogin().permitAll();

        var entryPoint = new HxRefreshHeaderAuthenticationEntryPoint();
        var requestMatcher = new RequestHeaderRequestMatcher("HX-Request");
        http.exceptionHandling(exception ->
                                       exception.defaultAuthenticationEntryPointFor(entryPoint,
                                                                                    requestMatcher)); (1)
        return http.build();
    }

    ...
}
1 Configure the authentication entry point to be active when the request has the HX-Request header.

If we now test again, we have this flow:

  1. Start the application and log on.

  2. Press the button to get a new joke, this should work fine.

  3. Open a new tab at http://localhost:8080/logout. The default logout page of Spring Security is shown. Confirm the logout.

  4. Go back to the first tab and press the button again.

  5. The htmx call will receive a 403 and will do a full page refresh, showing the login page again.

If we open Chrome Dev Tools, we can see this as well:

htmx auth error 5

Conclusion

This post showed how to properly handle authentication errors with Thymeleaf and htmx.

See thymeleaf-htmx-auth-error-handling on GitHub for the full sources of this example.

If you have any questions or remarks, feel free to post a comment at GitHub discussions.

If you want to be notified in the future about new articles, as well as other interesting things I'm working on, join my mailing list!
I send emails quite infrequently, and will never share your email address with anyone else.