Add a Swagger UI using a .NET 9 Json OpenAPI file

When Microsoft added native OpenAPI support to .NET 9 through Microsoft.AspNetCore.OpenApi, it generated the specification document for you but did not ship a browsable UI on top of it. Swashbuckle used to give this to you for free. If your team wants a Swagger UI to explore and test an API during development, you now have to wire it up yourself using the JSON file that .NET 9 already produces.

This is a common requirement in enterprise projects where a QA engineer or a frontend developer needs to poke at an API without pulling up Postman every time. The catch is that a Swagger UI needs relaxed security headers to run, mainly around Content Security Policy, and you never want that relaxation to leak into a production deployment. The approach below keeps the UI available for development and locked out entirely for production, using one configuration flag.

Why .NET 9 does not include a UI out of the box

The built in OpenAPI support in .NET 9 only handles document generation. Calling app.MapOpenApi() gives you a JSON file describing your endpoints, nothing more. Earlier ASP.NET Core versions leaned on Swashbuckle for both the document generation and the UI rendering, so a lot of developers assume the UI comes bundled in. It does not, and Microsoft has been explicit that the native OpenAPI feature is document generation only, leaving the UI as a separate concern you plug in with a package of your choice, such as Swashbuckle’s UI package or NSwag.

This separation is actually a reasonable design decision. A production API rarely needs a UI at all, and bundling one by default just adds an attack surface that most teams then have to remember to strip out. Splitting document generation from UI rendering means you opt in to the UI only where you actually want it.

Packages you need

Two NuGet packages go into the project on top of the standard OpenAPI generation. Swashbuckle.AspNetCore.SwaggerUI gives you just the UI renderer, not the full Swashbuckle document generator, since .NET 9 already produces the JSON itself. NetEscapades.AspNetCore.SecurityHeaders handles the security header policy, including the CSP rules that need to loosen up when the UI is active.

Keeping the UI package separate from the security headers package matters here. You want one place that decides whether a deployment is a UI-enabled development target or a locked down production API, and both the OpenAPI/UI wiring and the header policy read from that same decision.

Gating the Swagger UI behind a configuration flag

The middleware setup reads a DeploySwaggerUI setting from configuration rather than checking app.Environment.IsDevelopment(). That is a deliberate choice. IsDevelopment ties the decision to the ASPNETCORE_ENVIRONMENT variable, which is often Development locally but Staging or Production everywhere else. A dedicated configuration flag lets you turn the UI on for a specific test or integration deployment slot without pretending that whole environment is a development environment.

// Open up security restrictions to allow this to work
// Not recommended in production
//var deploySwaggerUI = app.Environment.IsDevelopment();
var deploySwaggerUI = app.Configuration.GetValue<bool>("DeploySwaggerUI");
 
app.UseSecurityHeaders(
    SecurityHeadersDefinitions.GetHeaderPolicyCollection(deploySwaggerUI));
 
// ... other middleware
 
app.MapOpenApi("/openapi/v1/openapi.json");
 
if (deploySwaggerUI)
{
    app.UseSwaggerUI(options =>
    {
        options.SwaggerEndpoint("/openapi/v1/openapi.json", "v1");
    });
}

The same deploySwaggerUI boolean drives both the security header policy and whether the Swagger UI middleware gets mapped at all. When the flag is false, the UI middleware is never added to the pipeline, and the header policy applies its strictest CSP rules. A mistake worth watching for here is leaving MapOpenApi reachable in production even when the UI is switched off. The JSON endpoint itself has no authentication by default, so if you do not want your API surface documented publicly, guard that endpoint too, not just the UI.

Setting the flag per environment

The flag itself is just a normal appsettings entry, which means you can override it per deployment slot through environment variables or your deployment pipeline without touching code.

{
  // Open up security restrictions to allow this to work
  // Not recommended in production
  "DeploySwaggerUI": true,
  ...

Set this to true only in appsettings.Development.json or in the app configuration for a dedicated internal test slot. Production appsettings should either omit the key entirely or set it explicitly to false, since GetValue<bool> defaults to false when the key is missing, which is the safe default you want here.

Relaxing the CSP for the UI to actually work

A Swagger UI page loads inline scripts and styles to render its interactive documentation, and a strict Content Security Policy blocks exactly that kind of thing. The SecurityHeadersDefinitions class below, built on NetEscapades.AspNetCore.SecurityHeaders, branches its CSP rules based on the same isDev flag used earlier.

namespace WebApiOpenApi;
 
public static class SecurityHeadersDefinitions
{
    public static HeaderPolicyCollection GetHeaderPolicyCollection(bool isDev)
    {
        var policy = new HeaderPolicyCollection()
            .AddFrameOptionsDeny()
            .AddContentTypeOptionsNoSniff()
            .AddReferrerPolicyStrictOriginWhenCrossOrigin()
            .AddCrossOriginOpenerPolicy(builder => builder.SameOrigin())
            .AddCrossOriginEmbedderPolicy(builder => builder.RequireCorp())
            .AddCrossOriginResourcePolicy(builder => builder.SameOrigin())
            .RemoveServerHeader()
            .AddPermissionsPolicy(builder =>
            {
                builder.AddAccelerometer().None();
                builder.AddAutoplay().None();
                builder.AddCamera().None();
                builder.AddEncryptedMedia().None();
                builder.AddFullscreen().All();
                builder.AddGeolocation().None();
                builder.AddGyroscope().None();
                builder.AddMagnetometer().None();
                builder.AddMicrophone().None();
                builder.AddMidi().None();
                builder.AddPayment().None();
                builder.AddPictureInPicture().None();
                builder.AddSyncXHR().None();
                builder.AddUsb().None();
            });
 
        AddCspHstsDefinitions(isDev, policy);
 
        policy.ApplyDocumentHeadersToAllResponses();
 
        return policy;
    }
 
    private static void AddCspHstsDefinitions(bool isDev, HeaderPolicyCollection policy)
    {
        if (!isDev)
        {
            policy.AddContentSecurityPolicy(builder =>
            {
                builder.AddObjectSrc().None();
                builder.AddBlockAllMixedContent();
                builder.AddImgSrc().None();
                builder.AddFormAction().None();
                builder.AddFontSrc().None();
                builder.AddStyleSrc().None();
                builder.AddScriptSrc().None();
                builder.AddBaseUri().Self();
                builder.AddFrameAncestors().None();
                builder.AddCustomDirective("require-trusted-types-for", "'script'");
            });
            // maxage = one year in seconds
            policy.AddStrictTransportSecurityMaxAgeIncludeSubDomains(maxAgeInSeconds: 60 * 60 * 24 * 365);
        }
        else
        {
            // allow swagger UI for dev
            policy.AddContentSecurityPolicy(builder =>
            {
                builder.AddObjectSrc().None();
                builder.AddBlockAllMixedContent();
                builder.AddImgSrc().Self().From("data:");
                builder.AddFormAction().Self();
                builder.AddFontSrc().Self();
                builder.AddStyleSrc().Self().UnsafeInline();
                builder.AddScriptSrc().Self().UnsafeInline(); //.WithNonce();
                builder.AddBaseUri().Self();
                builder.AddFrameAncestors().None();
            });
        }
    }
}

In the non-dev branch, the CSP blocks images, fonts, styles and scripts from every source, including the app’s own origin, which is the correct posture for an API with no UI to protect. In the dev branch, styles and scripts get UnsafeInline so the Swagger UI’s inline assets are allowed to run, and images get a data: exception because Swagger UI renders some of its icons as inline data URIs. This dev branch is meaningfully weaker CSP, and that is the whole trade-off you are accepting in exchange for a working UI.

Notice the commented out .WithNonce() call on the script-src directive. Nonces are the correct long-term fix, since they let you allow specific inline scripts without opening the door to any inline script an attacker might inject. The UnsafeInline approach here is a shortcut that works because it only ever runs in development or a locked-down internal test slot, never in production. If you tried to ship UnsafeInline to production to save yourself the work of wiring up nonces, you would be handing an attacker a much easier path to cross-site scripting.

What you get in the browser

With the flag enabled, hitting the Swagger UI route in a development deployment renders the familiar interactive documentation page, built entirely from the OpenAPI JSON that .NET 9 generated. A developer or tester can paste in a valid access token and call the API’s endpoints directly from the browser.

Swagger UI rendered from the .NET 9 generated OpenAPI JSON, with a bearer token authorized against a protected endpoint.
Swagger UI rendered from the .NET 9 generated OpenAPI JSON, with a bearer token authorized against a protected endpoint.

When this pattern makes sense

This setup earns its keep on internal APIs where a small group of developers or testers need a quick way to try endpoints without a separate API client, and where the deployment target is genuinely not internet-facing production. It is a poor fit for any API you plan to expose publicly, since even with the CSP hardening described above, you are still shipping a page whose entire job is to advertise every route, parameter and schema your API has.

If you need documentation for external partners or public consumers, look at generating a static, reviewed OpenAPI reference instead of exposing a live interactive UI, or consider Scalar’s OpenAPI UI, which several .NET 9 projects are adopting as a lighter alternative to Swashbuckle’s UI package now that Swashbuckle’s own document generator is no longer required.

Leave a Reply

Discover more from Behind the Stack

Subscribe now to keep reading and get access to the full archive.

Continue reading