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
- Starting Application Architecture
- The Service Layer
- Securing the API — JWT Bearer Authentication
- API Versioning
- Documentation with OpenAPI and Scalar
- Advanced Documentation — XML Comments and Response Attributes
- Deploying to Azure
- Quick Reference — NuGet Packages
- Quick Reference — Middleware Pipeline
- 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)
| Package | Version | Role |
|---|---|---|
Asp.Versioning.Mvc | 8.1.1 | API versioning |
AutoMapper | 16.0.0 | Object-to-object mapping |
Microsoft.AspNetCore.Authentication.JwtBearer | 10.0.2 | JWT Bearer authentication |
Microsoft.AspNetCore.JsonPatch.SystemTextJson | 10.0.2 | JSON Patch (PATCH verb) |
Microsoft.AspNetCore.OpenApi | 10.0.2 | OpenAPI 3.x document generation |
Microsoft.EntityFrameworkCore.Sqlite | 10.0.2 | SQLite provider for EF Core |
Serilog.AspNetCore | 10.0.0 | Structured logging |
Serilog.Sinks.Console | 6.1.1 | Console logging |
Serilog.Sinks.File | 7.0.0 | File logging |
System.IdentityModel.Tokens.Jwt | 8.15.0 | JWT 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:
| Scenario | Service 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:
- A city can have a maximum of 10 points of interest.
- 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
| Part | Content | Example |
|---|---|---|
| Header | Signing algorithm | { "alg": "HS256", "typ": "JWT" } |
| Payload | Claims (sub, given_name, city, exp…) | { "sub": "1", "city": "Antwerp" } |
| Signature | HMAC-SHA256(header + payload + secret) | HMACSHA256(...) |
Claims used in CityInfo
| Claim | Example value | Role |
|---|---|---|
sub | "1" | Subject — user identifier |
given_name | "Kevin" | First name |
family_name | "Dockx" | Last name |
city | "Antwerp" | User’s city (used for policies) |
iat | Unix timestamp | Issued At |
exp | Unix timestamp | Expiration (+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
SecretForKeyto 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
⚠️
UseAuthenticationmust always come beforeUseAuthorization. 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
| Strategy | Example | Advantages | Disadvantages |
|---|---|---|---|
| URL segment | /api/v1/cities | Very visible, easy to test | Less “purely RESTful” |
| Query string | /api/cities?api-version=1.0 | No route change | Less readable |
| Header | api-version: 1.0 | Clean URL | Less discoverable |
| Media type | Accept: application/vnd.api+json;v=1 | Very REST-compliant | Complex |
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
| Term | Definition |
|---|---|
| OpenAPI | Specification (open standard) for describing REST APIs |
| Swagger | Tooling created by SmartBear around OpenAPI (UI, codegen, etc.) |
| Scalar | Modern open-source UI for exploring OpenAPI specs |
Microsoft.AspNetCore.OpenApi | Official 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
| URL | Content |
|---|---|
/openapi/v1.json | OpenAPI 3.0 spec in JSON format for version 1 |
/openapi/v2.json | OpenAPI 3.0 spec in JSON format for version 2 |
/scalar/v1 | Scalar UI interface for version 1 |
/scalar/v2 | Scalar 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 Method | Typical codes |
|---|---|
GET (collection) | 200 OK |
GET (item) | 200 OK, 404 Not Found |
POST | 201 Created, 400 Bad Request, 409 Conflict |
PUT | 204 No Content, 400 Bad Request, 404 Not Found |
PATCH | 204 No Content, 400 Bad Request, 404 Not Found |
DELETE | 204 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:
| Value | Usage |
|---|---|
Development | Local development — Serilog, User Secrets |
Staging | Pre-production testing |
Production | Azure 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 key | Key Vault secret name |
|---|---|
Authentication:SecretForKey | Authentication--SecretForKey |
Authentication:Issuer | Authentication--Issuer |
ConnectionStrings:CityInfoDBConnectionString | ConnectionStrings--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:
| Header | Content |
|---|---|
X-Forwarded-For | Real client IP |
X-Forwarded-Proto | Original protocol (https) |
X-Forwarded-Host | Original hostname |
⚠️ On Azure App Service in production,
ForwardedHeadersconfiguration 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)
| Package | Role |
|---|---|
Azure.Extensions.AspNetCore.Configuration.Secrets | Key Vault integration in IConfiguration |
Azure.Identity | DefaultAzureCredential (MSI / Az CLI) |
Azure.Security.KeyVault.Secrets | Key Vault SDK |
Microsoft.ApplicationInsights.Extensibility | Core 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
Recommended order
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
| Middleware | Method | Role |
|---|---|---|
| Forwarded Headers | UseForwardedHeaders() | Read X-Forwarded-For/Proto from proxy |
| Exception Handler | UseExceptionHandler() | Handle uncaught exceptions → ProblemDetails |
| HTTPS Redirect | UseHttpsRedirection() | Redirect HTTP → HTTPS |
| Authentication | UseAuthentication() | Validate JWT, populate HttpContext.User |
| Authorization | UseAuthorization() | Check [Authorize], policies, claims |
| Routing | MapControllers() | Route to the correct controller action |
10. Best Practices Summary
Security
| Practice | Reason |
|---|---|
Store SecretForKey in User Secrets / Key Vault | Never in source code or committed appsettings.json |
Use ValidateIssuer, ValidateAudience, ValidateIssuerSigningKey = true | Prevent forged tokens |
| Short token expiration (1h or less) | Limit exposure window if a token is compromised |
| Mandatory HTTPS | UseHttpsRedirection() + HSTS |
| Claim-based policies rather than roles | More flexible and granular |
Versioning
| Practice | Reason |
|---|---|
| Version from the start | Difficult to add retroactively without a breaking change |
Use [ApiVersion(x, Deprecated = true)] before removing | Allow clients to migrate |
ReportApiVersions = true | Transparency for API consumers |
| One OpenAPI document per version | Avoids confusion in documentation |
Documentation
| Practice | Reason |
|---|---|
[ProducesResponseType] on each action | Accurate documentation of return codes |
XML <summary> comments on actions and DTOs | Automatically appears in the OpenAPI spec |
options.OpenApiVersion = OpenApiSpecVersion.OpenApi3_0 | Maximum compatibility with tooling |
| Document Transformer for global security | Single place to declare the Bearer requirement |
Deployment
| Practice | Reason |
|---|---|
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 Azure | Supports MSI, Az CLI, env vars — no hardcoded credentials |
-- convention for Key Vault secret names | Automatic 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