DL
Back to Blog
TechFebruary 2, 2026·5 min read

The .NET 8 API Gateway Pattern

How FhirHub's .NET 8 API gateway handles authentication, authorization, rate limiting, security headers, and DTO mapping -- ensuring the frontend never talks to HAPI FHIR directly.

D

David Le

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:

HeaderValuePurpose
X-Content-Type-OptionsnosniffPrevents MIME type sniffing
X-XSS-Protection0Disables legacy XSS filter (CSP is better)
Referrer-Policystrict-origin-when-cross-originLimits referrer leakage
Strict-Transport-Securitymax-age=31536000Enforces HTTPS for 1 year
Permissions-Policycamera=(), microphone=()...Disables unnecessary browser APIs
Content-Security-Policydefault-src 'none'Restricts content sources
X-Frame-OptionsDENYPrevents 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.


Find the source code on GitHub Connect on LinkedIn

Related Projects

Featured

FhirHub

A healthcare data management platform built on the HL7 FHIR R4 standard, providing a comprehensive web interface for managing patient clinical data including vitals, conditions, medications, lab orders, and bulk data exports with role-based access control and full audit logging.

Next.js 16
React 19
Typescript
Tailwind CSS 4
+8