Intermediate

ASP.NET Core 10 Web API: Securing, Versioning, Documenting and Deploying

JWT security, versioning, OpenAPI/Scalar docs and deploying an ASP.NET Core 10 Web API to Azure.

Continuation of the Supporting Databases, Searching, Filtering, and Paging course. This course covers advanced aspects of an ASP.NET Core 10 Web API: JWT security, versioning, OpenAPI/Scalar documentation, and deployment to Azure.


Table of Contents

  1. Starting Application Architecture
  2. The Service Layer
  3. Securing the API — JWT Bearer Authentication
  4. API Versioning
  5. Documentation with OpenAPI and Scalar
  6. Advanced Documentation — XML Comments and Response Attributes
  7. Deploying to Azure
  8. Quick Reference — NuGet Packages
  9. Quick Reference — Middleware Pipeline
  10. Best Practices Summary

1. Starting Application Architecture

Overview

The CityInfo API application serves as a running example throughout the course. It exposes Cities and Points of Interest (POI) resources via RESTful endpoints. The layered architecture looks like this:

graph TD
    Client["HTTP Client\n(browser / app)"]
    Controller["Controllers\n(CitiesController\nPointsOfInterestController)"]
    Service["Service Layer\n(IPointOfInterestService)"]
    Repository["Repository\n(ICityInfoRepository)"]
    EF["Entity Framework Core 10"]
    DB[(SQLite Database)]
    Mail["Mail Service\n(IMailService)"]

    Client -->|HTTP Request| Controller
    Controller -->|Business logic| Service
    Controller -->|Data queries| Repository
    Service -->|Data access| Repository
    Repository -->|LINQ / EF Core| EF
    EF -->|SQL| DB
    Service -->|Notifications| Mail

Project Structure

CityInfo.API/
├── Controllers/
│   ├── AuthenticationController.cs
│   ├── CitiesController.cs
│   ├── FilesController.cs
│   └── PointsOfInterestController.cs
├── DbContexts/
│   └── CityInfoContext.cs
├── Entities/
│   ├── City.cs
│   └── PointOfInterest.cs
├── Migrations/
├── Models/                        # DTOs
│   ├── AuthenticationRequestDto.cs
│   ├── CityDto.cs
│   ├── CityInfoUser.cs
│   ├── CityWithoutPointsOfInterestDto.cs
│   ├── PaginationMetadata.cs
│   ├── PointOfInteresCreationResult.cs
│   ├── PointOfInterestDto.cs
│   ├── PointOfInterestForCreationDto.cs
│   ├── PointOfInterestForUpdateDto.cs
│   └── PointsOfInterestBulkUpdateDto.cs
├── Profiles/                      # AutoMapper profiles
│   ├── CityProfile.cs
│   └── PointOfInterestProfile.cs
├── Properties/
│   └── launchSettings.json
├── Services/
│   ├── CityInfoRepository.cs
│   ├── CloudMailService.cs
│   ├── ICityInfoRepository.cs
│   ├── IMailService.cs
│   ├── IPointOfInterestService.cs
│   ├── LocalMailService.cs
│   ├── PointOfInterestService.cs
│   └── LocalMailService.cs
├── appsettings.json
├── appsettings.Development.json
├── appsettings.Production.json
├── CityInfo.API.csproj
└── Program.cs

Table — Base NuGet Packages (module 04)

PackageVersionRole
Asp.Versioning.Mvc8.1.1API versioning
AutoMapper16.0.0Object-to-object mapping
Microsoft.AspNetCore.Authentication.JwtBearer10.0.2JWT Bearer authentication
Microsoft.AspNetCore.JsonPatch.SystemTextJson10.0.2JSON Patch (PATCH verb)
Microsoft.AspNetCore.OpenApi10.0.2OpenAPI 3.x document generation
Microsoft.EntityFrameworkCore.Sqlite10.0.2SQLite provider for EF Core
Serilog.AspNetCore10.0.0Structured logging
Serilog.Sinks.Console6.1.1Console logging
Serilog.Sinks.File7.0.0File logging
System.IdentityModel.Tokens.Jwt8.15.0JWT token creation

2. The Service Layer

Why a Service Layer?

The current architecture — controller → repository — works well for simple CRUD operations. But when business rules accumulate in controllers (complex validations, orchestrating multiple repositories, calls to external services), it becomes worthwhile to introduce a service layer between the controller and the repository.

graph LR
    A["Controller\n(HTTP concerns only)"]
    B["Service Layer\n(Business Logic)"]
    C["Repository\n(Data Access)"]
    D[(Database)]

    A -->|"Business calls"| B
    B -->|"Data queries"| C
    C -->|"EF Core"| D

When to use a service layer:

ScenarioService Layer?
Simple CRUD without logic❌ Not needed
Complex validation (e.g., max 10 POIs per city)✅ Recommended
Orchestrating multiple repositories✅ Recommended
Notifications / emails to trigger✅ Recommended
Reusing logic between controllers✅ Recommended

Concrete Example: IPointOfInterestService

Starting scenario

Two business rules are added to illustrate the need:

  1. A city can have a maximum of 10 points of interest.
  2. Creating a POI must send a notification to city subscribers.

Service interface

// Services/IPointOfInterestService.cs
public interface IPointOfInterestService
{
    Task<PointOfInterestCreationResult> CreatePointOfInterestAsync(
        int cityId,
        PointOfInterestForCreationDto pointOfInterest,
        CancellationToken cancellationToken = default);
}

Result class (static factory pattern)

// Models/PointOfInterestCreationResult.cs
public class PointOfInterestCreationResult
{
    public bool Success { get; private set; }
    public string? ErrorMessage { get; private set; }
    public PointOfInterest? PointOfInterest { get; private set; }

    // Static factory methods — preferred pattern over public constructor
    public static PointOfInterestCreationResult SuccessResult(PointOfInterest poi)
        => new() { Success = true, PointOfInterest = poi };

    public static PointOfInterestCreationResult Failure(string errorMessage)
        => new() { Success = false, ErrorMessage = errorMessage };
}

Advantage of the static factory pattern: the method name (SuccessResult, Failure) clearly expresses the intent at object creation, unlike a generic constructor.

Service implementation

// Services/PointOfInterestService.cs
public class PointOfInterestService(
    ICityInfoRepository repository,
    IMailService mailService) : IPointOfInterestService
{
    private const int MaxPointsOfInterestPerCity = 10;

    public async Task<PointOfInterestCreationResult> CreatePointOfInterestAsync(
        int cityId,
        PointOfInterestForCreationDto pointOfInterestDto,
        CancellationToken cancellationToken = default)
    {
        if (!await repository.CityExistsAsync(cityId, cancellationToken))
            return PointOfInterestCreationResult.Failure("City not found.");

        var currentCount = await repository.GetPointsOfInterestCountForCityAsync(
            cityId, cancellationToken);

        if (currentCount >= MaxPointsOfInterestPerCity)
            return PointOfInterestCreationResult.Failure(
                $"A city can have a maximum of {MaxPointsOfInterestPerCity} points of interest.");

        // ... map and save
        // Send notification
        mailService.Send(
            "Point of interest created",
            $"Point of interest for city {cityId} was created.");

        return PointOfInterestCreationResult.SuccessResult(newPointOfInterest);
    }
}

Registration in the DI container

// Program.cs
builder.Services.AddScoped<IPointOfInterestService, PointOfInterestService>();

Usage in the controller (thin controller)

// Controllers/PointsOfInterestController.cs
[HttpPost]
public async Task<ActionResult<PointOfInterestDto>> CreatePointOfInterest(
    int cityId,
    PointOfInterestForCreationDto pointOfInterest,
    CancellationToken cancellationToken)
{
    var result = await _pointOfInterestService.CreatePointOfInterestAsync(
        cityId, pointOfInterest, cancellationToken);

    if (!result.Success)
        return BadRequest(result.ErrorMessage);

    var createdPointOfInterest = mapper.Map<PointOfInterestDto>(result.PointOfInterest!);

    return CreatedAtRoute("GetPointOfInterest",
        new { cityId, pointOfInterestId = createdPointOfInterest.Id },
        createdPointOfInterest);
}

3. Securing the API — JWT Bearer Authentication

Fundamental Concepts

JWT (JSON Web Token) is an open standard (RFC 7519) for securely exchanging information between parties as a signed token. A JWT consists of three Base64url-encoded parts separated by dots: header.payload.signature.

sequenceDiagram
    participant Client
    participant API as CityInfo API
    participant Auth as AuthenticationController

    Client->>Auth: POST /api/authentication/authenticate<br/>{userName, password}
    Auth->>Auth: ValidateUserCredentials()
    Auth-->>Client: 200 OK { token: "eyJ..." }

    Note over Client: Client stores the JWT token

    Client->>API: GET /api/v1/cities<br/>Authorization: Bearer eyJ...
    API->>API: UseAuthentication() validates the token
    API->>API: UseAuthorization() checks claims/policies
    API-->>Client: 200 OK [cities...]

JWT Structure

PartContentExample
HeaderSigning algorithm{ "alg": "HS256", "typ": "JWT" }
PayloadClaims (sub, given_name, city, exp…){ "sub": "1", "city": "Antwerp" }
SignatureHMAC-SHA256(header + payload + secret)HMACSHA256(...)

Claims used in CityInfo

ClaimExample valueRole
sub"1"Subject — user identifier
given_name"Kevin"First name
family_name"Dockx"Last name
city"Antwerp"User’s city (used for policies)
iatUnix timestampIssued At
expUnix timestampExpiration (+1h)

Configuration in appsettings (Use User Secrets in production!)

// appsettings.Development.json (or User Secrets)
{
  "Authentication": {
    "SecretForKey": "your-base64-secret-key-minimum-256-bits",
    "Issuer": "https://localhost:7062",
    "Audience": "cityinfoapi"
  }
}

⚠️ Security: never commit SecretForKey to source control. Use User Secrets in development and Azure Key Vault in production.

Complete AuthenticationController

// Controllers/AuthenticationController.cs
using CityInfo.API.Models;
using Microsoft.AspNetCore.Mvc;
using Microsoft.IdentityModel.Tokens;
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;

namespace CityInfo.API.Controllers;

[Route("api/[controller]")]
[ApiController]
public class AuthenticationController(IConfiguration configuration) : ControllerBase
{
    [HttpPost("authenticate")]
    public ActionResult<string> Authenticate(AuthenticationRequestDto authenticationRequest)
    {
        // Step 1: validate credentials
        var user = ValidateUserCredentials(
            authenticationRequest.UserName,
            authenticationRequest.Password);

        if (user is null)
            return Unauthorized();

        // Step 2: create the signing key
        var securityKey = new SymmetricSecurityKey(
            Convert.FromBase64String(configuration["Authentication:SecretForKey"]!));

        var signingCredentials = new SigningCredentials(
            securityKey,
            SecurityAlgorithms.HmacSha256);

        // Step 3: define claims
        var claimsForToken = new List<Claim>
        {
            new("sub",         user.UserId.ToString()),
            new("given_name",  user.FirstName),
            new("family_name", user.LastName),
            new("city",        user.City)
        };

        // Step 4: create the token
        var jwtSecurityToken = new JwtSecurityToken(
            issuer:              configuration["Authentication:Issuer"],
            audience:            configuration["Authentication:Audience"],
            claims:              claimsForToken,
            notBefore:           DateTime.UtcNow,
            expires:             DateTime.UtcNow.AddHours(1),
            signingCredentials:  signingCredentials);

        var tokenToReturn = new JwtSecurityTokenHandler()
            .WriteToken(jwtSecurityToken);

        return Ok(new { token = tokenToReturn });
    }

    private CityInfoUser? ValidateUserCredentials(string? userName, string? password)
    {
        // In production: query the user database
        // Here, we simulate a valid user
        return new CityInfoUser(1, userName ?? "", "Kevin", "Dockx", "Antwerp");
    }
}

Authentication request DTO

// Models/AuthenticationRequestDto.cs
public class AuthenticationRequestDto
{
    [Required]
    public string? UserName { get; set; }

    [Required]
    public string? Password { get; set; }
}

JWT middleware registration in Program.cs

// Program.cs — JWT Bearer authentication configuration
builder.Services.AddAuthentication("Bearer")
    .AddJwtBearer(options =>
    {
        options.TokenValidationParameters = new()
        {
            ValidateIssuer           = true,
            ValidateAudience         = true,
            ValidateIssuerSigningKey = true,
            ValidIssuer              = builder.Configuration["Authentication:Issuer"],
            ValidAudience            = builder.Configuration["Authentication:Audience"],
            IssuerSigningKey         = new SymmetricSecurityKey(
                Convert.FromBase64String(
                    builder.Configuration["Authentication:SecretForKey"]!))
        };
    });

Custom authorization policies

A policy allows requiring specific claims in addition to being authenticated.

// Program.cs — Policy based on a "city" claim
builder.Services.AddAuthorizationBuilder()
    .AddPolicy("MustBeFromAntwerp", policy =>
    {
        policy.RequireAuthenticatedUser();
        policy.RequireClaim("city", "Antwerp");
    });

Applying to an action or controller:

[Authorize(Policy = "MustBeFromAntwerp")]
[HttpDelete("{pointOfInterestId}")]
public async Task<ActionResult> DeletePointOfInterest(
    int cityId, int pointOfInterestId) { ... }

Pipeline order (critical!)

// Program.cs — Required order
app.UseAuthentication();  // 1. Who are you?
app.UseAuthorization();   // 2. Do you have permission?
app.MapControllers();     // 3. Dispatch to the correct controller

⚠️ UseAuthentication must always come before UseAuthorization. Reversing the order means identity information is not available when authorization is checked.

Protecting an entire controller with [Authorize]

[ApiController]
[Route("api/v{version:apiVersion}/cities")]
[Authorize]   // ← All actions require a valid token
public class CitiesController : ControllerBase { ... }

4. API Versioning

Why version?

An API is a contract with its consumers. When breaking changes are needed (renaming fields, removing endpoints, changing response structure), versioning allows the API to evolve without breaking existing clients.

Versioning strategies

StrategyExampleAdvantagesDisadvantages
URL segment/api/v1/citiesVery visible, easy to testLess “purely RESTful”
Query string/api/cities?api-version=1.0No route changeLess readable
Headerapi-version: 1.0Clean URLLess discoverable
Media typeAccept: application/vnd.api+json;v=1Very REST-compliantComplex

In this course, the URL segment strategy is used: api/v{version:apiVersion}/cities.

Package installation

<!-- CityInfo.API.csproj -->
<PackageReference Include="Asp.Versioning.Mvc" Version="8.1.1" />

Configuration in Program.cs

// Program.cs
using Asp.Versioning;

builder.Services.AddApiVersioning(setupAction =>
{
    setupAction.ReportApiVersions = true;                      // "api-supported-versions" header in responses
    setupAction.AssumeDefaultVersionWhenUnspecified = true;    // v1 if not specified
    setupAction.DefaultApiVersion = new ApiVersion(1, 0);
})
.AddMvc()                                                       // Support for MVC controllers
.AddApiExplorer(setupAction =>                                  // For OpenAPI integration
{
    setupAction.SubstituteApiVersionInUrl = true;               // Replaces {version} in URLs
    setupAction.GroupNameFormat = "'v'V";                       // Group name format: v1, v2
});

Applying to controllers

// Controllers/CitiesController.cs
using Asp.Versioning;

[ApiController]
[Route("api/v{version:apiVersion}/cities")]
[ApiVersion(1)]   // This controller supports v1
[ApiVersion(2)]   // AND v2
[Authorize]
public class CitiesController : ControllerBase
{
    // Action available in v1 and v2
    [HttpGet]
    public async Task<ActionResult<IEnumerable<CityWithoutPointsOfInterestDto>>> GetCities(...)
    { ... }
}

Deprecating a version

[ApiVersion(1, Deprecated = true)]  // v1 deprecated — "api-deprecated-versions" header sent
[ApiVersion(2)]
public class CitiesController : ControllerBase { ... }

Version-specific actions

// Action available only in v2
[HttpGet("search")]
[MapToApiVersion(2)]
public async Task<ActionResult<IEnumerable<CityDto>>> SearchCities(string query)
{ ... }

Diagram — Versioning flow

flowchart LR
    R1["GET /api/v1/cities"] --> V1["ApiVersion(1)\nCitiesController\nResponse: CityWithoutPointsOfInterestDto"]
    R2["GET /api/v2/cities"] --> V2["ApiVersion(2)\nCitiesController\nEnriched response"]
    R3["GET /api/cities\n(no version)"] --> DEFAULT["DefaultApiVersion = 1.0\n→ Redirects to v1"]
    DEFAULT --> V1

    style R1 fill:#d4edda
    style R2 fill:#cce5ff
    style R3 fill:#fff3cd

Response header — api-supported-versions

When ReportApiVersions = true, each response includes:

api-supported-versions: 1.0, 2.0
api-deprecated-versions: 1.0   (if deprecated)

5. Documentation with OpenAPI and Scalar

OpenAPI vs Swagger — Clarification

TermDefinition
OpenAPISpecification (open standard) for describing REST APIs
SwaggerTooling created by SmartBear around OpenAPI (UI, codegen, etc.)
ScalarModern open-source UI for exploring OpenAPI specs
Microsoft.AspNetCore.OpenApiOfficial Microsoft package for generating OpenAPI specs in ASP.NET Core 10

In ASP.NET Core 10, builder.Services.AddOpenApi() natively generates OpenAPI specs without Swashbuckle. Scalar replaces Swagger UI as the visual interface.

Basic configuration

// Program.cs — Module 05
using Microsoft.AspNetCore.OpenApi.Generated;
using Microsoft.OpenApi;
using Scalar.AspNetCore;

AddOpenApi with Document Transformer

// Program.cs — One OpenAPI document per API version
builder.Services.AddOpenApi("v1", options =>
{
    options.OpenApiVersion = OpenApiSpecVersion.OpenApi3_0;  // Force OpenAPI 3.0

    options.AddDocumentTransformer((document, context, cancellationToken) =>
    {
        // Customize document metadata
        document.Info = new()
        {
            Title       = "City Info API",
            Version     = context.DocumentName,   // "v1"
            Description = "Through this API you can access cities and their points of interest.",
        };

        // Initialize components
        document.Components ??= new OpenApiComponents();
        document.Components.SecuritySchemes ??= new Dictionary<string, IOpenApiSecurityScheme>();

        // Add Bearer security scheme
        document.Components.SecuritySchemes.Add("Bearer", new OpenApiSecurityScheme
        {
            Type         = SecuritySchemeType.Http,
            Scheme       = "bearer",
            BearerFormat = "JWT",
            Description  = "Input your Bearer token to access this API"
        });

        // Apply Bearer globally to all endpoints
        document.Security =
        [
            new OpenApiSecurityRequirement
            {
                { new OpenApiSecuritySchemeReference("Bearer"), [] }
            }
        ];

        return Task.CompletedTask;
    });
});

// Separate document for v2
builder.Services.AddOpenApi("v2", options =>
{
    options.OpenApiVersion = OpenApiSpecVersion.OpenApi3_0;
    options.AddDocumentTransformer((document, context, cancellationToken) =>
    {
        document.Info = new()
        {
            Title       = "City Info API",
            Version     = context.DocumentName,
            Description = "Through this API you can access cities and their points of interest.",
        };
        return Task.CompletedTask;
    });
});

API Explorer configuration for versioning

// Program.cs — Required to link ApiVersioning and OpenAPI
builder.Services.AddApiVersioning(...)
    .AddMvc()
    .AddApiExplorer(setupAction =>
    {
        setupAction.SubstituteApiVersionInUrl = true;
        setupAction.GroupNameFormat = "'v'V";   // Group "v1", "v2"
    });

Exposing OpenAPI and Scalar UI endpoints

// Program.cs — Middleware pipeline
app.MapOpenApi("/openapi/{documentName}.json");  // JSON endpoint: /openapi/v1.json

app.MapScalarApiReference(options =>
{
    options.WithTitle("City Info API")
           .WithTheme(ScalarTheme.Solarized)
           .WithDefaultHttpClient(ScalarTarget.CSharp, ScalarClient.HttpClient)
           .AddPreferredSecuritySchemes("Bearer");   // Pre-selects Bearer in the UI
});

Endpoint access

URLContent
/openapi/v1.jsonOpenAPI 3.0 spec in JSON format for version 1
/openapi/v2.jsonOpenAPI 3.0 spec in JSON format for version 2
/scalar/v1Scalar UI interface for version 1
/scalar/v2Scalar UI interface for version 2

Diagram — OpenAPI/Scalar pipeline

flowchart TD
    A["GET /openapi/v1.json request"] --> B["MapOpenApi middleware"]
    B --> C["IApiDescriptionGroupCollectionProvider\n(API Explorer)"]
    C --> D["Document Transformer\n(Info, Security, etc.)"]
    D --> E["OpenAPI 3.0 JSON Spec"]

    F["GET /scalar/v1 request"] --> G["MapScalarApiReference middleware"]
    G --> H["Scalar UI HTML/JS"]
    H -->|"Loads spec"| E
    H --> I["Interactive interface\nfor testing the API"]

    style E fill:#d4edda
    style I fill:#cce5ff

6. Advanced Documentation — XML Comments and Response Attributes

ProducesResponseType — Declaring possible HTTP codes

Without [ProducesResponseType], OpenAPI only generates a 200 code by default, which is inaccurate. These attributes allow documenting all possible return codes.

// Controllers/CitiesController.cs
/// <summary>
/// Get a city by id
/// </summary>
/// <param name="cityId">The id of the city to get</param>
/// <param name="includePointsOfInterest">Whether or not to include the points of interest</param>
/// <param name="cancellationToken">The injected cancellation token</param>
/// <returns>A city with or without points of interest</returns>
[HttpGet("{cityId}")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
public async Task<IActionResult> GetCity(
    int cityId,
    bool includePointsOfInterest = false,
    CancellationToken cancellationToken = default)
{
    var city = await cityInfoRepository.GetCityAsync(cityId, includePointsOfInterest, cancellationToken);

    if (city == null)
        return NotFound();

    return includePointsOfInterest
        ? Ok(mapper.Map<CityDto>(city))
        : Ok(mapper.Map<CityWithoutPointsOfInterestDto>(city));
}

Table — ProducesResponseType by HTTP method

HTTP MethodTypical codes
GET (collection)200 OK
GET (item)200 OK, 404 Not Found
POST201 Created, 400 Bad Request, 409 Conflict
PUT204 No Content, 400 Bad Request, 404 Not Found
PATCH204 No Content, 400 Bad Request, 404 Not Found
DELETE204 No Content, 404 Not Found

XML Comments — Enable in .csproj

<!-- CityInfo.API.csproj -->
<PropertyGroup>
    <GenerateDocumentationFile>true</GenerateDocumentationFile>
    <NoWarn>$(NoWarn);1591</NoWarn>  <!-- Ignore warnings for undocumented members -->
</PropertyGroup>

XML comments on models (DTOs)

// Models/CityWithoutPointsOfInterestDto.cs
/// <summary>
/// A DTO for a city without points of interest
/// </summary>
public class CityWithoutPointsOfInterestDto
{
    /// <summary>
    /// The id of the city
    /// </summary>
    public int Id { get; set; }

    /// <summary>
    /// The name of the city
    /// </summary>
    public string Name { get; set; } = string.Empty;

    /// <summary>
    /// The description of the city
    /// </summary>
    public string? Description { get; set; }
}

Value for OpenAPI

XML comments automatically appear in the generated OpenAPI spec:

  • <summary> → description of the endpoint or model
  • <param> → parameter descriptions
  • <returns> → return value description

7. Deploying to Azure

Deployment process overview

flowchart LR
    DEV["Environment\nDevelopment\n(local)"]
    PROD["Environment\nProduction\n(Azure)"]

    DEV -->|"Serilog → Console\n+ File"| LOG_DEV["Local Logs"]
    DEV -->|"User Secrets"| SECRET_DEV["Dev Secrets"]
    DEV -->|"OpenAPI + Scalar\nexposed"| DOC_DEV["Local Documentation"]

    PROD -->|"Serilog → Console\n+ File\n+ Application Insights"| LOG_PROD["Azure Monitor"]
    PROD -->|"Azure Key Vault"| SECRET_PROD["Production Secrets"]
    PROD -->|"ForwardedHeaders"| PROXY["Behind a reverse proxy"]
    PROD -->|"OpenAPI + Scalar\nalways exposed"| DOC_PROD["Production Documentation"]

Managing environments with ASPNETCORE_ENVIRONMENT

ASP.NET Core reads the ASPNETCORE_ENVIRONMENT environment variable to determine the active environment:

ValueUsage
DevelopmentLocal development — Serilog, User Secrets
StagingPre-production testing
ProductionAzure App Service, production servers

Serilog configuration per environment

// Program.cs (module 07)
var environment = Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT");

if (environment == Environments.Development)
{
    builder.Host.UseSerilog((context, loggerConfiguration) =>
        loggerConfiguration
            .MinimumLevel.Debug()
            .WriteTo.Console()
            .WriteTo.File("logs/cityinfo.txt", rollingInterval: RollingInterval.Day));
}
else
{
    // In production: Azure Key Vault + Application Insights
    var secretClient = new SecretClient(
        new Uri("https://psdemokeyvault.vault.azure.net/"),
        new DefaultAzureCredential());

    builder.Configuration.AddAzureKeyVault(secretClient, new KeyVaultSecretManager());

    builder.Host.UseSerilog((context, loggerConfiguration) =>
        loggerConfiguration
            .MinimumLevel.Debug()
            .WriteTo.Console()
            .WriteTo.File("logs/cityinfo.txt", rollingInterval: RollingInterval.Day)
            .WriteTo.ApplicationInsights(
                new TelemetryConfiguration
                {
                    InstrumentationKey = builder.Configuration["ApplicationInsightsInstrumentationKey"]
                },
                TelemetryConverter.Traces));
}

Azure Key Vault — Storing secrets in production

// Required packages:
// Azure.Extensions.AspNetCore.Configuration.Secrets
// Azure.Identity
// Azure.Security.KeyVault.Secrets

var secretClient = new SecretClient(
    new Uri("https://<your-keyvault>.vault.azure.net/"),
    new DefaultAzureCredential());  // Managed Identity or Az CLI locally

builder.Configuration.AddAzureKeyVault(secretClient, new KeyVaultSecretManager());

Secret naming convention in Key Vault:

Secrets in Azure Key Vault use -- to represent JSON hierarchy (: is not allowed in Key Vault secret names):

appsettings keyKey Vault secret name
Authentication:SecretForKeyAuthentication--SecretForKey
Authentication:IssuerAuthentication--Issuer
ConnectionStrings:CityInfoDBConnectionStringConnectionStrings--CityInfoDBConnectionString

ForwardedHeaders — Behind a reverse proxy

In production, the API is often behind a reverse proxy (Azure App Service, nginx, YARP). Without configuration, the API only sees the internal IP of the proxy, not the real client IP.

// Program.cs — Configure ForwardedHeaders
builder.Services.Configure<ForwardedHeadersOptions>(options =>
{
    options.ForwardedHeaders = ForwardedHeaders.XForwardedFor
                             | ForwardedHeaders.XForwardedProto;
});

// In the pipeline (before UseHttpsRedirection)
app.UseForwardedHeaders();
app.UseHttpsRedirection();

Forwarded headers:

HeaderContent
X-Forwarded-ForReal client IP
X-Forwarded-ProtoOriginal protocol (https)
X-Forwarded-HostOriginal hostname

⚠️ On Azure App Service in production, ForwardedHeaders configuration is required for HTTPS redirection and access logs to work correctly.

Application Insights — Telemetry

// Required packages:
// Microsoft.ApplicationInsights.AspNetCore
// Serilog.Sinks.ApplicationInsights

// In the Serilog config (production)
.WriteTo.ApplicationInsights(
    new TelemetryConfiguration
    {
        InstrumentationKey = builder.Configuration["ApplicationInsightsInstrumentationKey"]
    },
    TelemetryConverter.Traces)

Application Insights enables monitoring of:

  • HTTP requests (duration, status code, success/failure)
  • Exceptions and stack traces
  • Dependencies (SQL calls, external HTTP)
  • Custom metrics
  • Structured logs (via Serilog)

Additional Azure packages (module 07)

PackageRole
Azure.Extensions.AspNetCore.Configuration.SecretsKey Vault integration in IConfiguration
Azure.IdentityDefaultAzureCredential (MSI / Az CLI)
Azure.Security.KeyVault.SecretsKey Vault SDK
Microsoft.ApplicationInsights.ExtensibilityCore Application Insights

Complete pipeline — Final Program.cs (module 07)

// Program.cs — Final version with all elements

// 1. Serilog (initial bootstrapper)
Log.Logger = new LoggerConfiguration()
    .MinimumLevel.Debug()
    .WriteTo.Console()
    .CreateLogger();

var builder = WebApplication.CreateBuilder(args);

// 2. Environment-differentiated logging
var environment = Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT");
if (environment == Environments.Development)
{
    builder.Host.UseSerilog((ctx, cfg) => cfg
        .MinimumLevel.Debug()
        .WriteTo.Console()
        .WriteTo.File("logs/cityinfo.txt", rollingInterval: RollingInterval.Day));
}
else
{
    // Key Vault + Application Insights in production
    var secretClient = new SecretClient(
        new Uri("https://psdemokeyvault.vault.azure.net/"),
        new DefaultAzureCredential());
    builder.Configuration.AddAzureKeyVault(secretClient, new KeyVaultSecretManager());

    builder.Host.UseSerilog((ctx, cfg) => cfg
        .MinimumLevel.Debug()
        .WriteTo.Console()
        .WriteTo.File("logs/cityinfo.txt", rollingInterval: RollingInterval.Day)
        .WriteTo.ApplicationInsights(
            new TelemetryConfiguration
            {
                InstrumentationKey = builder.Configuration["ApplicationInsightsInstrumentationKey"]
            },
            TelemetryConverter.Traces));
}

// 3. Services
builder.Services.AddControllers()
    .AddXmlDataContractSerializerFormatters();

// OpenAPI — one document per version
builder.Services.AddOpenApi("v1", options => { /* ... transformers ... */ });
builder.Services.AddOpenApi("v2", options => { /* ... transformers ... */ });

builder.Services.AddProblemDetails();
builder.Services.AddSingleton<FileExtensionContentTypeProvider>();

// Conditional mail service (DEBUG/RELEASE)
#if DEBUG
builder.Services.AddTransient<IMailService, LocalMailService>();
#else
builder.Services.AddTransient<IMailService, CloudMailService>();
#endif

// EF Core SQLite
builder.Services.AddDbContext<CityInfoContext>(options =>
    options.UseSqlite(
        builder.Configuration["ConnectionStrings:CityInfoDBConnectionString"]!));

builder.Services.AddScoped<ICityInfoRepository, CityInfoRepository>();
builder.Services.AddScoped<IPointOfInterestService, PointOfInterestService>();
builder.Services.AddAutoMapper(config => { }, AppDomain.CurrentDomain.GetAssemblies());

// JWT Bearer Authentication
builder.Services.AddAuthentication("Bearer")
    .AddJwtBearer(options =>
    {
        options.TokenValidationParameters = new()
        {
            ValidateIssuer           = true,
            ValidateAudience         = true,
            ValidateIssuerSigningKey = true,
            ValidIssuer              = builder.Configuration["Authentication:Issuer"],
            ValidAudience            = builder.Configuration["Authentication:Audience"],
            IssuerSigningKey         = new SymmetricSecurityKey(
                Convert.FromBase64String(builder.Configuration["Authentication:SecretForKey"]!))
        };
    });

// Authorization Policies
builder.Services.AddAuthorizationBuilder()
    .AddPolicy("MustBeFromAntwerp", policy =>
    {
        policy.RequireAuthenticatedUser();
        policy.RequireClaim("city", "Antwerp");
    });

// API Versioning
builder.Services.AddApiVersioning(setupAction =>
{
    setupAction.ReportApiVersions                  = true;
    setupAction.AssumeDefaultVersionWhenUnspecified = true;
    setupAction.DefaultApiVersion                  = new ApiVersion(1, 0);
})
.AddMvc()
.AddApiExplorer(setupAction =>
{
    setupAction.SubstituteApiVersionInUrl = true;
    setupAction.GroupNameFormat           = "'v'V";
});

// ForwardedHeaders (reverse proxy)
builder.Services.Configure<ForwardedHeadersOptions>(options =>
{
    options.ForwardedHeaders = ForwardedHeaders.XForwardedFor
                             | ForwardedHeaders.XForwardedProto;
});

var app = builder.Build();

// 4. Middleware Pipeline
if (!app.Environment.IsDevelopment())
    app.UseExceptionHandler();

app.UseForwardedHeaders();   // Before UseHttpsRedirection!

// OpenAPI + Scalar (exposed in all environments in production)
app.MapOpenApi("/openapi/{documentName}.json");
app.MapScalarApiReference(options =>
{
    options.WithTitle("City Info API")
           .WithTheme(ScalarTheme.Solarized)
           .WithDefaultHttpClient(ScalarTarget.CSharp, ScalarClient.HttpClient)
           .AddPreferredSecuritySchemes("Bearer");
});

app.UseHttpsRedirection();
app.UseAuthentication();
app.UseAuthorization();
app.MapControllers();

app.Run();

8. Quick Reference — NuGet Packages

Complete .csproj file (module 07)

<Project Sdk="Microsoft.NET.Sdk.Web">

  <PropertyGroup>
    <TargetFramework>net10.0</TargetFramework>
    <Nullable>enable</Nullable>
    <ImplicitUsings>enable</ImplicitUsings>
    <UserSecretsId>768fffbd-b482-442c-a258-bb9298f89bed</UserSecretsId>
    <GenerateDocumentationFile>true</GenerateDocumentationFile>
    <NoWarn>$(NoWarn);1591</NoWarn>
  </PropertyGroup>

  <ItemGroup>
    <!-- API Versioning -->
    <PackageReference Include="Asp.Versioning.Mvc" Version="8.1.1" />

    <!-- Azure -->
    <PackageReference Include="Azure.Extensions.AspNetCore.Configuration.Secrets" Version="1.3.2" />
    <PackageReference Include="Azure.Identity" Version="1.13.2" />
    <PackageReference Include="Azure.Security.KeyVault.Secrets" Version="4.7.0" />
    <PackageReference Include="Microsoft.ApplicationInsights.Extensibility" Version="2.22.0" />

    <!-- AutoMapper -->
    <PackageReference Include="AutoMapper" Version="16.0.0" />

    <!-- JWT Authentication -->
    <PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.2" />

    <!-- JSON Patch -->
    <PackageReference Include="Microsoft.AspNetCore.JsonPatch.SystemTextJson" Version="10.0.2" />

    <!-- OpenAPI -->
    <PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.2" />
    <PackageReference Include="Scalar.AspNetCore" Version="2.x" />

    <!-- EF Core -->
    <PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.2" />
    <PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.2" />
    <PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="10.0.2">
      <PrivateAssets>all</PrivateAssets>
      <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
    </PackageReference>

    <!-- JWT Tokens -->
    <PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.15.0" />

    <!-- Serilog -->
    <PackageReference Include="Serilog.AspNetCore" Version="10.0.0" />
    <PackageReference Include="Serilog.Sinks.ApplicationInsights" Version="4.0.0" />
    <PackageReference Include="Serilog.Sinks.Console" Version="6.1.1" />
    <PackageReference Include="Serilog.Sinks.File" Version="7.0.0" />
  </ItemGroup>

</Project>

9. Quick Reference — Middleware Pipeline

flowchart TD
    A["1. UseForwardedHeaders()\nReverse proxy headers"] --> B
    B["2. UseExceptionHandler()\nException handling"] --> C
    C["3. MapOpenApi()\nOpenAPI spec endpoints"] --> D
    D["4. MapScalarApiReference()\nScalar UI"] --> E
    E["5. UseHttpsRedirection()\nForce HTTPS"] --> F
    F["6. UseAuthentication()\nWho are you?"] --> G
    G["7. UseAuthorization()\nDo you have permission?"] --> H
    H["8. MapControllers()\nDispatch to controllers"]

    style A fill:#fff3cd
    style F fill:#d4edda
    style G fill:#cce5ff
    style H fill:#f8d7da

Key middleware table

MiddlewareMethodRole
Forwarded HeadersUseForwardedHeaders()Read X-Forwarded-For/Proto from proxy
Exception HandlerUseExceptionHandler()Handle uncaught exceptions → ProblemDetails
HTTPS RedirectUseHttpsRedirection()Redirect HTTP → HTTPS
AuthenticationUseAuthentication()Validate JWT, populate HttpContext.User
AuthorizationUseAuthorization()Check [Authorize], policies, claims
RoutingMapControllers()Route to the correct controller action

10. Best Practices Summary

Security

PracticeReason
Store SecretForKey in User Secrets / Key VaultNever in source code or committed appsettings.json
Use ValidateIssuer, ValidateAudience, ValidateIssuerSigningKey = truePrevent forged tokens
Short token expiration (1h or less)Limit exposure window if a token is compromised
Mandatory HTTPSUseHttpsRedirection() + HSTS
Claim-based policies rather than rolesMore flexible and granular

Versioning

PracticeReason
Version from the startDifficult to add retroactively without a breaking change
Use [ApiVersion(x, Deprecated = true)] before removingAllow clients to migrate
ReportApiVersions = trueTransparency for API consumers
One OpenAPI document per versionAvoids confusion in documentation

Documentation

PracticeReason
[ProducesResponseType] on each actionAccurate documentation of return codes
XML <summary> comments on actions and DTOsAutomatically appears in the OpenAPI spec
options.OpenApiVersion = OpenApiSpecVersion.OpenApi3_0Maximum compatibility with tooling
Document Transformer for global securitySingle place to declare the Bearer requirement

Deployment

PracticeReason
UseForwardedHeaders() before UseHttpsRedirection()Forwarded proto must be read before redirect
Environment-differentiated logs (Dev vs Prod)Less noise in development, full telemetry in production
DefaultAzureCredential in AzureSupports MSI, Az CLI, env vars — no hardcoded credentials
-- convention for Key Vault secret namesAutomatic mapping to IConfiguration hierarchy

Summary Diagram — Complete Architecture

graph TB
    subgraph "Client"
        C["HTTP Client\n(browser, app, Scalar)"]
    end

    subgraph "Azure Cloud"
        subgraph "Azure App Service"
            RP["Reverse Proxy\n(sends X-Forwarded-*)"]
            subgraph "CityInfo API (ASP.NET Core 10)"
                MW["Middleware Pipeline\n(ForwardedHeaders → Auth → Controllers)"]
                AUTH["AuthenticationController\n(POST /api/authentication/authenticate)"]
                CITIES["CitiesController\n(GET /api/v1/cities)"]
                POI["PointsOfInterestController\n(CRUD /api/v1/cities/{id}/pointsofinterest)"]
                SVC["PointOfInterestService\n(Business Rules)"]
                REPO["CityInfoRepository\n(EF Core)"]
            end
            DB[(SQLite / SQL Azure)]
        end
        KV["Azure Key Vault\n(Secrets: JWT key, ConnectionString)"]
        AI["Application Insights\n(Logs, Metrics, Traces)"]
    end

    C -->|"HTTPS"| RP
    RP --> MW
    MW --> AUTH
    MW --> CITIES
    MW --> POI
    POI --> SVC
    SVC --> REPO
    CITIES --> REPO
    REPO --> DB
    MW -->|"Serilog"| AI
    KV -->|"Configuration secrets"| MW

Search Terms

asp.net · core · web · api · securing · versioning · documenting · deploying · apis · c# · .net · development · configuration · openapi · pipeline · service · azure · comments · diagram · documentation · jwt · middleware · packages · program.cs

Interested in this course?

Contact us to book it or get a custom training plan for your team.