TL;DR
- An OpenAPI security scheme of type
httpwith schemebeareradds the Authorize button; paste the raw token and Swagger UI sendsAuthorization: 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.ApiWeb 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/loginthat returns{ "accessToken": "...", "expiresAt": "..." }. The authentication scheme is the defaultJwtBearerDefaults.AuthenticationScheme, which is"Bearer". - Pick one path from this table.
| Your project | Path | Steps |
|---|---|---|
.NET 8 or 9 that already uses Swashbuckle, or references Microsoft.AspNetCore.OpenApi 8.x or 9.x | A: Swashbuckle 9.x, pinned | 1, 3, 4, 7 |
| .NET 10, or a project with no other OpenAPI package | A2: Swashbuckle 10 | 2, 3, 4, 7 |
.NET 9 or 10 that uses the built-in AddOpenApi() | B: document transformer | 4, 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.
| |
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
- The OpenAPI document gets a
components.securitySchemesentry namedBearerof typehttpwith schemebearer. Swagger UI and Scalar read it and show an Authorize control. - You paste the token. The UI keeps it in memory, or in browser storage when persistence is on.
- Before each call, the UI checks the operation’s
securitylist. Only operations that referenceBearerget the header. - That reference comes either from a global requirement (every operation) or from a per-operation filter that reads
[Authorize]and[AllowAnonymous]metadata. - The JWT bearer handler validates the header exactly as it does for any other client.
| |
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
| |
Type = SecuritySchemeType.HttpwithScheme = "bearer"tells the UI to build the header itself asBearer <value>. That is why the description says to paste the token alone.InandNameare ignored for theHttptype (the Microsoft.OpenApi serializer drops them from the JSON). They matter only forSecuritySchemeType.ApiKey, where the UI sends the pasted value unchanged.AddSecurityRequirementwith anOpenApiReferenceapplies 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.
| |
- The types moved from
Microsoft.OpenApi.ModelstoMicrosoft.OpenApi. Old tutorials fail to compile on Swashbuckle 10 for this reason alone. AddSecurityRequirementnow takes aFunc<OpenApiDocument, OpenApiSecurityRequirement>, as the Swashbuckle migration guide describes.OpenApiSecuritySchemeReferencereplaces theReferenceproperty. Passingdocumentlets 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.
| |
Then, in SwaggerServiceExtensions, replace the whole options.AddSecurityRequirement(...) call with:
| |
EndpointMetadatacontains attributes from both the controller and the action, so a class-level[Authorize]with one[AllowAnonymous]action is handled correctly.- Checking the
IAuthorizeDataandIAllowAnonymousinterfaces also catches[Authorize(Roles = "Admin")]and custom attributes that implement them. TryAddavoids 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:
| |
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
| |
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).
| |
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
| |
- 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.Documentexists only on .NET 10. On .NET 9, useusing Microsoft.OpenApi.Models;, initialize withnew List<OpenApiSecurityRequirement>(), and usenew OpenApiSecurityScheme { Reference = new OpenApiReference { Type = ReferenceType.SecurityScheme, Id = "Bearer" } }as the key withArray.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():
| |
Path A or A2, in the pipeline after builder.Build():
| |
Path B, before builder.Build():
| |
Path B, after builder.Build(), with Scalar at /scalar or Swagger UI at /swagger:
| |
IsDevelopment()keeps the document and both UIs out of production. A browser that opens/swaggercannot 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’sEnablePersistentAuthentication()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
- Run the API and open
https://localhost:7001/swagger(or/scalar). - Expand
POST /api/auth/login, click Try it out, send your test user’s credentials, and copyaccessTokenfrom the response. - Click Authorize, paste the token without any prefix, click Authorize, then Close.
- Run
GET /api/me. The request shows-H 'Authorization: Bearer eyJ...'and returns 200.
The same flow as an .http file:
| |
Expected results:
| |
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 anHttpscheme. The UI sendsBearer Bearer eyJ...and the API returns 401invalid_token. Paste the token alone, or switch toApiKeyand type the prefix yourself (not recommended). - Copying a Swashbuckle 6 sample into a Swashbuckle 10 project.
Microsoft.OpenApi.Modelsno 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.OpenApi9.x builds fine, then every document request returns 500 withTypeLoadException: 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 emptysecurityobject, 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
AuthorizeFilteradds no endpoint metadata, so the filter shows no locks. Invert the check to lock everything withoutIAllowAnonymous.
Related guides
- Issuing and Validating JWT Access Tokens in ASP.NET Core
- Refresh Tokens in ASP.NET Core: Rotation, Reuse Detection, and Logout
- Role-Based and Policy-Based Authorization in ASP.NET Core
- API Key Authentication for Service-to-Service Calls
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.
