The .NET 8 API Gateway Pattern
By David Le -- Part 9 of the FhirHub Series
FhirHub's frontend never talks to HAPI FHIR directly. Everything goes through a .NET 8 API gateway that handles authentication, authorization, rate limiting, error handling, and security headers. This post breaks down the middleware pipeline and explains why each layer exists.
Why an API Gateway?
HAPI FHIR is a powerful FHIR server, but it's designed for data storage and retrieval -- not for application-level security, business logic, or client-specific DTOs. The API gateway handles:
- Authentication -- JWT validation against Keycloak
- Authorization -- Role-based and resource-level access control
- Rate limiting -- Protection against abuse
- Error handling -- Consistent error response format
- Security headers -- Defense against common web attacks
- DTO mapping -- Translating FHIR resources to frontend-friendly shapes
- Request logging -- Structured logs for monitoring and debugging
The Middleware Pipeline
Requests flow through the pipeline in order:
// Program.cs
app.UseSerilogRequestLogging(); // 1. Log every request
app.UseMiddleware<ExceptionHandlingMiddleware>(); // 2. Catch unhandled exceptions
app.UseMiddleware<SecurityHeadersMiddleware>(); // 3. Add security headers
app.UseRateLimiter(); // 4. Enforce rate limits
app.UseSwagger(); // 5. Swagger docs
app.UseSwaggerUI();
app.UseCors("Frontend"); // 6. CORS policy
app.UseHttpsRedirection(); // 7. Force HTTPS
app.UseAuthentication(); // 8. Validate JWT
app.UseAuthorization(); // 9. Check policies
app.MapControllers(); // 10. Route to controller
Order matters. Exception handling wraps everything below it. Authentication must come before authorization. Rate limiting sits above authentication so that unauthenticated spam is still throttled.
Exception Handling Middleware
The exception handler catches unhandled exceptions and maps them to structured JSON responses:
// Middleware/ExceptionHandlingMiddleware.cs
private static async Task HandleExceptionAsync(
HttpContext context, Exception exception)
{
context.Response.ContentType = "application/json";
var (statusCode, error) = exception switch
{
KeyNotFoundException => (HttpStatusCode.NotFound,
new ApiError("NOT_FOUND", exception.Message, 404)),
ArgumentException => (HttpStatusCode.BadRequest,
new ApiError("BAD_REQUEST", exception.Message, 400)),
UnauthorizedAccessException => (HttpStatusCode.Unauthorized,
new ApiError("UNAUTHORIZED", exception.Message, 401)),
_ => (HttpStatusCode.InternalServerError,
new ApiError("INTERNAL_ERROR",
"An unexpected error occurred", 500))
};
context.Response.StatusCode = (int)statusCode;
await context.Response.WriteAsync(
JsonSerializer.Serialize(error, options));
}
This pattern ensures the frontend always receives a consistent error shape -- no stack traces leak in production, and the error code string (NOT_FOUND, BAD_REQUEST) is machine-readable for error handling logic on the client.
Security Headers Middleware
Every response gets security headers stripped and added:
// Middleware/SecurityHeadersMiddleware.cs
public async Task InvokeAsync(HttpContext context)
{
// Remove server fingerprinting
context.Response.Headers.Remove("Server");
context.Response.Headers.Remove("X-Powered-By");
// Defensive headers
context.Response.Headers["X-Content-Type-Options"] = "nosniff";
context.Response.Headers["X-XSS-Protection"] = "0";
context.Response.Headers["Referrer-Policy"] =
"strict-origin-when-cross-origin";
context.Response.Headers["Strict-Transport-Security"] =
"max-age=31536000; includeSubDomains";
context.Response.Headers["Permissions-Policy"] =
"camera=(), microphone=(), geolocation=(), payment=()";
// Path-specific CSP
var path = context.Request.Path.Value ?? "";
if (path.StartsWith("/swagger"))
{
context.Response.Headers["Content-Security-Policy"] =
"default-src 'self'; style-src 'self' 'unsafe-inline'; " +
"script-src 'self' 'unsafe-inline'; img-src 'self' data:; " +
"font-src 'self' data:; frame-ancestors 'none'";
}
else
{
context.Response.Headers["Content-Security-Policy"] =
"default-src 'none'; frame-ancestors 'none'";
}
context.Response.Headers["X-Frame-Options"] = "DENY";
await _next(context);
}
Key headers explained:
| Header | Value | Purpose |
|---|---|---|
| X-Content-Type-Options | nosniff | Prevents MIME type sniffing |
| X-XSS-Protection | 0 | Disables legacy XSS filter (CSP is better) |
| Referrer-Policy | strict-origin-when-cross-origin | Limits referrer leakage |
| Strict-Transport-Security | max-age=31536000 | Enforces HTTPS for 1 year |
| Permissions-Policy | camera=(), microphone=()... | Disables unnecessary browser APIs |
| Content-Security-Policy | default-src 'none' | Restricts content sources |
| X-Frame-Options | DENY | Prevents clickjacking |
The CSP is relaxed for Swagger UI (it needs inline styles/scripts) but strict for API endpoints (JSON responses don't need any content sources).
Rate Limiting
Two rate limiting strategies protect the API:
Global Rate Limit
Every IP gets 100 requests per minute:
options.GlobalLimiter = PartitionedRateLimiter.Create<HttpContext, string>(
httpContext => RateLimitPartition.GetFixedWindowLimiter(
httpContext.Connection.RemoteIpAddress?.ToString() ?? "unknown",
_ => new FixedWindowRateLimiterOptions
{
PermitLimit = 100,
Window = TimeSpan.FromMinutes(1)
}));
Write Operation Rate Limit
Write endpoints get a stricter sliding window limit -- 20 operations per minute with 4 segments:
options.AddSlidingWindowLimiter("WriteOperations", limiterOptions =>
{
limiterOptions.PermitLimit = 20;
limiterOptions.Window = TimeSpan.FromMinutes(1);
limiterOptions.SegmentsPerWindow = 4;
});
The sliding window is applied via attribute on write endpoints:
[HttpPost("{id}/vitals")]
[EnableRateLimiting("WriteOperations")]
public async Task<IActionResult> RecordVitals(...)
When rate limited, clients receive HTTP 429 with a Retry-After: 60 header.
CORS Configuration
CORS is configured to allow only the frontend origin:
builder.Services.AddCors(options =>
{
options.AddPolicy("Frontend", policy =>
policy.WithOrigins(allowedOrigins)
.WithHeaders("Authorization", "Content-Type",
"Accept", "X-Requested-With")
.WithMethods("GET", "POST", "PUT", "DELETE", "OPTIONS")
.SetPreflightMaxAge(TimeSpan.FromMinutes(10))
.AllowCredentials());
});
The preflight cache is set to 10 minutes to reduce OPTIONS requests. Only specific headers and methods are allowed -- no wildcards.
FluentValidation
Request validation uses FluentValidation for declarative rules:
builder.Services.AddFluentValidationAutoValidation();
builder.Services.AddValidatorsFromAssemblyContaining<RecordVitalsRequestValidator>();
Auto-validation means invalid requests are rejected before they reach the controller. The error response is consistent with the exception middleware's format.
Structured Logging with Serilog
Every request is logged with structured data:
app.UseSerilogRequestLogging(options =>
{
options.MessageTemplate =
"HTTP {RequestMethod} {RequestPath} responded " +
"{StatusCode} in {Elapsed:0.0000} ms";
});
And the early bootstrap logger catches startup failures:
Log.Logger = new LoggerConfiguration()
.WriteTo.Console()
.CreateBootstrapLogger();
Swagger with JWT Support
Swagger is configured with a Bearer token security definition so developers can test authenticated endpoints:
builder.Services.AddSwaggerGen(options =>
{
options.AddSecurityDefinition("Bearer",
new OpenApiSecurityScheme
{
Name = "Authorization",
Type = SecuritySchemeType.Http,
Scheme = "Bearer",
BearerFormat = "JWT",
In = ParameterLocation.Header,
Description = "Enter your JWT token"
});
});
Service Registration
The gateway registers three layers of services:
// Core business services
builder.Services.AddCoreServices();
// Infrastructure (FHIR client)
builder.Services.AddSingleton<IFhirClientFactory, FhirClientFactory>();
// Repositories (HAPI FHIR data access)
builder.Services.AddScoped<IPatientRepository, HapiFhirPatientRepository>();
builder.Services.AddScoped<IDashboardRepository, HapiFhirDashboardRepository>();
builder.Services.AddScoped<IExportRepository, HapiFhirExportRepository>();
// Keycloak Admin API
builder.Services.AddHttpClient<IKeycloakAdminService, KeycloakAdminService>();
Repositories are scoped (one per request) because they hold per-request state. The FHIR client factory is singleton since it manages connection pooling.
What's Next
In Part 10, we'll examine the Docker Compose configuration in detail -- health checks, volume management, network architecture, and why healthcare apps need separate databases.