Start Debugging

How to Remove or Hide Individual Endpoints That MapIdentityApi Maps in ASP.NET Core

MapIdentityApi<TUser>() maps ten endpoints and has no switch to skip one. Here are four measured ways to deal with that on .NET 11: hide an endpoint from OpenAPI, make it return 404, shadow it, or remove it from routing entirely, plus the /confirmEmail trap that turns registration into a 500.

Short answer: MapIdentityApi<TUser>() has no parameter for choosing endpoints, and that is still true in .NET 11 RC 2. What you get back is an IEndpointConventionBuilder, and its conventions run on every endpoint with the final route pattern available, so you can match /register or /manage/2fa by pattern. To hide an endpoint from the OpenAPI document only, add ExcludeFromDescriptionAttribute in a convention. To make it unusable, also replace EndpointBuilder.RequestDelegate with one that returns 404. To remove it from routing completely, map Identity into a private IEndpointRouteBuilder and re-expose its endpoints through a filtering EndpointDataSource (about 30 lines, shown below). Never remove /confirmEmail while /register or /resendConfirmationEmail are live: both throw NotSupportedException and return 500.

Everything below was run on macOS against SDK 11.0.100-rc.1.26425.128 with Microsoft.AspNetCore.Identity.EntityFrameworkCore 11.0.0-rc.1.26425.128 and an EF Core InMemory store. I diffed IdentityApiEndpointRouteBuilderExtensions.cs between the v8.0.0, v11.0.0-rc.1.26425.128 and v11.0.0-rc.2.26504.105 tags: the route list is identical in all three, and the file did not change between RC 1 and RC 2, so the techniques apply to .NET 8, 9, 10 and 11.

Why there is no built-in way to skip an endpoint

MapIdentityApi<TUser>() is one extension method that creates an empty route group and maps everything into it:

MethodRouteRequires auth
POST/registerno
POST/loginno
POST/refreshno
GET/confirmEmailno
POST/resendConfirmationEmailno
POST/forgotPasswordno
POST/resetPasswordno
POST/manage/2fayes
GET/manage/infoyes
POST/manage/infoyes

The source then wraps the group in a private IdentityEndpointsConventionBuilder type, with a comment saying it does so “to avoid a potential future behavioral breaking change”. You cannot cast the return value back to RouteGroupBuilder, and the handlers are lambdas inside the method, so you cannot call them yourself either.

The request to split it up has been open since 2024 as dotnet/aspnetcore#55792 (“Split MapIdentityApi() into multiple APIs and allow selective end points to be enabled”), and the broader dotnet/aspnetcore#50303 about extensibility goes back to the .NET 8 previews. Both sit in the Backlog milestone. Until one of them ships, the usual reasons people hit this (public sign-up must be off, 2FA is handled elsewhere, the OpenAPI document should not advertise password reset) all have to be handled from the outside.

The test setup

This is the app every variant below plugs into. The Identity endpoints live under /account, because that is how most real apps mount them and because it affects how you match routes:

// .NET 11 RC 1, Microsoft.AspNetCore.Identity.EntityFrameworkCore 11.0.0-rc.1.26425.128
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Identity.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore;

var builder = WebApplication.CreateBuilder(args);
builder.Services.AddDbContext<AppDbContext>(o => o.UseInMemoryDatabase("ids"));
builder.Services.AddAuthorization();
builder.Services.AddIdentityApiEndpoints<IdentityUser>()
    .AddEntityFrameworkStores<AppDbContext>();
builder.Services.AddOpenApi();

var app = builder.Build();
app.MapOpenApi();

var account = app.MapGroup("/account");
account.MapIdentityApi<IdentityUser>();

app.Run();

class AppDbContext(DbContextOptions<AppDbContext> options)
    : IdentityDbContext<IdentityUser>(options);

With no changes, /openapi/v1.json lists all ten routes under /account, POST /account/register returns 200, and an anonymous POST /account/manage/2fa returns 401.

One detail matters for every technique below: inside a convention, RouteEndpointBuilder.RoutePattern.RawText is the final pattern, group prefix included. Logging it from a convention prints /account/register, /account/manage/2fa, and so on, not /register. Identity itself relies on this when it names the confirm-email endpoint. So match with EndsWith, or compare against the full prefixed path:

// .NET 11 RC 1
string[] blocked = ["/register", "/manage/2fa"];

bool IsBlocked(EndpointBuilder b) =>
    b is RouteEndpointBuilder rb &&
    blocked.Any(p => rb.RoutePattern.RawText!.EndsWith(p, StringComparison.OrdinalIgnoreCase));

Option 1: hide an endpoint from the OpenAPI document only

If the endpoint should keep working but stay out of your published contract (common for /refresh or /manage/info in a public API document), add the same metadata that .ExcludeFromDescription() adds:

// .NET 11 RC 1
account.MapIdentityApi<IdentityUser>().Add(b =>
{
    if (IsBlocked(b))
        b.Metadata.Add(new ExcludeFromDescriptionAttribute());
});

Measured result: /account/register and /account/manage/2fa disappear from /openapi/v1.json, and POST /account/register still returns 200. That is the point of this option, and also its limit. Hiding is documentation, not access control. Anyone who has read the Identity docs knows the route exists.

ExcludeFromDescriptionAttribute lives in Microsoft.AspNetCore.Routing and implements IExcludeFromDescriptionMetadata, which both the built-in Microsoft.AspNetCore.OpenApi generator and Swashbuckle’s API explorer honour. If you would rather keep all of this in one place, you can do the same filtering in a document transformer, as shown in the post on OpenAPI operation and schema transformers, but the metadata approach is simpler and also covers other API explorer consumers.

Option 2: keep the route but make it return 404

To actually switch an endpoint off, replace its request delegate in the same convention. Conventions receive the EndpointBuilder before the RouteEndpoint is built, and RequestDelegate is a settable property:

// .NET 11 RC 1
account.MapIdentityApi<IdentityUser>().Add(b =>
{
    if (!IsBlocked(b)) return;

    b.Metadata.Add(new ExcludeFromDescriptionAttribute());
    b.RequestDelegate = ctx =>
    {
        ctx.Response.StatusCode = StatusCodes.Status404NotFound;
        return Task.CompletedTask;
    };
});

Measured result: POST /account/register returns 404, both routes are gone from the OpenAPI document, and every other endpoint behaves as before. I got the same result with .Finally(...) instead of .Add(...). Finally conventions run after all other conventions, which is the safer choice if something else in your pipeline also touches RequestDelegate.

The catch is that the endpoint still exists as far as routing and authorization are concerned. It keeps all its metadata, including the RequireAuthorization() that Identity puts on the /manage group. The authorization middleware runs before the endpoint’s delegate, so an anonymous POST /account/manage/2fa still returns 401, not 404. Only authenticated callers reach your 404. If the goal is “do not reveal that this exists”, that difference is visible from outside.

An endpoint filter gets you to the same place, because AddEndpointFilter works on any IEndpointConventionBuilder:

// .NET 11 RC 1
account.MapIdentityApi<IdentityUser>().AddEndpointFilter(async (ctx, next) =>
{
    var route = (ctx.HttpContext.GetEndpoint() as RouteEndpoint)?.RoutePattern.RawText ?? "";
    if (blocked.Any(p => route.EndsWith(p))) return Results.NotFound();
    return await next(ctx);
});

It returned 404 for /account/register too, but it is strictly worse here: the filter runs on every Identity request, the endpoints stay in the OpenAPI document unless you add the metadata as well, and the model binding of the request body has already happened by the time the filter runs. The endpoint filters vs middleware comparison explains that ordering in more detail. Use the delegate swap.

Option 3: shadow the route with a higher-priority endpoint

A lot of answers online suggest mapping your own endpoint on the same route. Mapped naively, with account.MapPost("/register", () => Results.NotFound()) after MapIdentityApi, it does nothing useful: I expected an AmbiguousMatchException, but a JSON POST /account/register still returned 200 from Identity’s handler. Identity’s endpoint binds a [FromBody] parameter, so it carries application/json accepts metadata, and the content-type matcher policy ranks it above your endpoint, which declares nothing. Your endpoint silently never runs. Give it a lower Order and the matcher prefers it before any of that tie-breaking happens:

// .NET 11 RC 1
account.MapIdentityApi<IdentityUser>();
account.MapPost("/register", () => Results.NotFound())
    .WithOrder(-1)
    .ExcludeFromDescription();

Measured result: POST /account/register returns 404, and the endpoint list now contains both POST /account/register order=0 and POST /account/register order=-1. The original endpoint is still in the OpenAPI document, because ExcludeFromDescription() only applies to your shadow endpoint. You need option 1 on top to hide it.

Shadowing is the right tool when you want to replace an endpoint rather than remove it, for example a /register that also requires an invite code, while the rest of MapIdentityApi stays stock. For plain removal it is the messiest of the four options.

Option 4: remove endpoints from routing entirely

To make an endpoint truly not exist (no route, no OpenAPI entry, no 401 for anonymous callers, just the same 404 as any unknown URL), you have to stop its RouteEndpoint from reaching the routing system. MapIdentityApi only uses two members of the IEndpointRouteBuilder you pass in: ServiceProvider, to resolve its services, and DataSources, which MapGroup("") adds its endpoint source to. That gives you a seam:

  1. Pass MapIdentityApi a small IEndpointRouteBuilder of your own whose DataSources collection is private.
  2. Wrap that private collection in an EndpointDataSource that drops the endpoints you do not want.
  3. Add the wrapper to the real route builder’s DataSources.
// .NET 11 RC 1, works the same on .NET 8, 9 and 10
using Microsoft.Extensions.Primitives;

static class IdentityApiExceptExtensions
{
    public static IEndpointConventionBuilder MapIdentityApiExcept<TUser>(
        this IEndpointRouteBuilder endpoints, params string[] excludedRoutes)
        where TUser : class, new()
    {
        var capture = new CapturingRouteBuilder(endpoints);
        var conventions = capture.MapIdentityApi<TUser>();

        bool Keep(Endpoint e) =>
            e is not RouteEndpoint re ||
            !excludedRoutes.Any(r =>
                re.RoutePattern.RawText!.EndsWith(r, StringComparison.OrdinalIgnoreCase));

        endpoints.DataSources.Add(new FilteringEndpointDataSource(capture.DataSources, Keep));
        return conventions;
    }

    private sealed class CapturingRouteBuilder(IEndpointRouteBuilder inner) : IEndpointRouteBuilder
    {
        public ICollection<EndpointDataSource> DataSources { get; } = new List<EndpointDataSource>();
        public IServiceProvider ServiceProvider => inner.ServiceProvider;
        public IApplicationBuilder CreateApplicationBuilder() => inner.CreateApplicationBuilder();
    }

    private sealed class FilteringEndpointDataSource(
        ICollection<EndpointDataSource> sources, Func<Endpoint, bool> keep) : EndpointDataSource
    {
        private readonly CompositeEndpointDataSource _inner = new(sources);

        public override IReadOnlyList<Endpoint> Endpoints =>
            _inner.Endpoints.Where(keep).ToList();

        public override IReadOnlyList<Endpoint> GetGroupedEndpoints(RouteGroupContext context) =>
            _inner.GetGroupedEndpoints(context).Where(keep).ToList();

        public override IChangeToken GetChangeToken() => _inner.GetChangeToken();
    }
}

Usage is a one-line change:

// .NET 11 RC 1
var account = app.MapGroup("/account");
account.MapIdentityApiExcept<IdentityUser>("/register", "/manage/2fa");

Measured result: the endpoint list has eight entries instead of ten, /openapi/v1.json lists eight paths, and both POST /account/register and an anonymous POST /account/manage/2fa return 404. The other eight endpoints work as before, and the returned convention builder still applies to them, so .RequireRateLimiting(...) or .WithTags(...) chained on the result behaves as usual.

Two parts of that code are easy to get wrong:

This relies on MapIdentityApi using only ServiceProvider and DataSources. That is true in every version from 8.0 to 11 RC 2, but it is an implementation detail. A future version that, say, called CreateApplicationBuilder() would still work through the delegation above. One that maps into some other builder would not. A test that asserts the expected route list (the IEnumerable<EndpointDataSource> dump I used is three lines) will catch that on upgrade.

The /confirmEmail trap

The one endpoint you cannot drop on its own is GET /confirmEmail. /register, /resendConfirmationEmail and the email-change branch of POST /manage/info all generate the confirmation link by endpoint name, with LinkGenerator.GetUriByName. The name is assigned in a Finally convention on /confirmEmail, as MapIdentityApi- followed by the final route pattern.

With option 4 and MapIdentityApiExcept<IdentityUser>("/confirmEmail"), the convention still runs (the endpoint is built, then filtered out), so the name is set, but the link generator cannot find an endpoint with that name. Registration then fails:

System.NotSupportedException: Could not find endpoint named 'MapIdentityApi-/account/confirmEmail'.

Measured result: POST /account/register returns 500 and so does POST /account/resendConfirmationEmail, while /login keeps working. If you do not want email confirmation through this API at all, remove /register and /resendConfirmationEmail together with /confirmEmail, or keep /confirmEmail routed and only hide it from OpenAPI with option 1.

Option 2 does not have this problem, because the endpoint still exists for link generation. It just answers 404.

Which option to use

If you are already at the point of replacing several endpoints, compare the trade-offs in JWT vs cookie authentication in ASP.NET Core 11 first. The bearer tokens MapIdentityApi issues are opaque Data Protection tokens, not JWTs, and that is often the real reason a team outgrows these endpoints.

Gotchas

Sources

Comments

Sign in with GitHub to comment. Reactions and replies thread back to the comments repo.

< Back