DEV Community

harshvardhan
harshvardhan

Posted on

Setting Up a Production-Ready ASP.NET Core Web API: JWT, Identity, CQRS, Validation, Exception Handling & CORS

When building an ASP.NET Core Web API, Program.cs can quickly become the central place where everything comes together.

Database configuration, dependency injection, authentication, authorization, CQRS, validation, exception handling, CORS, Swagger, and application middleware all need to be configured correctly.

In this article, I'll walk through how I configured these pieces in an ASP.NET Core Web API using Entity Framework Core, ASP.NET Identity, JWT authentication, MediatR, FluentValidation, and Clean Architecture concepts.

1. Creating the WebApplication Builder

The first step is creating the application builder:

var builder = WebApplication.CreateBuilder(args);
Enter fullscreen mode Exit fullscreen mode

WebApplication.CreateBuilder() initializes the application and provides access to:

  • Configuration
  • Dependency Injection
  • Logging
  • Environment information
  • Application services

The builder.Services collection is where we register the services our application needs.


2. Adding Controllers

builder.Services.AddControllers();
Enter fullscreen mode Exit fullscreen mode

This registers ASP.NET Core MVC controllers with the dependency injection container.

For example:

[ApiController]
[Route("api/[controller]")]
public class AuthController : ControllerBase
{
    // endpoints
}
Enter fullscreen mode Exit fullscreen mode

Without AddControllers(), ASP.NET Core won't be able to discover and use controller-based API endpoints.

Later, we map these controllers using:

app.MapControllers();
Enter fullscreen mode Exit fullscreen mode

3. Global Exception Handling

Instead of handling exceptions individually inside every controller or handler, I use a global exception handler.

builder.Services.AddExceptionHandler<GlobalExceptionHandler>();
builder.Services.AddProblemDetails();
Enter fullscreen mode Exit fullscreen mode

The GlobalExceptionHandler is responsible for catching unhandled exceptions and converting them into appropriate HTTP responses.

This gives the API a consistent error format.

For example, instead of writing:

try
{
    // logic
}
catch(Exception ex)
{
    // handle exception
}
Enter fullscreen mode Exit fullscreen mode

inside every controller, exceptions can be handled centrally.

The middleware is then enabled with:

app.UseExceptionHandler();
Enter fullscreen mode Exit fullscreen mode

This keeps controllers and handlers much cleaner.


4. Configuring Entity Framework Core

The application uses SQL Server as its database.

First, the connection string is retrieved from configuration:

string connectionString =
    builder.Configuration.GetConnectionString("default");
Enter fullscreen mode Exit fullscreen mode

Then the AppDbContext is registered:

builder.Services.AddDbContext<AppDbContext>(options =>
    options.UseSqlServer(connectionString));
Enter fullscreen mode Exit fullscreen mode

This allows Entity Framework Core to inject AppDbContext wherever it is required.

For example:

public class LoginCommandHandler
{
    private readonly AppDbContext _context;

    public LoginCommandHandler(AppDbContext context)
    {
        _context = context;
    }
}
Enter fullscreen mode Exit fullscreen mode

The connection string itself should normally be stored in appsettings.json or another configuration provider rather than being hardcoded.

Example:

{
  "ConnectionStrings": {
    "default": "Server=.;Database=MyDatabase;Trusted_Connection=True;TrustServerCertificate=True"
  }
}
Enter fullscreen mode Exit fullscreen mode

5. Registering MediatR

For CQRS, I use MediatR to separate commands and queries from the controllers.

builder.Services.AddMediatR(cfg =>
    cfg.RegisterServicesFromAssembly(
        typeof(LoginCommandHandler).Assembly));
Enter fullscreen mode Exit fullscreen mode

This tells MediatR to scan the assembly containing LoginCommandHandler and automatically register the handlers it finds.

For example:

public record LoginCommand(
    string Email,
    string Password
) : IRequest<LoginResponse>;
Enter fullscreen mode Exit fullscreen mode

The corresponding handler can be:

public class LoginCommandHandler
    : IRequestHandler<LoginCommand, LoginResponse>
{
    public async Task<LoginResponse> Handle(
        LoginCommand request,
        CancellationToken cancellationToken)
    {
        // login logic
    }
}
Enter fullscreen mode Exit fullscreen mode

The controller doesn't need to know how the login operation is implemented.

It simply sends the command:

await _mediator.Send(command);
Enter fullscreen mode Exit fullscreen mode

This is one of the main benefits of using CQRS with MediatR.


6. Adding FluentValidation

Validation is another responsibility that I prefer to keep separate from controllers.

builder.Services.AddValidatorsFromAssembly(
    typeof(LoginCommand).Assembly);
Enter fullscreen mode Exit fullscreen mode

This searches the assembly for FluentValidation validators.

For example:

public class LoginCommandValidator
    : AbstractValidator<LoginCommand>
{
    public LoginCommandValidator()
    {
        RuleFor(x => x.Email)
            .NotEmpty()
            .EmailAddress();

        RuleFor(x => x.Password)
            .NotEmpty()
            .MinimumLength(6);
    }
}
Enter fullscreen mode Exit fullscreen mode

Now validation rules are separated from the command itself.


7. Validation Pipeline Behavior

Simply registering validators isn't enough if we want validation to happen automatically before the handler executes.

For that, I use a MediatR pipeline behavior:

builder.Services.AddTransient(
    typeof(IPipelineBehavior<,>),
    typeof(ValidationBehavior<,>));
Enter fullscreen mode Exit fullscreen mode

The pipeline works roughly like this:

Controller
    ↓
MediatR
    ↓
ValidationBehavior
    ↓
Validator
    ↓
Command Handler
Enter fullscreen mode Exit fullscreen mode

If validation fails, the handler doesn't need to execute.

This keeps business logic free from repetitive validation checks.


8. Configuring ASP.NET Identity

For authentication and user management, I use ASP.NET Core Identity.

builder.Services
    .AddIdentity<ApplicationUser, IdentityRole<Guid>>()
    .AddEntityFrameworkStores<AppDbContext>()
    .AddDefaultTokenProviders();
Enter fullscreen mode Exit fullscreen mode

Here:

  • ApplicationUser represents our custom user entity.
  • IdentityRole<Guid> represents application roles.
  • AppDbContext stores Identity data in SQL Server.
  • AddDefaultTokenProviders() enables built-in token providers.

Because the application uses Guid as the key type, roles and users are configured with Guid.

A custom user can look like:

public class ApplicationUser : IdentityUser<Guid>
{
    public string FullName { get; set; }
}
Enter fullscreen mode Exit fullscreen mode

This allows us to extend the default Identity user with application-specific properties.


9. JWT Authentication

ASP.NET Identity handles user management, but for API authentication I use JWT bearer tokens.

First, the authentication scheme is configured:

builder.Services.AddAuthentication(options =>
{
    options.DefaultAuthenticateScheme =
        JwtBearerDefaults.AuthenticationScheme;

    options.DefaultChallengeScheme =
        JwtBearerDefaults.AuthenticationScheme;

    options.DefaultScheme =
        JwtBearerDefaults.AuthenticationScheme;
})
Enter fullscreen mode Exit fullscreen mode

Then JWT authentication is added:

.AddJwtBearer(options =>
{
    options.SaveToken = true;
    options.RequireHttpsMetadata = false;

    options.TokenValidationParameters =
        new TokenValidationParameters
        {
            ValidateIssuer = true,
            ValidateAudience = true,

            ValidAudience =
                builder.Configuration["JWT:ValidAudience"],

            ValidIssuer =
                builder.Configuration["JWT:ValidIssuer"],

            ClockSkew = TimeSpan.Zero,

            IssuerSigningKey =
                new SymmetricSecurityKey(
                    Encoding.UTF8.GetBytes(
                        builder.Configuration["JWT:secret"]))
        };
});
Enter fullscreen mode Exit fullscreen mode

When a client sends a request with:

Authorization: Bearer <token>
Enter fullscreen mode Exit fullscreen mode

ASP.NET Core validates the JWT.

Several things are checked here.

Issuer

ValidateIssuer = true
Enter fullscreen mode Exit fullscreen mode

The API verifies that the token was issued by the expected issuer.

Audience

ValidateAudience = true
Enter fullscreen mode Exit fullscreen mode

The token must be intended for the configured API audience.

Signing Key

IssuerSigningKey =
    new SymmetricSecurityKey(...)
Enter fullscreen mode Exit fullscreen mode

This key is used to verify that the token hasn't been modified.

Clock Skew

ClockSkew = TimeSpan.Zero
Enter fullscreen mode Exit fullscreen mode

This removes the default time tolerance during token expiration validation.

For production applications, secrets should be stored securely using environment variables, secret managers, or another secure configuration mechanism rather than committing them directly to source control.


10. Configuring CORS

Since my frontend is running separately from the API, I need to configure Cross-Origin Resource Sharing.

builder.Services.AddCors(options =>
    options.AddPolicy("AngularPolicy", policy =>
    {
        policy
            .WithOrigins("http://localhost:4200")
            .AllowAnyHeader()
            .AllowAnyMethod()
            .AllowCredentials();
    })
);
Enter fullscreen mode Exit fullscreen mode

The important part is:

.WithOrigins("http://localhost:4200")
Enter fullscreen mode Exit fullscreen mode

This allows requests from my Angular development server.

I also allow:

.AllowAnyHeader()
.AllowAnyMethod()
Enter fullscreen mode Exit fullscreen mode

which allows HTTP methods such as:

  • GET
  • POST
  • PUT
  • DELETE

and request headers such as:

Authorization
Content-Type
Enter fullscreen mode Exit fullscreen mode

Finally:

.AllowCredentials();
Enter fullscreen mode Exit fullscreen mode

allows credentials such as cookies or authentication-related browser credentials to be sent.

For production, the allowed origin should be changed from localhost to the actual frontend domain.


11. Registering Application Services

Next, I register the services used by the application.

builder.Services.AddScoped<ITokenService, TokenService>();
builder.Services.AddScoped<IAuthService, AuthService>();
builder.Services.AddScoped<IAdminService, AdminService>();
builder.Services.AddScoped<ICurrentUser, CurrentUser>();
builder.Services.AddScoped<IEmailService, EmailService>();
Enter fullscreen mode Exit fullscreen mode

This follows the Dependency Inversion Principle.

For example, instead of depending directly on:

TokenService
Enter fullscreen mode Exit fullscreen mode

other parts of the application depend on:

ITokenService
Enter fullscreen mode Exit fullscreen mode

The implementation can then be injected automatically.

public class AuthService : IAuthService
{
    private readonly ITokenService _tokenService;

    public AuthService(ITokenService tokenService)
    {
        _tokenService = tokenService;
    }
}
Enter fullscreen mode Exit fullscreen mode

Using interfaces makes services easier to replace, test, and maintain.


12. Understanding Scoped Lifetime

Most application services above are registered using:

AddScoped()
Enter fullscreen mode Exit fullscreen mode

A scoped service is created once per HTTP request.

For example:

HTTP Request
     ↓
AuthService instance
     ↓
TokenService instance
     ↓
Database operations
     ↓
HTTP Response
Enter fullscreen mode Exit fullscreen mode

When the request finishes, the scoped services are disposed.

This lifetime is commonly used for services that work with DbContext.


13. Swagger Configuration

Swagger is useful for testing and documenting API endpoints.

First:

builder.Services.AddEndpointsApiExplorer();
Enter fullscreen mode Exit fullscreen mode

Then Swagger is configured:

builder.Services.AddSwaggerGen(option =>
{
    option.AddSecurityDefinition(
        "Bearer",
        new OpenApiSecurityScheme
        {
            Type = SecuritySchemeType.Http,
            Scheme = "Bearer",
            BearerFormat = "JWT",
            Description = "JWT Authentication"
        });

    option.AddSecurityRequirement(document =>
        new OpenApiSecurityRequirement
        {
            [new OpenApiSecuritySchemeReference(
                "Bearer",
                document)] = []
        });
});
Enter fullscreen mode Exit fullscreen mode

The security definition tells Swagger that the API uses Bearer authentication.

After running the application, Swagger provides an interactive interface where we can test endpoints.

For protected endpoints, we can provide the JWT token and make authenticated requests directly from Swagger.


14. Building the Application

After registering all services:

var app = builder.Build();
Enter fullscreen mode Exit fullscreen mode

At this point, the application has been built and we can configure its middleware pipeline.


15. Middleware Pipeline

The middleware order is important.

My pipeline looks like this:

app.UseExceptionHandler();

app.UseSwagger();

app.UseSwaggerUI();

app.UseHttpsRedirection();

app.UseCors("AngularPolicy");

app.UseAuthentication();

app.UseAuthorization();

app.MapControllers();
Enter fullscreen mode Exit fullscreen mode

Let's understand the important parts.


Exception Handler

app.UseExceptionHandler();
Enter fullscreen mode Exit fullscreen mode

This handles unhandled exceptions globally.


Swagger

app.UseSwagger();
app.UseSwaggerUI();
Enter fullscreen mode Exit fullscreen mode

These enable the Swagger JSON endpoint and Swagger UI.


HTTPS Redirection

app.UseHttpsRedirection();
Enter fullscreen mode Exit fullscreen mode

HTTP requests are redirected to HTTPS.

For production applications, HTTPS should generally be used for protecting authentication tokens and other sensitive data in transit.


CORS

app.UseCors("AngularPolicy");
Enter fullscreen mode Exit fullscreen mode

This activates the CORS policy we registered earlier.


Authentication

app.UseAuthentication();
Enter fullscreen mode Exit fullscreen mode

This determines who the current user is.

For JWT authentication, it reads the bearer token and validates it.


Authorization

app.UseAuthorization();
Enter fullscreen mode Exit fullscreen mode

Authentication and authorization are different concepts.

Authentication answers:

Who are you?

Authorization answers:

Are you allowed to perform this operation?

For example:

[Authorize(Roles = "Admin")]
Enter fullscreen mode Exit fullscreen mode

can restrict an endpoint to users with the Admin role.

That's why authentication should run before authorization.


16. Mapping Controllers

Finally:

app.MapControllers();
Enter fullscreen mode Exit fullscreen mode

This maps controller routes to the application's HTTP pipeline.

For example:

[ApiController]
[Route("api/[controller]")]
public class AdminController : ControllerBase
{
    [HttpGet]
    public IActionResult Get()
    {
        return Ok();
    }
}
Enter fullscreen mode Exit fullscreen mode

will become accessible through the configured API route.


17. Seeding Roles and Admin Data

Before starting the application, I also seed initial roles and administrator data:

await RoleSeeder.SeedData(app);

await AdminSeeder.SeedData(app);
Enter fullscreen mode Exit fullscreen mode

This is useful when the application requires predefined roles or an initial administrator account.

For example, the role seeder might create:

Admin
Customer
Employee
Enter fullscreen mode Exit fullscreen mode

if they don't already exist.

The admin seeder can then create the initial administrator account.

This means the application doesn't require manually inserting an administrator into the database every time the database is created.


18. Starting the Application

Finally:

app.Run();
Enter fullscreen mode Exit fullscreen mode

starts the ASP.NET Core application and begins listening for HTTP requests.


Complete Program.cs

Putting everything together:

var builder = WebApplication.CreateBuilder(args);

// Controllers
builder.Services.AddControllers();

// Exception handling
builder.Services.AddExceptionHandler<GlobalExceptionHandler>();
builder.Services.AddProblemDetails();

// Database
string connectionString =
    builder.Configuration.GetConnectionString("default");

builder.Services.AddDbContext<AppDbContext>(options =>
    options.UseSqlServer(connectionString));

// MediatR
builder.Services.AddMediatR(cfg =>
    cfg.RegisterServicesFromAssembly(
        typeof(LoginCommandHandler).Assembly));

// FluentValidation
builder.Services.AddValidatorsFromAssembly(
    typeof(LoginCommand).Assembly);

// Validation pipeline
builder.Services.AddTransient(
    typeof(IPipelineBehavior<,>),
    typeof(ValidationBehavior<,>));

// Identity
builder.Services
    .AddIdentity<ApplicationUser, IdentityRole<Guid>>()
    .AddEntityFrameworkStores<AppDbContext>()
    .AddDefaultTokenProviders();

// JWT Authentication
builder.Services
    .AddAuthentication(options =>
    {
        options.DefaultAuthenticateScheme =
            JwtBearerDefaults.AuthenticationScheme;

        options.DefaultChallengeScheme =
            JwtBearerDefaults.AuthenticationScheme;

        options.DefaultScheme =
            JwtBearerDefaults.AuthenticationScheme;
    })
    .AddJwtBearer(options =>
    {
        options.SaveToken = true;
        options.RequireHttpsMetadata = false;

        options.TokenValidationParameters =
            new TokenValidationParameters
            {
                ValidateIssuer = true,
                ValidateAudience = true,

                ValidAudience =
                    builder.Configuration["JWT:ValidAudience"],

                ValidIssuer =
                    builder.Configuration["JWT:ValidIssuer"],

                ClockSkew = TimeSpan.Zero,

                IssuerSigningKey =
                    new SymmetricSecurityKey(
                        Encoding.UTF8.GetBytes(
                            builder.Configuration["JWT:secret"]))
            };
    });

// CORS
builder.Services.AddCors(options =>
    options.AddPolicy("AngularPolicy", policy =>
    {
        policy
            .WithOrigins("http://localhost:4200")
            .AllowAnyHeader()
            .AllowAnyMethod()
            .AllowCredentials();
    }));

// Application services
builder.Services.AddScoped<ITokenService, TokenService>();
builder.Services.AddScoped<IAuthService, AuthService>();
builder.Services.AddScoped<IAdminService, AdminService>();
builder.Services.AddScoped<ICurrentUser, CurrentUser>();
builder.Services.AddScoped<IEmailService, EmailService>();

// Swagger
builder.Services.AddEndpointsApiExplorer();

builder.Services.AddSwaggerGen(option =>
{
    option.AddSecurityDefinition(
        "Bearer",
        new OpenApiSecurityScheme
        {
            Type = SecuritySchemeType.Http,
            Scheme = "Bearer",
            BearerFormat = "JWT",
            Description = "JWT Authentication"
        });

    option.AddSecurityRequirement(document =>
        new OpenApiSecurityRequirement
        {
            [new OpenApiSecuritySchemeReference(
                "Bearer",
                document)] = []
        });
});


var app = builder.Build();

// Middleware
app.UseExceptionHandler();

app.UseSwagger();
app.UseSwaggerUI();

app.UseHttpsRedirection();

app.UseCors("AngularPolicy");

app.UseAuthentication();
app.UseAuthorization();

app.MapControllers();

// Seed initial data
await RoleSeeder.SeedData(app);
await AdminSeeder.SeedData(app);

app.Run();
Enter fullscreen mode Exit fullscreen mode

Top comments (0)