Start Debugging

How to add OpenAPI authentication flows to Swagger UI in .NET 11

In .NET 11 the OpenAPI document is generated by Microsoft.AspNetCore.OpenApi and Swagger UI is no longer in the template. Here is how to wire Bearer, OAuth2 with PKCE, and OpenID Connect so the Authorize button actually works.

In .NET 11 the OpenAPI document is produced by Microsoft.AspNetCore.OpenApi and Swagger UI is no longer in the project template. To get an Authorize button that actually sends headers, you need three pieces wired together: a document transformer that registers a security scheme on the OpenAPI document, a global or operation-level security requirement so endpoints declare what they need, and the Swagger UI middleware (Swashbuckle.AspNetCore.SwaggerUI) configured with OAuth client settings if you are using OAuth2 or OpenID Connect. This post walks through Bearer JWT, OAuth2 authorization code with PKCE, and OpenID Connect, all on .NET 11 GA.

Versions referenced throughout: .NET 11.0 GA, Microsoft.AspNetCore.OpenApi 11.0, Swashbuckle.AspNetCore.SwaggerUI 7.x, Microsoft.AspNetCore.Authentication.JwtBearer 11.0. Examples are minimal API but the same transformers work in MVC controllers.

What changed since .NET 8

In .NET 8 and earlier, Swashbuckle.AspNetCore shipped as the default. You called AddSwaggerGen() and configured everything (auth schemes, requirements, UI options) in one place. From .NET 9 onwards the template ships Microsoft.AspNetCore.OpenApi for document generation and removes Swagger UI entirely. .NET 11 keeps that split.

This means two things for authentication flows:

  1. The OpenAPI document is no longer Swashbuckle’s responsibility, so all OperationFilter and DocumentFilter examples on Stack Overflow are obsolete. The new hook is IOpenApiDocumentTransformer and IOpenApiOperationTransformer.
  2. Swagger UI is now optional. If you want it back you install Swashbuckle.AspNetCore.SwaggerUI (just the UI package, around 600 KB) and point it at the JSON document the new generator emits.

If you only care about a try-it-out UI, Scalar is a lighter alternative that reads the same OpenAPI document. The transformers below produce a valid OpenAPI 3.x security model, so any UI that respects the spec will pick up the auth flows.

The minimal Bearer JWT setup

Start with the simplest scheme: http with bearer and a JWT format hint. Install the OpenAPI generator, the UI, and JWT bearer authentication:

# .NET 11
dotnet add package Microsoft.AspNetCore.OpenApi
dotnet add package Swashbuckle.AspNetCore.SwaggerUI
dotnet add package Microsoft.AspNetCore.Authentication.JwtBearer

Add a document transformer that registers the scheme:

// .NET 11, C# 14
using Microsoft.AspNetCore.OpenApi;
using Microsoft.OpenApi.Models;

internal sealed class BearerSecuritySchemeTransformer : IOpenApiDocumentTransformer
{
    public Task TransformAsync(
        OpenApiDocument document,
        OpenApiDocumentTransformerContext context,
        CancellationToken ct)
    {
        document.Components ??= new OpenApiComponents();
        document.Components.SecuritySchemes["Bearer"] = new OpenApiSecurityScheme
        {
            Type = SecuritySchemeType.Http,
            Scheme = "bearer",
            BearerFormat = "JWT",
            In = ParameterLocation.Header,
            Description = "Paste a JWT issued by your IdP."
        };

        document.SecurityRequirements.Add(new OpenApiSecurityRequirement
        {
            [new OpenApiSecurityScheme
            {
                Reference = new OpenApiReference
                {
                    Type = ReferenceType.SecurityScheme,
                    Id = "Bearer"
                }
            }] = []
        });

        return Task.CompletedTask;
    }
}

Register it and serve the JSON plus UI:

// .NET 11, C# 14, Program.cs
using Microsoft.AspNetCore.Authentication.JwtBearer;

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddOpenApi(options =>
{
    options.AddDocumentTransformer<BearerSecuritySchemeTransformer>();
});

builder.Services
    .AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
    .AddJwtBearer(o =>
    {
        o.Authority = "https://login.example.com/";
        o.Audience = "api://my-api";
    });

builder.Services.AddAuthorization();

var app = builder.Build();

app.MapOpenApi();           // serves /openapi/v1.json
app.UseSwaggerUI(c =>
{
    c.SwaggerEndpoint("/openapi/v1.json", "API v1");
});

app.UseAuthentication();
app.UseAuthorization();

app.MapGet("/secret", () => "hello").RequireAuthorization();
app.Run();

Open /swagger, click Authorize, paste the token, and Swagger UI now sends Authorization: Bearer <token> on every call. The global SecurityRequirements mean every operation inherits the requirement; if you want a public endpoint, override it per operation (covered under “Multiple schemes” below).

OAuth2 authorization code with PKCE

The Bearer setup is fine for “I already have a token, paste it here,” but most teams want Swagger UI to actually walk the user through an OAuth login. For SPA-style flows, use authorization code with PKCE.

Add another transformer:

// .NET 11, C# 14
internal sealed class OAuth2SecuritySchemeTransformer(IConfiguration config)
    : IOpenApiDocumentTransformer
{
    public Task TransformAsync(
        OpenApiDocument document,
        OpenApiDocumentTransformerContext context,
        CancellationToken ct)
    {
        var authority = config["Auth:Authority"]!.TrimEnd('/');

        document.Components ??= new OpenApiComponents();
        document.Components.SecuritySchemes["oauth2"] = new OpenApiSecurityScheme
        {
            Type = SecuritySchemeType.OAuth2,
            Flows = new OpenApiOAuthFlows
            {
                AuthorizationCode = new OpenApiOAuthFlow
                {
                    AuthorizationUrl = new Uri($"{authority}/oauth2/authorize"),
                    TokenUrl = new Uri($"{authority}/oauth2/token"),
                    Scopes = new Dictionary<string, string>
                    {
                        ["api://my-api/read"]  = "Read your data",
                        ["api://my-api/write"] = "Write your data"
                    }
                }
            }
        };

        document.SecurityRequirements.Add(new OpenApiSecurityRequirement
        {
            [new OpenApiSecurityScheme
            {
                Reference = new OpenApiReference
                {
                    Type = ReferenceType.SecurityScheme,
                    Id = "oauth2"
                }
            }] = ["api://my-api/read", "api://my-api/write"]
        });

        return Task.CompletedTask;
    }
}

The OpenAPI document side is done. Swagger UI also needs to know who it is to the IdP, otherwise the redirect from the authorize endpoint will fail with invalid_client:

app.UseSwaggerUI(c =>
{
    c.SwaggerEndpoint("/openapi/v1.json", "API v1");

    c.OAuthClientId("swagger-ui");        // public client registered with the IdP
    c.OAuthUsePkce();                     // mandatory for public clients
    c.OAuthScopes("api://my-api/read");
    c.OAuthAppName("Swagger UI for My API");
});

Two registrations on the IdP side that catch people out:

OpenID Connect via discovery

If your IdP exposes a discovery document, prefer openIdConnect over hard-coding URLs. Swagger UI 7.x reads the discovery document and figures the rest out:

// .NET 11, C# 14
internal sealed class OidcSecuritySchemeTransformer(IConfiguration config)
    : IOpenApiDocumentTransformer
{
    public Task TransformAsync(
        OpenApiDocument document,
        OpenApiDocumentTransformerContext context,
        CancellationToken ct)
    {
        var authority = config["Auth:Authority"]!.TrimEnd('/');

        document.Components ??= new OpenApiComponents();
        document.Components.SecuritySchemes["oidc"] = new OpenApiSecurityScheme
        {
            Type = SecuritySchemeType.OpenIdConnect,
            OpenIdConnectUrl = new Uri($"{authority}/.well-known/openid-configuration")
        };

        document.SecurityRequirements.Add(new OpenApiSecurityRequirement
        {
            [new OpenApiSecurityScheme
            {
                Reference = new OpenApiReference
                {
                    Type = ReferenceType.SecurityScheme,
                    Id = "oidc"
                }
            }] = ["openid", "profile", "api://my-api/read"]
        });

        return Task.CompletedTask;
    }
}

The openIdConnect scheme has been valid OpenAPI 3.x since 3.0.1 and gives Swagger UI a single source of truth for authorization_endpoint, token_endpoint, and scopes_supported. Practically, this is the cleanest configuration when running against Microsoft Entra ID, Auth0, Keycloak, or anything else that exposes /.well-known/openid-configuration. You still need OAuthClientId and OAuthUsePkce on the Swagger UI side; the discovery document only covers the server side of the contract.

Multiple schemes and per-operation requirements

Real APIs usually have a mix: a couple of endpoints accept an API key, the rest require OAuth, the health probe is anonymous. Drop the global SecurityRequirements.Add(...) call from the document transformer and apply requirements per operation instead.

Add an operation transformer that reads metadata from the endpoint:

// .NET 11, C# 14
using Microsoft.AspNetCore.Authorization;

internal sealed class SecurityRequirementOperationTransformer
    : IOpenApiOperationTransformer
{
    public Task TransformAsync(
        OpenApiOperation operation,
        OpenApiOperationTransformerContext context,
        CancellationToken ct)
    {
        var endpoint = context.Description.ActionDescriptor.EndpointMetadata;
        var hasAuth   = endpoint.OfType<IAuthorizeData>().Any();
        var anonymous = endpoint.OfType<IAllowAnonymous>().Any();

        if (!hasAuth || anonymous) return Task.CompletedTask;

        var schemeId = endpoint
            .OfType<AuthorizeAttribute>()
            .Select(a => a.AuthenticationSchemes)
            .FirstOrDefault(s => !string.IsNullOrEmpty(s)) ?? "oauth2";

        operation.Security.Add(new OpenApiSecurityRequirement
        {
            [new OpenApiSecurityScheme
            {
                Reference = new OpenApiReference
                {
                    Type = ReferenceType.SecurityScheme,
                    Id = schemeId
                }
            }] = []
        });

        return Task.CompletedTask;
    }
}

Register both transformers next to each other:

builder.Services.AddOpenApi(o =>
{
    o.AddDocumentTransformer<OAuth2SecuritySchemeTransformer>();
    o.AddDocumentTransformer<ApiKeySecuritySchemeTransformer>();
    o.AddOperationTransformer<SecurityRequirementOperationTransformer>();
});

Now [Authorize] paints a padlock on the operation, [AllowAnonymous] skips it, and [Authorize(AuthenticationSchemes = "ApiKey")] paints a padlock for the right scheme. The OpenAPI document is back to the way Swashbuckle’s old AddSecurityRequirement overload worked, but with no OperationFilter to maintain.

Gotchas that bite in production

A few things never get mentioned in the official docs but show up in every triage:

document.Components can be null. On a fresh OpenApiDocument, Components is null until something assigns to it. The defensive document.Components ??= new OpenApiComponents(); line in every transformer above is not optional. The serializer does not write components.securitySchemes if the section is missing, and Swagger UI silently ignores the requirement reference because the scheme it points to does not exist.

Reference.Id must exactly match the dictionary key. If you registered the scheme as "Bearer" but the requirement uses "bearer", OpenAPI 3.x considers it an unresolved $ref and Swagger UI shows the lock icon but never sends the header. Pick one casing per app and stick to it.

Persisted authorization is off by default. Every page reload wipes the token. For developer ergonomics enable c.EnablePersistAuthorization(). The token is stored in localStorage, so do not turn this on in a production deployment.

OAuth redirect URL with non-root path bases. When the app runs behind a reverse proxy at /api, Swagger UI builds the redirect as /api/swagger/oauth2-redirect.html. The IdP registration must include that exact path or the callback fails with redirect_uri_mismatch. Check Forwarded headers and UsePathBase if the redirect looks wrong.

Native AOT. As of .NET 11, the new OpenAPI generator is not annotated trim-safe for arbitrary transformers, and Swashbuckle.AspNetCore.SwaggerUI’s static file serving does work under AOT but the transformers should avoid reflection over closed generics. If you hit RequiresUnreferencedCode warnings, see the Native AOT minimal API guide for the pattern.

Operation requirements append, they do not replace. If the document has a global SecurityRequirements and the operation transformer adds one, both are evaluated as alternatives (OR semantics in OpenAPI). For a public endpoint, you must explicitly clear operation.Security rather than just leaving the operation transformer alone.

Wiring SwaggerUI with multiple documents

If you version your API and emit one OpenAPI document per version, Swagger UI’s dropdown needs each endpoint:

app.MapOpenApi("/openapi/{documentName}.json");

app.UseSwaggerUI(c =>
{
    c.SwaggerEndpoint("/openapi/v1.json", "API v1");
    c.SwaggerEndpoint("/openapi/v2.json", "API v2");

    c.OAuthClientId("swagger-ui");
    c.OAuthUsePkce();
});

Each document carries its own securitySchemes, so a transformer that runs per document gets called once per version. Good news: that means no shared state to chase. Bad news: if you forget to register the transformer for the v2 document, only v1 has the padlock. The pattern fits cleanly with Asp.Versioning 10.0’s WithDocumentPerVersion() (covered in the API versioning post).

Sources

Comments

Sign in with GitHub to comment. Reactions and replies thread back to the comments repo.

< Back