TL;DR

  • An OpenAPI security scheme of type http with scheme bearer adds the Authorize button; paste the raw token and Swagger UI sends Authorization: Bearer <token>.
  • An operation filter (or transformer) reads [Authorize] and [AllowAnonymous], so only protected endpoints get a lock and documented 401/403 responses.
  • Working code for Swashbuckle 9, Swashbuckle 10 (Microsoft.OpenApi 2.x), and the built-in .NET 9/10 OpenAPI with Scalar, plus the version mix that turns every document request into a 500.

Level: Beginner · Prerequisites: An ASP.NET Core Web API with JWT bearer authentication (Issuing and Validating JWT Access Tokens in ASP.NET Core) · Reading time: 11 min

Your API issues JSON Web Tokens (JWT), and every endpoint with [Authorize] returns 401 when you try it from Swagger UI. The UI is not broken. It simply does not know that your API expects an Authorization: Bearer <token> header, because nothing in the OpenAPI document says so.

In this guide you describe the bearer scheme in the OpenAPI document, mark only the protected operations with a lock, and keep the documentation UI out of production. You get the same result on Swashbuckle 6 to 9, on Swashbuckle 10, and on the built-in Microsoft.AspNetCore.OpenApi package of .NET 9 and 10.

What you will build

  • An Authorize button in Swagger UI that takes a raw access token and sends Authorization: Bearer <token> on protected calls.
  • A lock icon only on operations that carry [Authorize], with 401 and 403 responses documented. [AllowAnonymous] operations stay open.
  • Working code for Swashbuckle 6 to 9, for Swashbuckle 10 (Microsoft.OpenApi 2.x), and for the built-in OpenAPI document with Scalar or Swagger UI.
  • A token that survives a page reload while you develop.
  • Documentation endpoints that exist only in the Development environment.

Before you start

  • A Store.Api Web API with controllers on .NET 8, 9, or 10.
  • JWT bearer authentication from Issuing and Validating JWT Access Tokens in ASP.NET Core, including an anonymous POST /api/auth/login that returns { "accessToken": "...", "expiresAt": "..." }. The authentication scheme is the default JwtBearerDefaults.AuthenticationScheme, which is "Bearer".
  • Pick one path from this table.
Your projectPathSteps
.NET 8 or 9 that already uses Swashbuckle, or references Microsoft.AspNetCore.OpenApi 8.x or 9.xA: Swashbuckle 9.x, pinned1, 3, 4, 7
.NET 10, or a project with no other OpenAPI packageA2: Swashbuckle 102, 3, 4, 7
.NET 9 or 10 that uses the built-in AddOpenApi()B: document transformer4, 5, 6, 7

Why pin? Swashbuckle 10 moved to Microsoft.OpenApi 2.x, which renamed namespaces and replaced how references work. Microsoft.AspNetCore.OpenApi 8.x and 9.x depend on Microsoft.OpenApi 1.x, while 10.x depends on 2.x. A project can load only one Microsoft.OpenApi version, so the two packages must agree, or the build or the first document request fails. The .NET 9 and .NET 10 webapi templates both add Microsoft.AspNetCore.OpenApi by default.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
# Path A: Swashbuckle 6 to 9 (pin the last 9.x release)
dotnet add package Swashbuckle.AspNetCore --version 9.0.6

# Path A2: Swashbuckle 10 (Microsoft.OpenApi 2.x)
dotnet add package Swashbuckle.AspNetCore

# Path B: built-in OpenAPI (.NET 9+) plus one UI
dotnet add package Microsoft.AspNetCore.OpenApi
dotnet add package Scalar.AspNetCore
dotnet add package Swashbuckle.AspNetCore.SwaggerUI

Swashbuckle.AspNetCore.SwaggerUI only serves the UI files and has no Microsoft.OpenApi dependency, so it is safe next to any version of the built-in package.

How it works

  1. The OpenAPI document gets a components.securitySchemes entry named Bearer of type http with scheme bearer. Swagger UI and Scalar read it and show an Authorize control.
  2. You paste the token. The UI keeps it in memory, or in browser storage when persistence is on.
  3. Before each call, the UI checks the operation’s security list. Only operations that reference Bearer get the header.
  4. That reference comes either from a global requirement (every operation) or from a per-operation filter that reads [Authorize] and [AllowAnonymous] metadata.
  5. The JWT bearer handler validates the header exactly as it does for any other client.
1
2
3
4
OpenAPI document
  components.securitySchemes.Bearer  -> Authorize button
  paths./api/me.get.security: Bearer -> lock icon, header sent
  paths./api/me/ping.get             -> no lock, no header

The document only tells the UI what to send. It never protects anything. [Authorize] and the authentication middleware still do that.

Step 1: Describe the bearer scheme with Swashbuckle 6 to 9

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
// File: OpenApi/SwaggerServiceExtensions.cs
using Microsoft.OpenApi.Models;

namespace Store.Api.OpenApi;

public static class SwaggerServiceExtensions
{
    public const string BearerSchemeId = "Bearer";

    public static IServiceCollection AddSwaggerWithJwt(this IServiceCollection services)
    {
        services.AddEndpointsApiExplorer();
        services.AddSwaggerGen(options =>
        {
            options.SwaggerDoc("v1", new OpenApiInfo { Title = "Store API", Version = "v1" });

            options.AddSecurityDefinition(BearerSchemeId, new OpenApiSecurityScheme
            {
                Type = SecuritySchemeType.Http,
                Scheme = "bearer",
                BearerFormat = "JWT",
                In = ParameterLocation.Header,
                Name = "Authorization",
                Description = "Paste the access token only. Swagger UI adds the 'Bearer ' prefix."
            });

            options.AddSecurityRequirement(new OpenApiSecurityRequirement
            {
                [new OpenApiSecurityScheme
                {
                    Reference = new OpenApiReference
                    {
                        Type = ReferenceType.SecurityScheme,
                        Id = BearerSchemeId
                    }
                }] = Array.Empty<string>()
            });
        });

        return services;
    }
}
  • Type = SecuritySchemeType.Http with Scheme = "bearer" tells the UI to build the header itself as Bearer <value>. That is why the description says to paste the token alone.
  • In and Name are ignored for the Http type (the Microsoft.OpenApi serializer drops them from the JSON). They matter only for SecuritySchemeType.ApiKey, where the UI sends the pasted value unchanged.
  • AddSecurityRequirement with an OpenApiReference applies the scheme to every operation. It is the quickest way to get a working button. Step 3 replaces it with a per-operation version.
  • The empty array is the list of scopes. Only OAuth2 schemes use scopes.

Step 2: Use the Swashbuckle 10 syntax (Microsoft.OpenApi 2.x)

Skip this step on Path A. On Path A2, use this version of the same file.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
// File: OpenApi/SwaggerServiceExtensions.cs
using Microsoft.OpenApi;

namespace Store.Api.OpenApi;

public static class SwaggerServiceExtensions
{
    public const string BearerSchemeId = "Bearer";

    public static IServiceCollection AddSwaggerWithJwt(this IServiceCollection services)
    {
        services.AddEndpointsApiExplorer();
        services.AddSwaggerGen(options =>
        {
            options.SwaggerDoc("v1", new OpenApiInfo { Title = "Store API", Version = "v1" });

            options.AddSecurityDefinition(BearerSchemeId, new OpenApiSecurityScheme
            {
                Type = SecuritySchemeType.Http,
                Scheme = "bearer",
                BearerFormat = "JWT",
                Description = "Paste the access token only. Swagger UI adds the 'Bearer ' prefix."
            });

            options.AddSecurityRequirement(document => new OpenApiSecurityRequirement
            {
                [new OpenApiSecuritySchemeReference(BearerSchemeId, document)] = []
            });
        });

        return services;
    }
}
  • The types moved from Microsoft.OpenApi.Models to Microsoft.OpenApi. Old tutorials fail to compile on Swashbuckle 10 for this reason alone.
  • AddSecurityRequirement now takes a Func<OpenApiDocument, OpenApiSecurityRequirement>, as the Swashbuckle migration guide describes.
  • OpenApiSecuritySchemeReference replaces the Reference property. Passing document lets the reference resolve to the scheme you defined. This matches the bearer sample in the Swashbuckle documentation.

Step 3: Show the lock only on protected endpoints

A global requirement puts a lock on POST /api/auth/login too, which is misleading. This filter adds the requirement only where authorization metadata says so.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
// File: OpenApi/AuthorizeOperationFilter.cs
using Microsoft.AspNetCore.Authorization;
using Microsoft.OpenApi.Models;
using Swashbuckle.AspNetCore.SwaggerGen;

namespace Store.Api.OpenApi;

public sealed class AuthorizeOperationFilter : IOperationFilter
{
    public void Apply(OpenApiOperation operation, OperationFilterContext context)
    {
        var metadata = context.ApiDescription.ActionDescriptor.EndpointMetadata;

        var requiresAuth = metadata.OfType<IAuthorizeData>().Any();
        var allowsAnonymous = metadata.OfType<IAllowAnonymous>().Any();

        if (!requiresAuth || allowsAnonymous)
        {
            return;
        }

        operation.Responses.TryAdd("401", new OpenApiResponse { Description = "Unauthorized" });
        operation.Responses.TryAdd("403", new OpenApiResponse { Description = "Forbidden" });

        operation.Security = new List<OpenApiSecurityRequirement>
        {
            new()
            {
                [new OpenApiSecurityScheme
                {
                    Reference = new OpenApiReference
                    {
                        Type = ReferenceType.SecurityScheme,
                        Id = SwaggerServiceExtensions.BearerSchemeId
                    }
                }] = Array.Empty<string>()
            }
        };
    }
}

Then, in SwaggerServiceExtensions, replace the whole options.AddSecurityRequirement(...) call with:

1
2
// File: OpenApi/SwaggerServiceExtensions.cs (inside AddSwaggerGen)
options.OperationFilter<AuthorizeOperationFilter>();
  • EndpointMetadata contains attributes from both the controller and the action, so a class-level [Authorize] with one [AllowAnonymous] action is handled correctly.
  • Checking the IAuthorizeData and IAllowAnonymous interfaces also catches [Authorize(Roles = "Admin")] and custom attributes that implement them.
  • TryAdd avoids a duplicate key exception when an action already declares [ProducesResponseType(401)].

On Swashbuckle 10, change the using to Microsoft.OpenApi and replace the last two statements with:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
// File: OpenApi/AuthorizeOperationFilter.cs (Swashbuckle 10 version of the end of Apply)
operation.Responses ??= new OpenApiResponses();
operation.Responses.TryAdd("401", new OpenApiResponse { Description = "Unauthorized" });
operation.Responses.TryAdd("403", new OpenApiResponse { Description = "Forbidden" });

operation.Security =
[
    new OpenApiSecurityRequirement
    {
        [new OpenApiSecuritySchemeReference(SwaggerServiceExtensions.BearerSchemeId, context.Document)] = []
    }
];

context.Document is the document being generated, which plays the same role as the document argument in Step 2.

Step 4: Add a protected endpoint to test with

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// File: Auth/MeController.cs
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;

namespace Store.Api.Auth;

[ApiController]
[Route("api/me")]
[Authorize]
public sealed class MeController : ControllerBase
{
    [HttpGet]
    public IActionResult Get() => Ok(new
    {
        userId = User.FindFirst("sub")?.Value,
        email = User.FindFirst("email")?.Value,
        roles = User.FindAll("role").Select(c => c.Value)
    });

    [HttpGet("ping")]
    [AllowAnonymous]
    public IActionResult Ping() => Ok(new { status = "ok" });
}

This controller gives you one locked and one open operation. The AuthController from Issuing and Validating JWT Access Tokens in ASP.NET Core already has such a pair (GET /api/auth/me and POST /api/auth/login), so skip this step if you prefer to test with those. The claim names sub, email, and role match the series convention (MapInboundClaims = false).

Step 5: Add the bearer scheme to the built-in document (Path B)

On .NET 9 and later, AddOpenApi() generates the document and transformers let you edit it. This is the .NET 10 version (Microsoft.OpenApi 2.x).

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
// File: OpenApi/BearerSecuritySchemeTransformer.cs
using Microsoft.AspNetCore.OpenApi;
using Microsoft.OpenApi;

namespace Store.Api.OpenApi;

public sealed class BearerSecuritySchemeTransformer : IOpenApiDocumentTransformer
{
    public Task TransformAsync(OpenApiDocument document, OpenApiDocumentTransformerContext context, CancellationToken cancellationToken)
    {
        document.Components ??= new OpenApiComponents();
        document.Components.SecuritySchemes ??= new Dictionary<string, IOpenApiSecurityScheme>();
        document.Components.SecuritySchemes["Bearer"] = new OpenApiSecurityScheme
        {
            Type = SecuritySchemeType.Http,
            Scheme = "bearer",
            BearerFormat = "JWT",
            Description = "Paste the access token only."
        };
        return Task.CompletedTask;
    }
}

On .NET 9 (Microsoft.OpenApi 1.x), use using Microsoft.OpenApi.Models; and delete the SecuritySchemes ??= line, because that dictionary is already created and typed IDictionary<string, OpenApiSecurityScheme>.

Step 6: Mark protected operations in the built-in document

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// File: OpenApi/AuthorizeOperationTransformer.cs
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.OpenApi;
using Microsoft.OpenApi;

namespace Store.Api.OpenApi;

public sealed class AuthorizeOperationTransformer : IOpenApiOperationTransformer
{
    public Task TransformAsync(OpenApiOperation operation, OpenApiOperationTransformerContext context, CancellationToken cancellationToken)
    {
        var metadata = context.Description.ActionDescriptor.EndpointMetadata;
        if (!metadata.OfType<IAuthorizeData>().Any() || metadata.OfType<IAllowAnonymous>().Any())
        {
            return Task.CompletedTask;
        }

        operation.Security ??= [];
        operation.Security.Add(new OpenApiSecurityRequirement
        {
            [new OpenApiSecuritySchemeReference("Bearer", context.Document)] = []
        });
        return Task.CompletedTask;
    }
}
  • Operation transformers run before document transformers, so the scheme does not exist yet when this code runs. ASP.NET Core 10 registers the document’s components after all transformers finish, so the reference resolves when the JSON is written.
  • context.Document exists only on .NET 10. On .NET 9, use using Microsoft.OpenApi.Models;, initialize with new List<OpenApiSecurityRequirement>(), and use new OpenApiSecurityScheme { Reference = new OpenApiReference { Type = ReferenceType.SecurityScheme, Id = "Bearer" } } as the key with Array.Empty<string>().

Step 7: Register everything in Program.cs

Add using Store.Api.OpenApi; at the top of Program.cs (plus using Scalar.AspNetCore; if you choose Scalar). Then, for Path A or A2, before builder.Build():

1
2
// File: Program.cs (before builder.Build())
builder.Services.AddSwaggerWithJwt();

Path A or A2, in the pipeline after builder.Build():

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
// File: Program.cs (after builder.Build(), before app.MapControllers())
if (app.Environment.IsDevelopment())
{
    app.UseSwagger();
    app.UseSwaggerUI(options =>
    {
        options.SwaggerEndpoint("/swagger/v1/swagger.json", "Store API v1");
        options.EnablePersistAuthorization();
    });
}

Path B, before builder.Build():

1
2
3
4
5
6
// File: Program.cs (before builder.Build())
builder.Services.AddOpenApi(options =>
{
    options.AddDocumentTransformer<BearerSecuritySchemeTransformer>();
    options.AddOperationTransformer<AuthorizeOperationTransformer>();
});

Path B, after builder.Build(), with Scalar at /scalar or Swagger UI at /swagger:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
// File: Program.cs (after builder.Build(), before app.MapControllers())
if (app.Environment.IsDevelopment())
{
    app.MapOpenApi();
    app.MapScalarApiReference(options => options
        .AddPreferredSecuritySchemes("Bearer")
        .EnablePersistentAuthentication());

    app.UseSwaggerUI(options =>
    {
        options.SwaggerEndpoint("/openapi/v1.json", "Store API v1");
        options.EnablePersistAuthorization();
    });
}
  • IsDevelopment() keeps the document and both UIs out of production. A browser that opens /swagger cannot attach your JWT, so if you must expose docs in staging, protect them at the network level (VPN or an IP allow list on the reverse proxy).
  • EnablePersistAuthorization() (Swashbuckle 6.2 and later) and Scalar’s EnablePersistentAuthentication() store the token in browser storage so a reload keeps it. Keep them out of shared environments.
  • Keep only the UI you want. Both are shown so you can compare.

Try it

  1. Run the API and open https://localhost:7001/swagger (or /scalar).
  2. Expand POST /api/auth/login, click Try it out, send your test user’s credentials, and copy accessToken from the response.
  3. Click Authorize, paste the token without any prefix, click Authorize, then Close.
  4. Run GET /api/me. The request shows -H 'Authorization: Bearer eyJ...' and returns 200.

The same flow as an .http file:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
@accessToken = paste-the-token-here

### Open endpoint: no lock, no token needed
GET https://localhost:7001/api/me/ping

### Protected endpoint without a token: expect 401
GET https://localhost:7001/api/me

### Protected endpoint with the token: expect 200
GET https://localhost:7001/api/me
Authorization: Bearer {{accessToken}}

### The double prefix mistake: expect 401
GET https://localhost:7001/api/me
Authorization: Bearer Bearer {{accessToken}}

Expected results:

1
2
3
4
GET /api/me/ping                 -> 200 {"status":"ok"}
GET /api/me (no header)          -> 401, WWW-Authenticate: Bearer
GET /api/me (token)              -> 200 {"userId":"...","email":"ada@example.com","roles":["Customer"]}
GET /api/me ("Bearer Bearer ...") -> 401, WWW-Authenticate: Bearer error="invalid_token"

Then open the raw document (/swagger/v1/swagger.json or /openapi/v1.json). /api/me has "security": [ { "Bearer": [ ] } ] and /api/me/ping has no security key.

Production checklist

  • Map the OpenAPI document and UI only in Development, or protect them at the network edge.
  • Never put a real token in a scheme description or in Scalar’s prefilled authentication.
  • Keep persisted authorization out of shared environments, because any script on that origin can read browser storage.
  • Keep exactly one Microsoft.OpenApi major version in the project, and pin the Swashbuckle major in the .csproj.
  • Read the Swashbuckle release notes before you move from 9.x to 10.x, since your filters must change too.
  • Make the lock match reality: if you add a fallback authorization policy, update the filter so operations without [AllowAnonymous] get the lock.
  • Keep access tokens short-lived (the series uses 15 minutes) and re-authorize in the UI when calls start returning 401.

Common pitfalls

  • Pasting Bearer eyJ... into an Http scheme. The UI sends Bearer Bearer eyJ... and the API returns 401 invalid_token. Paste the token alone, or switch to ApiKey and type the prefix yourself (not recommended).
  • Copying a Swashbuckle 6 sample into a Swashbuckle 10 project. Microsoft.OpenApi.Models no longer exists. Use Step 2, or pin 9.0.6 with --version.
  • Mixing Microsoft.OpenApi 1.x and 2.x. Swashbuckle 10 next to Microsoft.AspNetCore.OpenApi 9.x builds fine, then every document request returns 500 with TypeLoadException: Could not load type 'Microsoft.OpenApi.Models.OpenApiDocument'. Align the versions as the table shows.
  • A scheme with no requirement. The Authorize button appears, but no operation references the scheme, so the UI never sends the header. Add Step 1’s requirement or Step 3’s filter.
  • Mismatched scheme ids. AddSecurityDefinition("Bearer", ...) with a reference to "bearer" does not resolve. Microsoft.OpenApi 2.x writes an empty security object, and 1.x points at a scheme that does not exist. Either way, no header is sent. Use one constant.
  • Global authorization the filter cannot see. A fallback policy or a global AuthorizeFilter adds no endpoint metadata, so the filter shows no locks. Invert the check to lock everything without IAllowAnonymous.

Part of The .NET Implementation Playbook. Previous: Issuing and Validating JWT Access Tokens in ASP.NET Core. Next in the series: CQRS Without MediatR: Commands, Queries, and a Small Dispatcher (coming soon).

Get the next articles by email: subscribe to the newsletter.

How this article was made: drafted with AI assistance, then reviewed by Softinbit Technology before publishing.