Interactive Docs with Scalar
Serve an interactive API reference with Scalar.AspNetCore over the built-in document, and keep it out of public production traffic.
if (app.Environment.IsDevelopment())
{
app.MapOpenApi();
app.MapScalarApiReference(o => o.WithTitle("Orders API")); // /scalar
}The built-in generator produces a document, not a UI. The templates no longer include Swagger UI, so teams add a viewer themselves. Scalar.AspNetCore (2.18.2) is the common choice: app.MapScalarApiReference() maps /scalar, serves the page and its JavaScript from the package itself (no CDN request), and the page loads the document from /openapi/v1.json, the default route of MapOpenApi. It renders operations, schemas and examples, and has a request client with generated snippets for curl, C# HttpClient and other languages.
Two endpoints, one decision
The UI is useless without the document and the document is the sensitive part: it lists every route, parameter and schema, including ones nobody links to. Whatever rule protects one must protect both. The usual choices:
| Setting | Approach |
|---|---|
| Local development | Map both inside if (app.Environment.IsDevelopment()). This is what the template does for the document. |
| Shared test or staging | Map behind a configuration switch and RequireAuthorization with a policy. RequireHost for an internal name only matches the client-supplied Host header; the ingress must also refuse that host from outside. |
| Public API for partners | Do not serve docs from the service. Publish the build-time document to a docs site or portal, reviewed like any release. |
Authorization needs thought. A browser navigating to /scalar sends cookies, never a bearer token, so a policy that only accepts JWT bearer will always answer 401. Use the cookie or OpenID Connect scheme the company's internal tools already use, add it to the policy with AddAuthenticationSchemes, or restrict by network at the ingress.
Configuration
Options are chained on ScalarOptions: WithTitle, WithTheme, WithDefaultHttpClient(ScalarTarget.CSharp, ScalarClient.HttpClient), AddDocument("internal") for multiple documents, WithOpenApiRoutePattern when MapOpenApi uses a non-default route, and AddPreferredSecuritySchemes("Bearer") to select the scheme from your security transformer. A token can be pre-filled for local use; only ever read it from user secrets, never from a committed file.
Alternatives
- Swagger UI from
Swashbuckle.AspNetCore.SwaggerUIpointed at/openapi/v1.json: familiar to many consumers, UI only, no second generator needed. - ReDoc: read-only reference, good for published documentation sites.
.httpfiles: run requests from Visual Studio, VS Code or Rider, committed next to the code, no runtime dependency at all.- Importing the document into Postman or Bruno for exploratory testing.
Production failure modes
The common incident is not a vulnerability in the viewer but exposure: ASPNETCORE_ENVIRONMENT=Development set on a production deployment to see an error page also publishes the full API map and a request console. The request client runs in the browser against the servers URL, so a document listing production lets a developer send a real POST /payments while believing they are on staging. Keep production documents free of a production server entry unless the docs are meant to call it.
Example
using Microsoft.AspNetCore.Http.HttpResults;
using Scalar.AspNetCore;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddOpenApi();
builder.Services.AddOpenApi("internal", options =>
options.ShouldInclude = description => description.GroupName == "internal");
var app = builder.Build();
if (app.Environment.IsDevelopment())
{
app.MapOpenApi(); // /openapi/v1.json, /openapi/internal.json
app.MapScalarApiReference(options => // /scalar
{
options
.WithTitle("Orders API")
.AddDocument("v1", "Public")
.AddDocument("internal", "Internal")
.WithDefaultHttpClient(ScalarTarget.CSharp, ScalarClient.HttpClient)
.AddPreferredSecuritySchemes("Bearer");
});
}
app.MapGet("/orders/{id:int}", Results<Ok<OrderResponse>, NotFound> (int id) =>
id == 1042 ? TypedResults.Ok(new OrderResponse(1042, "Paid", 129.50m)) : TypedResults.NotFound())
.WithName("GetOrder")
.WithTags("Orders");
app.MapPost("/admin/orders/{id:int}/resend-confirmation", (int id) => TypedResults.Accepted($"/admin/jobs/{id}"))
.WithName("ResendOrderConfirmation")
.WithGroupName("internal");
app.Run();
record OrderResponse(int Id, string Status, decimal Total);When to use it
- Let developers explore and call the Orders API from /scalar on their own machine with a C# HttpClient snippet for each operation.
- Expose the internal document and Scalar on a staging host only to signed-in members of the api-developer role.
- Show public and internal documents side by side in one Scalar page during local development.
More examples
Staging docs behind a host and a policy
The gates that must hold are the configuration switch that defaults to off and a policy using the cookie scheme a browser can actually present. <code>RequireHost</code> only matches the <code>Host</code> header the client sends, and anyone who reaches the public ingress can send <code>Host: orders-docs.internal.example.com</code>, so it is routing hygiene, not access control. Pair it with an ingress or network rule that never routes that host from outside.
using Microsoft.AspNetCore.Authentication.Cookies;
using Scalar.AspNetCore;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddOpenApi();
// The company SSO signs developers in to internal tools with this cookie scheme.
builder.Services.AddAuthentication()
.AddCookie(CookieAuthenticationDefaults.AuthenticationScheme, options =>
{
options.LoginPath = "/account/login";
options.Cookie.Name = "__Host-orders-docs";
});
builder.Services.AddAuthorizationBuilder()
.AddPolicy("ApiDocs", policy => policy
.AddAuthenticationSchemes(CookieAuthenticationDefaults.AuthenticationScheme)
.RequireAuthenticatedUser()
.RequireRole("api-developer"));
var app = builder.Build();
app.UseAuthentication();
app.UseAuthorization();
if (app.Configuration.GetValue<bool>("ApiDocs:Enabled"))
{
var docsHost = app.Configuration["ApiDocs:Host"] ?? "orders-docs.internal.example.com";
app.MapOpenApi().RequireHost(docsHost).RequireAuthorization("ApiDocs");
app.MapScalarApiReference().RequireHost(docsHost).RequireAuthorization("ApiDocs");
}
app.MapGet("/orders/{id:int}", (int id) => TypedResults.Ok(new { id, status = "Paid" }));
app.Run();Custom routes for the document and the UI
When <code>MapOpenApi</code> moves off its default <code>/openapi/{documentName}.json</code> route, Scalar must be told with <code>WithOpenApiRoutePattern</code>, otherwise the page loads and shows a fetch error. Putting the document and the UI under one prefix also makes it simple to deny <code>/docs</code> at the ingress for every environment where it should not be reachable.
using Scalar.AspNetCore;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddOpenApi();
var app = builder.Build();
if (app.Environment.IsDevelopment())
{
// Everything under /docs so a single ingress rule can block it.
app.MapOpenApi("/docs/openapi/{documentName}.json");
app.MapScalarApiReference("/docs", options =>
{
options
.WithTitle("Inventory API")
.WithOpenApiRoutePattern("/docs/openapi/{documentName}.json");
});
}
app.MapGet("/inventory/{sku}", (string sku) => TypedResults.Ok(new { sku, onHand = 42, reserved = 3 }))
.WithName("GetStockLevel");
app.Run();A committed .http file instead of a UI
A <code>.http</code> file runs in Visual Studio, VS Code with REST Client, and Rider, and is reviewed in pull requests like code. It adds nothing to the running service, so there is nothing to secure in production. The token comes from a local <code>.env</code> file through <code>$dotenv</code> rather than being typed into the committed file. Put <code>.env</code> in <code>.gitignore</code> (or use <code>http-client.private.env.json</code> or user secrets), and use a short-lived local or staging token, never a production credential.
@baseUrl = http://localhost:5080
@token = {{$dotenv ORDERS_API_TOKEN}}
### Get one order
GET {{baseUrl}}/orders/1042
Authorization: Bearer {{token}}
Accept: application/json
### Place an order
POST {{baseUrl}}/orders
Authorization: Bearer {{token}}
Content-Type: application/json
Idempotency-Key: 7d4f1c2e-1b7a-4c55-9a5e-0d2f4b6a7e11
{
"customerEmail": "[email protected]",
"sku": "KETTLE-BLUE",
"quantity": 2
}
### Document itself
GET {{baseUrl}}/openapi/v1.json
Discussion