ASP.NET Core 10: Securing a .NET 10 Minimal API with ASP.NET Core Identity Built-in Endpoints, EF Core & Swagger

In this article, we will explore ASP.NET Core 10 Security for Minimal APIs with Built-In Security Enhancements 

Why ASP.NET Core Identity's Built-in API Endpoints?

Starting with .NET 8, ASP.NET Core Identity ships a set of ready-made Minimal API endpoints for authentication. Instead of hand-rolling /register, /login, /refresh-token, and email-confirmation logic, you can call three simple extension methods:

  • AddIdentityApiEndpoints<TUser>() — This registers Identity's services configured for API scenarios (no cookies/redirects, JSON responses).
  • .AddEntityFrameworkStores<TDbContext>() — This tells Identity to persist users, roles, and tokens using Entity Framework Core.
  • app.MapIdentityApi<TUser>() — This maps the actual HTTP routes (register, login, refresh, confirmEmail, resendConfirmationEmail, forgotPassword, resetPassword, manage/info, manage/2fa) onto your route group.

Combined with Bearer tokens instead of cookies, this offfers a production-ready authentication surface for SPAs, mobile apps, and third-party API consumers in just a few lines of code.

Step 1: Wiring Up Identity Services

The code in Listing 1 shows the class that has all the Identity configuration lives in one extension method, AddApplicationIdentity, so that Program.cs stays clean:

public static class IdentityServiceExtensions
{
    public static IServiceCollection AddApplicationIdentity(
        this IServiceCollection services,
        IConfiguration configuration,
        IHostEnvironment environment)
    {
        var connectionString = configuration.GetConnectionString("IdentityDatabase");
        if (string.IsNullOrWhiteSpace(connectionString))
        {
            throw new InvalidOperationException("Configure ConnectionStrings:IdentityDatabase for SQL Server.");
        }

        services.AddDbContext<ApplicationDbContext>(options => options.UseSqlServer(connectionString));

        services.AddIdentityApiEndpoints<ApplicationUser>(options =>
        {
            // Skip email confirmation in Development since DevelopmentEmailSender only logs
            // confirmation links instead of sending real emails, which would otherwise block login.
            options.SignIn.RequireConfirmedEmail = !environment.IsDevelopment();
            options.User.RequireUniqueEmail = true;
            options.Lockout.MaxFailedAccessAttempts = 5;
            options.Lockout.DefaultLockoutTimeSpan = TimeSpan.FromMinutes(15);
        })
        .AddEntityFrameworkStores<ApplicationDbContext>();

        services.AddAuthentication(options =>
        {
            options.DefaultAuthenticateScheme = IdentityConstants.BearerScheme;
            options.DefaultChallengeScheme = IdentityConstants.BearerScheme;
        });
        services.AddAuthorization();

        services.AddOptions<BearerTokenOptions>(IdentityConstants.BearerScheme)
            .Configure(options =>
            {
                options.BearerTokenExpiration = TimeSpan.FromMinutes(
                    configuration.GetValue("IdentityTokens:AccessTokenMinutes", 15));
                options.RefreshTokenExpiration = TimeSpan.FromDays(
                    configuration.GetValue("IdentityTokens:RefreshTokenDays", 7));
            })
            .Validate(options => options.BearerTokenExpiration > TimeSpan.Zero,
                "Access token lifetime must be positive.")
            .Validate(options => options.RefreshTokenExpiration > options.BearerTokenExpiration,
                "Refresh token lifetime must exceed the access token lifetime.")
            .ValidateOnStart();

        if (!environment.IsDevelopment())
        {
            throw new InvalidOperationException(
                "Replace the Development email sender registration with a production IEmailSender<ApplicationUser>.");
        }

        services.AddTransient<IEmailSender<ApplicationUser>, DevelopmentEmailSender>();
        return services;
    }
}

Listing 1: The Identity Class

What this code actually does

  • AddDbContext<ApplicationDbContext>: This registers EF Core with SQL Server as the persistence provider for Identity's tables (users, roles, tokens).
  • AddIdentityApiEndpoints<ApplicationUser>: This configures Identity for API use. Notably, RequireConfirmedEmail is toggled off only in Development, because the sample project doesn't send real emails locally (see the DevelopmentEmailSender below) in Production, confirmation is enforced.
  • Bearer scheme as default: DefaultAuthenticateScheme/DefaultChallengeScheme are set to IdentityConstants.BearerScheme, which is exactly what .AddBearerToken() configures behind the scenes for MapIdentityApi  tokens are validated from the Authorization: Bearer <token> header instead of cookies.
  • BearerTokenOptions: The customizes access-token and refresh-token lifetimes from appsettings.json, with validation rules enforced on startup (ValidateOnStart) so misconfiguration fails immediately rather than silently at runtime.
  • Email sender registration: The throws in non-Development environments as a deliberate guardrail, forcing a real IEmailSender<ApplicationUser> implementation (e.g. SendGrid, SMTP) to be wired up before shipping to Production.

Step 2: The Application User & DbContext

The code in Listing 2 shows that, rather than using the framework's own IdentityUser, the project defines a custom ApplicationUser so tat we can add extra profile fields without breaking changes:

public sealed class ApplicationUser : IdentityUser
{
    public string? DisplayName { get; set; }
}
public sealed class ApplicationDbContext(DbContextOptions<ApplicationDbContext> options)
    : IdentityDbContext<ApplicationUser>(options)
{
    protected override void OnModelCreating(ModelBuilder builder)
    {
        base.OnModelCreating(builder);

        builder.Entity<ApplicationUser>()
            .Property(user => user.DisplayName)
            .HasMaxLength(100);
    }
}

Listing 2: The ApplicationUser and ApplicationDbContext classes

The ApplicationDbContext inherits IdentityDbContext<ApplicationUser>, which already defines all the Identity tables like AspNetUsers, AspNetRoles, AspNetUserTokens, etc. The override is just to add a column-length constraint for the new DisplayName property.

Step 3:  Program.cs — Put Everything Together

using Core10_JWTApp.Identity;
using Core10_JWTApp.Patients;
using Microsoft.AspNetCore.OpenApi;
using Microsoft.OpenApi;

var builder = WebApplication.CreateBuilder(args);

// Registers OpenAPI document generation, and adds a Bearer security scheme so tokens
// can be supplied via the Swagger UI "Authorize" button.
builder.Services.AddOpenApi(options =>
{
    options.AddDocumentTransformer((document, context, cancellationToken) =>
    {
        document.Components ??= new OpenApiComponents();
        document.Components.SecuritySchemes ??= new Dictionary<string, IOpenApiSecurityScheme>();
        document.Components.SecuritySchemes["Bearer"] = new OpenApiSecurityScheme
        {
            Type = SecuritySchemeType.Http,
            Scheme = "bearer",
            BearerFormat = "JWT",
            In = ParameterLocation.Header,
            Description = "Enter the access token returned by POST /auth/login."
        };
        document.Security ??= new List<OpenApiSecurityRequirement>();
        document.Security.Add(new OpenApiSecurityRequirement
        {
            [new OpenApiSecuritySchemeReference("Bearer", document)] = new List<string>()
        });
        return Task.CompletedTask;
    });
});

// Registers EF Core DbContext, ASP.NET Core Identity API endpoints, and bearer token authentication.
builder.Services.AddApplicationIdentity(builder.Configuration, builder.Environment);

var app = builder.Build();

if (app.Environment.IsDevelopment())
{
    // Ensure the Identity database/schema exists on startup (Development only, no migrations required).
    await using var scope = app.Services.CreateAsyncScope();
    var database = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
    await database.Database.EnsureCreatedAsync();

    // Expose the OpenAPI JSON document for local testing/tools.
    app.MapOpenApi();

    // Serve the Swagger UI page at /swagger, so all endpoints can be browsed and tested.
    app.UseSwaggerUI(options =>
    {
        options.SwaggerEndpoint("/openapi/v1.json", "Core10_JWTApp v1");
        options.RoutePrefix = "swagger";
    });
}

app.UseHttpsRedirection();
app.UseAuthentication();   // Resolves the caller's identity (bearer token) from the request.
app.UseAuthorization();    // Enforces RequireAuthorization() rules on endpoints.

// Groups all Identity endpoints (register, login, refresh, confirmEmail, manage/info, etc.)
// under the "/auth" prefix.
var authentication = app.MapGroup("/auth").WithTags("Authentication");
authentication.MapIdentityApi<ApplicationUser>();

// Groups the custom Patients API under "/api".
var api = app.MapGroup("/api").WithTags("Patients");
api.MapPatientEndpoints();

app.Run();

Listing 3: Program.cs to put all together. 

What Program.cs code does?

  • OpenAPI + Bearer scheme: .NET 9/10's built-in OpenAPI generator doesn't add a security scheme automatically, so a document transformer is used to inject a Bearer HTTP security scheme into the generated spec. This is used to have the Swagger UI with an Authorize button that attaches the token to every secured request.
  • EnsureCreatedAsync(): in Development, creates the database and tables if they don't already exist this is a quick way to bootstrap without running EF Core migrations.
  • MapOpenApi() + UseSwaggerUI(): the framework generates the raw OpenAPI JSON at /openapi/v1.json; Swashbuckle's SwaggerUI middleware then renders an interactive HTML page at /swagger that reads that JSON.
  • Middleware order matters: UseAuthentication() must run before UseAuthorization() the authentication identifies who is calling, the authorization decides what they're allowed to do.
  • MapIdentityApi<ApplicationUser>(): This is the single call that maps all the ASP.NET Core 10 built-in Identity routes: /register, /login, /refresh, /confirmEmail, /resendConfirmationEmail, /forgotPassword, /resetPassword, and /manage/info (for reading/updating email & password), all prefixed with /auth.
  • MapPatientEndpoints(): registers the custom, secured Patients API under /api.

Step 4:  A Secured "Patients" Resource

To demonstrate a real protected resource beyond authentication itself, a small Patients API was added: an entity class, an in-memory sample dataset, and Minimal API endpoints that require a valid bearer token.

The Patient entity

public sealed class Patient
{
    public int Id { get; set; }
    public string Name { get; set; } = string.Empty;
    public int Age { get; set; }
    public string Gender { get; set; } = string.Empty;
    public string Diagnosis { get; set; } = string.Empty;
    public string ContactNumber { get; set; } = string.Empty;
    public DateOnly AdmissionDate { get; set; }
}

Listing 4: The Patient Entity

A plain POCO used both as the in-memory sample data shape and the JSON contract returned by the API. Sample data lives in a static PatientStore class so no database is required for this demo endpoint.

public static class PatientEndpoints
{
    public static RouteGroupBuilder MapPatientEndpoints(this RouteGroupBuilder group)
    {
        var patients = group.MapGroup("/patients").RequireAuthorization();
        patients.MapGet("", GetPatients).WithName("GetPatients");
        patients.MapGet("/{id:int}", GetPatientById).WithName("GetPatientById");
        return group;
    }

    private static Ok<IReadOnlyList<Patient>> GetPatients() =>
        TypedResults.Ok(PatientStore.SamplePatients);

    private static Results<Ok<Patient>, NotFound> GetPatientById(int id)
    {
        var patient = PatientStore.SamplePatients.FirstOrDefault(p => p.Id == id);
        return patient is null ? TypedResults.NotFound() : TypedResults.Ok(patient);
    }
}

Listing 5: The secured endpoints

The key line is .RequireAuthorization() on the route group, it forces every route under /patients to reject anonymous callers with 401 Unauthorized unless a valid Bearer token that is obtained from /auth/login is attached to the request. The TypedResults is used instead of plain Results for strongly-typed, OpenAPI-friendly responses like 200 OK or 404 Not Found are declared right in the method signature.

Following Video shows the execution:


Summary with Important Points

  1. ASP.NET Core Identity's built-in API endpoints eliminate boilerplate auth code — registration, login, refresh tokens, and email confirmation come for free.
  2. AddIdentityApiEndpoints<TUser>().AddEntityFrameworkStores<TDbContext>() plus app.MapIdentityApi<TUser>() is all it takes to stand up a token-based auth API.
  3. Bearer scheme (instead of cookies) is the right choice for APIs consumed by SPAs, mobile clients, or third parties.
  4. RequireAuthorization() on a route group is the simplest way to lock down a whole feature (like the Patients API) behind authentication.
  5. A document transformer lets you extend the built-in OpenAPI generator (e.g. adding a Bearer security scheme) without needing a full third-party Swagger generator.
The code for this article can be downloaded from this link.


Popular posts from this blog

Azure AI Building RAG Application Solution: Using Azure AI Search Service and Creating Data Source, Index, and Indexer

ASP.NET Core 8 API: How to implement REPR Pattern Endpoints in ASP.NET Core 8 API

ASP.NET Core 7: Using PostgreSQL to store Identity Information