Edit:Updated after a sharp comment from a reader — the original version used reflection over attributes, which misses RequireAuthorization() on minimal APIs and fallback policies, and collapsed multi-scheme requirements incorrectly. Fixed below.
If you've wired up Swashbuckle with AddSecurityDefinition and AddSecurityRequirement following the standard tutorial, you've probably hit this: every single endpoint in Swagger UI shows the padlock icon and demands the same auth scheme - even endpoints that don't need auth at all, or that need a different scheme entirely. This gets worse fast once you're running more than one JWT scheme in the same API.
Why this happens
The common tutorial snippet looks like this:
builder.Services.AddSwaggerGen(options =>
{
options.AddSecurityDefinition("Bearer", new OpenApiSecurityScheme
{
Name = "Authorization",
Type = SecuritySchemeType.Http,
Scheme = "Bearer",
BearerFormat = "JWT",
In = ParameterLocation.Header,
});
options.AddSecurityRequirement(new OpenApiSecurityRequirement
{
{
new OpenApiSecurityScheme
{
Reference = new OpenApiReference
{
Type = ReferenceType.SecurityScheme,
Id = "Bearer"
}
},
Array.Empty<string>()
}
});
});
AddSecurityRequirement at this level applies globally - to every operation in the generated spec, regardless of whether the underlying endpoint actually requires that scheme, or requires a different one, or requires no auth at all. Swashbuckle has no way to know your intent unless you tell it per-operation.
The first attempt (and why it wasn't enough)
My first fix was an IOperationFilter that reflected over AuthorizeAttribute and AllowAnonymousAttribute on the controller/action. That works fine for typical MVC controllers — but a reader pointed out two real gaps:
Minimal APIs and fallback policies don't show up via reflection. RequireAuthorization() on a minimal API endpoint, or a global AuthorizationOptions.FallbackPolicy, protect the endpoint at runtime without any AuthorizeAttribute being declared. Reflection-based detection reports these as anonymous, which is wrong.
Multi-scheme requirements were being collapsed incorrectly. In OpenAPI, schemes inside one requirement object mean AND (all required together); separate requirement objects mean OR (any one satisfies it). The original code took schemeNames.First(), silently dropping any additional required schemes from the spec.
The fix: read runtime metadata, not attributes
The more reliable source is ApiDescription.ActionDescriptor.EndpointMetadata, which reflects what's actually enforced at runtime — including minimal API policies and fallback policies — not just what's declared via attributes:
public class SecurityRequirementsOperationFilter : IOperationFilter
{
public void Apply(OpenApiOperation operation, OperationFilterContext context)
{
var endpointMetadata = context.ApiDescription.ActionDescriptor.EndpointMetadata;
var authorizeData = endpointMetadata.OfType<IAuthorizeData>().ToList();
var hasAnonymous = endpointMetadata.OfType<IAllowAnonymous>().Any();
if (hasAnonymous)
{
return; // explicitly anonymous, no padlock
}
if (!authorizeData.Any())
{
// No explicit [Authorize] - check if a fallback policy applies
// (inject IAuthorizationPolicyProvider or pass AuthorizationOptions
// in to resolve FallbackPolicy here if one is configured)
return;
}
// Group scheme names per AuthorizeData entry - each entry's schemes
// are its own AND group; multiple entries are OR'd as separate
// requirement objects, matching OpenAPI semantics.
var requirementGroups = authorizeData
.Select(a => (a.AuthenticationSchemes ?? "Bearer")
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
.Where(schemes => schemes.Length > 0)
.Distinct(new StringArrayComparer())
.ToList();
if (!requirementGroups.Any())
{
requirementGroups.Add(new[] { "Bearer" });
}
operation.Security = requirementGroups
.Select(schemes =>
{
var requirement = new OpenApiSecurityRequirement();
foreach (var scheme in schemes)
{
requirement[new OpenApiSecurityScheme
{
Reference = new OpenApiReference
{
Type = ReferenceType.SecurityScheme,
Id = scheme
}
}] = Array.Empty<string>();
}
return requirement;
})
.ToList();
}
private class StringArrayComparer : IEqualityComparer<string[]>
{
public bool Equals(string[]? x, string[]? y) =>
x != null && y != null && x.OrderBy(s => s).SequenceEqual(y.OrderBy(s => s));
public int GetHashCode(string[] obj) =>
obj.OrderBy(s => s).Aggregate(17, (hash, s) => hash * 31 + s.GetHashCode());
}
}
Register it the same way as before:
builder.Services.AddSwaggerGen(options =>
{
// ... AddSecurityDefinition calls for each scheme (e.g. "MainScheme", "UserTokenScheme")
options.OperationFilter<SecurityRequirementsOperationFilter>();
});
Now operation.Security correctly represents:
Anonymous endpoints ([AllowAnonymous] or no auth metadata at all) → no padlock
Single-scheme endpoints → one requirement object, one scheme
Multi-scheme AND ([Authorize(AuthenticationSchemes = "A,B")]) → one requirement object containing both schemes
Multi-scheme OR (stacked [Authorize] attributes with different schemes) → separate requirement objects, one per attribute
This is closer to the actual OpenAPI spec semantics, not just a padlock that happens to look right for the common case.
Fallback policies still need explicit handling
If you use a global FallbackPolicy (protects any endpoint without explicit authorization metadata), the filter above deliberately does nothing for those endpoints rather than guessing — you'll want to resolve the policy's scheme requirements yourself (via IAuthorizationPolicyProvider) and apply them the same way as an explicit [Authorize]. I've left this as a TODO above rather than baking in an assumption about your policy setup, since fallback policies vary a lot between projects.
Why this matters more with multiple schemes
If you're only running a single JWT scheme, the blanket AddSecurityRequirement approach is merely imprecise — annoying, but not actively misleading. Once you're running two schemes side by side (see my earlier post on dual JWT Bearer schemes), the global approach becomes actively wrong: it tells every consumer of your Swagger UI to authenticate with the wrong token on some endpoints, or hides a required second token entirely, which is exactly the kind of thing that sends other developers down the wrong debugging path.
Checklist
Does Swagger UI show a padlock only on endpoints that actually require auth?
If you have [AllowAnonymous] endpoints, do they correctly show no lock?
If you're running multiple schemes, does each protected endpoint show the correct scheme(s) — not just "a" scheme?
Do multi-scheme AND requirements show as one requirement object with both schemes, not just the first one?
Do you have any minimal API endpoints or a fallback policy, and if so, are they handled explicitly rather than assumed anonymous?
Have you tested the Authorize button end-to-end — does the token you enter actually get attached to requests for the endpoint you're testing?
This is a small amount of extra setup for a much more honest Swagger UI — and it stops other developers (or future you) from trusting a spec that's quietly lying about what each endpoint needs.
Top comments (2)
One edge case is authorization that isn’t expressed as controller/action attributes:
RequireAuthorization()on minimal APIs and aFallbackPolicycan protect an endpoint while reflection over attributes says it is anonymous. Readingcontext.ApiDescription.ActionDescriptor.EndpointMetadatais closer to the runtime contract, and I’d add generated-spec tests for anonymous, default-scheme, and alternate-scheme endpoints. Multiple schemes also need care: schemes inside one OpenAPI requirement object mean AND, while separate objects mean OR, so taking onlyFirst()can silently misdocument the contract. How would you represent fallback-policy endpoints?You're right on the metadata point - reflecting on
AuthorizeAttribute/AllowAnonymousAttributeonly tells you what's declared imperatively. Minimal APIs usingRequireAuthorization()or a globalFallbackPolicywon't show up via reflection at all, so the filter would wrongly report them as anonymous.context.ApiDescription.ActionDescriptor.EndpointMetadatais the better source since it reflects what's actually enforced at runtime, not just what's declared via attributes - I'd pullIAuthorizeDataoff the endpoint metadata instead of walking attributes directly.On the AND/OR issue - that's a real bug in the approach as written, not just an edge case. Taking
schemeNames.First()collapses a multi-scheme[Authorize(AuthenticationSchemes = "A,B")](which OpenAPI would represent as one requirement object containing both - AND) down to just scheme A, silently dropping B from the spec. And if an endpoint genuinely needs to represent "A OR B" that has to be two separate requirement objects in theoperation.Securitylist, which the current code structurally can't produce since it only ever emits one.For fallback-policy endpoints specifically, I'd check whether the endpoint has any explicit
IAuthorizeDataat all - if none, but a fallback policy is configured onAuthorizationOptions.FallbackPolicy, resolve that policy's scheme requirements and apply them the same way as an explicit [Authorize], rather than treating "no attribute" as "no auth."I'll update the post with a corrected version that uses
EndpointMetadataand properly separates AND vs OR requirement objects — appreciate you flagging it, this would've bitten people running fallback policies.