Reusable, cloud-agnostic infrastructure for ASP.NET Core API and worker services: JWT authentication, CORS, API key gating, standardized error responses, and configuration binding, packaged as four focused NuGet libraries.
I originally built this to abstract out infrastructure setup for a separate project, then realized it would generalize well beyond that project if I stripped out the parts that were specific to it. The original version was fairly tightly coupled to Azure (Blob Storage for data protection keys, Application Insights for telemetry) and to that project's own internal package feed. This version removes that coupling entirely: no assumptions about which cloud you're on, no forced telemetry vendor, no hardcoded auth conventions. Every opinionated default is something you can opt out of or override.
I also wanted a public example/portfolio piece of how I approach .NET backend engineering. Building reusable packages with clean abstractions, making the configurable surface explicit, and putting tooling around complicated setup so less experienced developers can get off the ground quickly.
| Package | Purpose |
|---|---|
GPeterson.Infrastructure |
ApiApp/WorkerApp builders, the actual infrastructure setup for API and worker services |
GPeterson.Infrastructure.Contracts |
Zero-dependency shared types: Response<T>, ErrorDetails, OptionsConfigurationAttribute, common constants. Safe to reference from anything, including client SDKs, without pulling in ASP.NET Core |
GPeterson.Infrastructure.Extensions |
AddConfiguredOptions<T>(), binds and validates an options class from configuration by convention |
GPeterson.Infrastructure.Models |
API-facing response models (e.g. ApiErrorResponseModel), kept separate from Contracts since these are the actual wire shape returned to clients, not just internal service-to-service types |
Targets .NET 10.
Use ApiApp for services with publicly exposed HTTP endpoints.
using GPeterson.Infrastructure.Api;
var app = ApiApp.Initialize(args);
app.AddInfrastructure(options =>
{
options.UseAuthentication = true;
});
await app.RunAsync();That gives you controllers, API key gating, CORS, JWT bearer validation, Swagger in Development, and standardized error responses. Everything is opt-out.
See samples/GPeterson.Infrastructure.SampleApi for a
complete, runnable example exercising every feature described below. It's also the target the
integration test suite runs against.
<ItemGroup>
<PackageReference Include="GPeterson.Infrastructure" Version="2.0.0" />
</ItemGroup>That's the only reference needed. Everything ApiApp uses (JWT bearer auth, data protection,
Swagger, hosting) ships as a dependency of the package itself, there's nothing extra to add to your
.csproj.
AddInfrastructure takes a single configuration callback. Every property defaults to the "batteries
included" behavior below, so set only what you want to change. Adding a feature to this library is
an additive property here rather than a signature change, and call sites never end up passing a run
of bare booleans positionally.
| Property | Default | Behavior |
|---|---|---|
AddCors |
true |
Registers a default CORS policy. See Configuration for how it's sourced, or set ConfigureCors to build it yourself. |
UseAuthentication |
false |
Registers JWT bearer authentication and data protection. See Configuration. |
UseApiKey |
true |
Gates every endpoint behind an API key header. Set false if you're relying on a different auth model (JWT only, or auth enforced upstream by a gateway) and handle it via configureMiddleware (below). |
UseExceptionHandler |
true |
Registers ApiExceptionHandler, so any unhandled exception returns the standardized 500 shape. See Error handling. |
AddSwagger |
true |
Registers OpenAPI/Swagger generation. UI is only mapped when running in the Development environment. |
ApiKeyHeaderName |
x-api-key |
Header the API key middleware checks. |
ReadinessPath |
/readiness |
Exact GET path that bypasses the API key check, for orchestrator probes that can't present a key. Set to default to disable the bypass. |
AppName |
ApiApp |
Data protection application name. |
ConfigureDataProtection |
none | Callback to configure key persistence (Azure Blob, Redis, a shared file path). If omitted, ASP.NET Core's local file-system default is used, fine for local dev and single-instance deployments, but keys won't be shared across machines. |
ConfigureCors |
none | Callback to configure the CORS policy directly, instead of the config-driven default. |
ConfigureJson |
none | Callback to configure JSON serialization. If omitted, framework defaults apply (numeric enums). |
ConfigureSwaggerGen |
none | Callback to configure Swashbuckle directly (security definitions, XML doc paths, etc). |
A fuller example:
app.AddInfrastructure(options =>
{
options.UseAuthentication = true;
options.ApiKeyHeaderName = "x-tenant-key";
options.ReadinessPath = new PathString("/health/ready");
options.ConfigureDataProtection = dp => dp.PersistKeysToStackExchangeRedis(redis);
options.ConfigureJson = json => json.Converters.Add(new JsonStringEnumConverter());
});AddInfrastructure may only be called once per ApiApp; a second call throws rather than silently
double-registering services and letting the later flags win.
ApiApp.RunAsync(configureMiddleware, cancellationToken) accepts an optional callback to add custom
middleware to the pipeline (after the API key check, before authentication), and a
CancellationToken for graceful shutdown in tests or hosts that manage their own lifetime.
API key, required only if UseApiKey is true (the default).
ApiApp:ApiKeys holds one or more accepted keys, presented in the header named by
ApiKeyHeaderName. A request is authorized if it matches any key in the set, which supports
rotation (add the new key, remove the old one once callers migrate) without a deploy, and per-caller
keys without a code change.
{ "ApiApp": { "ApiKeys": ["your-key-here"] } }Generate a key:
openssl rand -base64 32 | tr -d '=+/=' | tr '/+' '_-'Keys are compared exactly, including casing, and in constant time. Case-insensitive matching would cut the effective entropy of a key generated the way above by roughly 25%, and a short-circuiting comparison would let a caller discover a valid key one character at a time by watching response timing. Send the key back precisely as configured.
CORS, only read if AddCors is true (the default) and no ConfigureCors callback was
supplied. CorsOptions:AllowedHosts takes a semicolon-separated list of allowed origins. Startup
fails with an InvalidOperationException if CORS is enabled and neither source is present, rather
than quietly serving a policy with no origins.
{ "CorsOptions": { "AllowedHosts": "https://app.example.com;https://admin.example.com" } }Authentication, only required if UseAuthentication is true.
ApiAppAuthentication:Issuer, Audience, and SecretKey are all required. This library
validates JWTs presented to protected endpoints, it does not issue them; mint tokens with
whichever identity provider or auth service you're already using, using these same three values.
Missing values fail at startup with an OptionsValidationException naming the offending field, via
ValidateOnStart, rather than at the first authenticated request.
openssl rand -base64 32ApiAppAuthentication:NameClaimType (default sub) and RoleClaimType (default role) are
optional; override them if your token issuer uses different claim keys. The defaults match the
standard JWT/OIDC claim names (RFC 7519), not the legacy ASP.NET claim URIs. Token lifetime is
validated with zero clock skew, so an expired token is rejected immediately rather than staying
valid for the framework's default five extra minutes.
With UseExceptionHandler enabled (the default), ApiExceptionHandler converts any unhandled
exception into a 500 carrying the standardized ApiErrorResponseModel body, logged once with the
request method, path, and trace identifier. It runs at the pipeline level, so it covers model
binding, filters, and minimal-API handlers, not just controller actions. Your actions do not need
try/catch. Let exceptions propagate.
{
"errorCode": "UnknownException",
"errorDisplayMessage": "Something unexpected went wrong. Please try again or contact support for more help."
}The exception message is deliberately not returned to the caller. Internal exception text routinely leaks connection strings, file paths, and SQL, and the caller can't do anything useful with it. Correlate through the logged trace identifier instead.
Expected domain failures are a different thing, and still belong in your code mapped explicitly. See
ErrorResponse below.
Two base classes are available under GPeterson.Infrastructure.Api.Controllers, useful whether or
not you use anything else in this library.
ApiControllerBase provides ErrorResponse(ErrorDetails, Func<string, int> errorCodeMap) for
mapping your own domain error codes to specific status codes:
public class MyController(ILogger<MyController> logger) : ApiControllerBase(logger)
{
[HttpGet("{id:guid}")]
public async Task<IActionResult> Get(Guid id)
{
Response<Customer> result = await customerService.GetAsync(id);
if (!result.Success)
return ErrorResponse(result.Error, code => code switch
{
"CustomerNotFound" => StatusCodes.Status404NotFound,
_ => StatusCodes.Status500InternalServerError,
});
return Ok(result.Result);
}
}ExceptionResponse(e) is also available, for the narrow case where an action must catch an
exception to do something else first (compensating work, releasing a resource) and still wants the
standard shape, or where you've disabled the pipeline handler. Prefer letting exceptions propagate.
Note the constructor takes a plain ILogger, not ILogger<T>. That's intentional: a base class
can't know its derived type's generic parameter, so derived controllers inject ILogger<TSelf> (as
above) and pass it up, since ILogger<T> implements ILogger.
ApiAppAuthenticatedControllerBase (: ApiControllerBase) adds identity helpers for
authenticated endpoints:
public class MyController(ILogger<MyController> logger) : ApiAppAuthenticatedControllerBase(logger)
{
[HttpGet]
[Authorize]
public IActionResult Get() => Ok(new { userId = UserId() });
}UserId()returns the current user's id, resolved viaClaimsIdentity.Name(governed byNameClaimType, see Configuration). ThrowsInvalidOperationExceptionif no user is present, so only call it from endpoints that actually require authentication.AnonymousUserId()uses the same resolution but returnsnullinstead of throwing, for endpoints that allow anonymous access but still want to know who's calling if a token was provided.
Both work out of the box against tokens using the standard sub claim, and against non-default
claim types if you've overridden NameClaimType, since they read through the same configured value
rather than a hardcoded claim key.
RunAsync builds the pipeline in this order, skipping anything you've disabled:
UseExceptionHandler(first, so everything downstream is covered)- Swagger and Swagger UI, Development only
UseHttpsRedirectionUseCorsApiKeyMiddleware- Your
configureMiddlewarecallback UseAuthentication/UseAuthorizationGET /liveness response, thenMapControllers
GET / returns {"status": "API is running."} and bypasses the API key check, as does a GET to the
configured ReadinessPath. Both bypasses are exact path matches and GET only; nothing else skips the
key check.
Use WorkerApp for background/worker services with no HTTP surface.
using GPeterson.Infrastructure.Worker;
var app = WorkerApp.Initialize(args);
app.AddInfrastructure((services, configuration) =>
{
services.AddHostedService<MyBackgroundService>();
});
await app.RunAsync();Same single reference as ApiApp:
<ItemGroup>
<PackageReference Include="GPeterson.Infrastructure" Version="2.0.0" />
</ItemGroup>WorkerApp.Initialize() wires up Host.CreateDefaultBuilder (standard
appsettings.json/environment variable/command-line configuration) plus
UseWindowsService()/UseSystemd(). Both are safe no-ops unless the app is actually running under
that specific host, so a worker deployed as a Windows Service or systemd unit works correctly
without any extra setup on your end.
No configuration is required by the library itself. There's no equivalent to ApiApp's API
key/CORS/auth surface here, since a worker has no HTTP pipeline to protect.
GPeterson.Infrastructure.Extensions ships AddConfiguredOptions<TOptions>(), which the API
packages use internally and which is useful on its own. Decorate an options class with
[OptionsConfiguration("SectionName")] and the section name travels with the type instead of being
repeated at every registration site:
[OptionsConfiguration("Billing")]
public class BillingOptions
{
[Required]
public required string ApiBaseUrl { get; init; }
[Range(1, 30)]
public int RetryCount { get; init; } = 3;
}
services.AddConfiguredOptions<BillingOptions>();That binds the section, validates its DataAnnotations, and runs that validation at startup rather
than on first resolution, so a misconfigured deployment fails immediately instead of at the first
request that happens to need the value. Pass an explicit sectionName argument to override the
attribute, or to bind a type you don't own.
The type is registered both as IOptions<TOptions>/IOptionsMonitor<TOptions> and as a plain
singleton, so consumers that don't care about reload-on-change can inject BillingOptions directly
without the wrapper. Inject IOptionsMonitor<TOptions> when you do want live updates.
GPeterson.Infrastructure.Contracts ships a small result type for service boundaries where a
failure is an expected outcome rather than a fault, so callers branch instead of paying for
exceptions as control flow:
public async Task<Response<Customer>> GetAsync(Guid id)
{
Customer? customer = await repository.FindAsync(id);
if (customer is null)
return new ErrorDetails
{
ErrorCode = "CustomerNotFound",
ErrorDisplayMessage = "We couldn't find that customer.",
};
return customer;
}Implicit conversions mean neither branch has to name the wrapper, and [MemberNotNullWhen] on
Success means that after if (result.Success) the compiler knows Result is non-null without a
null-forgiving operator, and knows Error is non-null in the else.
Nothing in this repository consumes Response<T>; the infrastructure packages don't depend on it.
It lives in Contracts because that package is zero-dependency and safe to reference from anything,
including client SDKs and workers that never touch ASP.NET Core. It's published as shared vocabulary
for consumers designing their own service interfaces. The wire shape actually returned to HTTP
clients is ApiErrorResponseModel in the Models package, deliberately kept separate.
dotnet build
dotnet test --settings coverlet.runsettings --collect:"XPlat Code Coverage" --results-directory ./coverageTwo test tiers, both run in CI:
- Unit tests (
tests/GPeterson.Infrastructure.Tests) inspect DI registrations and middleware behavior directly, usingInternalsVisibleTorather than booting a host. - Integration tests (
tests/GPeterson.Infrastructure.IntegrationTests) run against the sample API throughWebApplicationFactory, and prove the feature flags change end-to-end pipeline behavior rather than just which services got registered.
Coverage is collected on every pull request, summarized on the job page, and enforced against a
floor. Packages publish from main through a reusable workflow using NuGet Trusted Publishing, so
no long-lived API key is stored in the repository.
Things worth revisiting, not commitments:
- A built-in readiness/health endpoint.
ApiKeyMiddlewarebypasses the configuredReadinessPath, but no endpoint ships at that path yet, so consumers have to map their own. Shipping one overMicrosoft.Extensions.Diagnostics.HealthCheckswould close the loop. - A generic
AddHttpClientabstraction. Typed clients with sane default resilience policies (retry, circuit breaker) would be a natural fit for this library's scope. Not yet built. - Automated package versioning.
<Version>is currently bumped manually per.csprojbefore a release-worthy merge. Worth automating (e.g. Nerdbank.GitVersioning) once release cadence picks up. - Swagger vs. API key middleware ordering. Swagger's middleware runs before
ApiKeyMiddleware, so Swagger UI is reachable without an API key in Development. That's probably fine (Development environments aren't usually public, and OpenAPI docs aren't typically sensitive), but it was never a deliberate choice, just an artifact of registration order. Worth confirming intentionally rather than leaving implicit. - Analyzer and format enforcement in CI. No
TreatWarningsAsErrors, nodotnet format --verify-no-changesgate, and no Dependabot yet.
MIT. See LICENSE.