In this post, I want to describe an approach to allow anonymous access to a health check endpoint, added using the
MapHealthChecks extension on EndpointRouting. I’ll also cover how to make changes to custom middleware so that it doesn’t apply to endpoints which allow anonymous access. The scenario is based on one that I encountered when upgrading an ASP.NET Core application to 3.1.
Thanks to Ryan Nowak for his ideas on the best way to achieve this requirement.
We have an ASP.NET Core web API service which was originally created as an ASP.NET Core 1.0 project. Over the last few years, we’ve migrated to newer versions of ASP.NET Core. Recently I was performing a new migration of it from ASP.NET Core 2.2 to ASP.NET Core 3.1. The migration, in general, went pretty well. I’ve migrated many projects, so I’m familiar with the things I need to update.
I ran into one challenge though, which at first seemed a little tricky to resolve. In the most recent versions of ASP.NET Core, a revised routing system has been available, called Endpoint Routing. The idea is that routing is assessed earlier in the middleware pipeline, such that the final endpoint which matches the route and any conditions, can be determined at the start of the middleware flow.
This is useful because endpoint routing ensures that subsequent middleware can use the knowledge of the final endpoint in its own logic. I covered an early preview of what would become endpoint routing in this older post. The pros/cons remain the same, even if some of the details are different today.
As part of my upgrade, I was keen to use endpoint routing. Let’s take a look at what the applications Configure method looked like when I started migrating the project. The snippets below are intentionally simplified for this discussion. If you’d like to play with the final source code, it’s available on my GitHub.
Startup Before Migration
In the Configure method from my 2.2 version of the API project, the health check middleware is the first which is registered to run. A health check is required so that our container engine can check on the health of deployed services.
Next, the code adds the standard authentication and authorisation middleware components. In my case, authenticated access is granted to the API by providing a JWT (JSON Web Token) that includes a claim about which tenant the user can access.
I then register a custom middleware which performs some route checking. We’ll look at its code momentarily.
The final registration adds MVC into the pipeline.
Middleware Before Migration
Here is a simplified version of my custom middleware.
My custom middleware performs some early checks for the validity of the URL and then validates whether the user’s claims show that they have access to the tenant ID provided in the path.
NOTE: I’m aware there are other potentially better ways to achieve this authorisation logic which I’m intentionally not covering here. My goal was to migrate the project without making such a significant change. I will likely revisit that in a future post. For now, the middleware works and provides the authorisation we require.
The middleware expects that the URL has at least two parts to the path. The first of these is expected to be the ID of the tenant. If the URL is not valid, then a 404 response is returned.
The claims are then read from the authenticated user identity, looking for a claim named “Tenant”. The Tenant ID must then match the ID in the request path, or the request will be forbidden.
First Migration Attempt
During migration, I made some changes to the Configure method to switch to using the latest endpoint routing feature. After doing so, my code looked something like this.
When using endpoint routing, two pieces of middleware are used. The first registered with
UseRouting adds the middleware which determines the final endpoint for the request.
UseEndpoints is called, including the mappings for endpoints in the application. In my case, I am now using
MapHealthChecks to register my health check endpoint. Then I use
MapControllers such that my controller endpoints are known. I include
RequireAuthoization so that all controller endpoints can be accessed only by an authenticated user.
So What’s the Problem?
Everything looks reasonable, and the code compiles. However, at runtime, there is a problem. If I navigate to the “/healthcheck” URL, to which my health check endpoint is mapped, I receive a 404 – Not Found response.
If we study the before/after Configure method code more closely, the issue is apparent.
Previously, the health check middleware ran first in the pipeline. So requests matching its route pattern were immediately handled and returned.
In the new version, the endpoints are mapped after the route checker. As a result, the request to “/healthcheck” is first getting validated by my custom middleware. Since it contains only a single path part, it’s being considered invalid, and a 404 is returned.
What I’d like to do is have the health check endpoint run without authorisation by any of the built-in components or indeed, my custom middleware. To achieve that, we can mark it as accessible by anonymous users, and update the custom middleware to not apply to endpoints which are explicitly marked as not requiring authorisation.
Here’s the fixed code for the Configure method in the Startup class.
The first step is to mark the health check middleware so that we allow the endpoint to be reached by unauthenticated, anonymous users. This can be achieved by calling the
WithMetadata extension method on the
IEndpointConventionBuilder, returned by the called to
This method accepts one or more objects that represent additional metadata about this endpoint.
Here we can pass in a new instance of the
AllowAnonymousAttribute type. This attribute is typically used to mark controllers, actions or razor pages to identify them as not requiring authentication. The attribute includes a marker interface named
At this point, we have marked this endpoint as reachable without authorisation taking place. We need to update the middleware to honour this.
We can update the middleware to add a few additional lines at the top of its logic.
The changes (lines, 9 to 14), first attempt to get data from the endpoint routing system about the final endpoint. This can return null, so we handle the null case with a null conditional operator when accessing the endpoint properties.
If we have an endpoint, we can request its metadata using the generic
GetMetadata<T> method. This takes the generic type for the metadata that you are looking for. In our case, we can use the
IAllowAnonymous marker interface.
If the returned value is an object, i.e. not null, then we know that the final endpoint does not require authorisation. As a result, we can invoke the next middleware and return. When the
IAllowAnonymous metadata is present, this avoids the custom route authorisation code from being executed.
This approach was not immediately apparent but what we ended up with is quite clean code. We first marked our health check endpoint with an attribute in its metadata, so that we could identify that it didn’t require authentication or authorisation to execute. We then made a small change in our middleware to support conditionally applying the route checking logic, only against endpoints which require authorisation.
This same approach is used in the official ASP.NET Core authorisation middleware and can be seen in their GitHub repository.