Mert Özen Further With Every Line
Security API Security (OWASP API Top 10) — #09

API8 and API9: Misconfiguration and Inventory — Using OpenAPI as an Inventory, Deprecation/Sunset Headers and Shadow APIs

Mert Özen Nov 8, 2026 11 min 5 views
API8 and API9: Misconfiguration and Inventory — Using OpenAPI as an Inventory, Deprecation/Sunset Headers and Shadow APIs

Against API8 you tie an API-specific checklist to tests that run in the Production environment; against API9 you use the OpenAPI document generated at build time as a committed inventory and retire old versions with the RFC 9745 Deprecation and RFC 8594 Sunset headers. CORS and middleware order, catching an unannounced endpoint with git diff, and a time zone trap.

Version note: The code in this post was built and run with .NET 10, Asp.Versioning.Http 10.2.3 and Microsoft.Extensions.ApiDescription.Server 10.0.14.

Guess First

The lab's API (Application Programming Interface) has been growing for eight posts and now has more than 25 endpoints. Before going to production, can you answer these questions: is the OpenAPI document open to the internet? Is there a stack trace in the response when an exception happens? Which origins can call this API from a browser? And the hardest one: which endpoints were added last month, which are still used, when will the old version be shut down?

The first three questions are about API8 (Security Misconfiguration in the OWASP API Security Top 10 2023), the last one about API9 (Improper Inventory Management). We discussed general misconfiguration, error pages and headers in the A02 Security Misconfiguration post of the Web Security (OWASP Top 10) series; I won't repeat that here. In this post we build an API-specific checklist and tie each item to a test that runs in the Production environment, then use the OpenAPI document generated at build time as an inventory and retire old versions with the Deprecation and Sunset headers.

Why API8 and API9 Go Together

The two items are two faces of the same problem: not knowing what you publish. In API8 you don't know the settings of what you publish: the OpenAPI UI (User Interface) you opened in development stays open in production, the CORS (Cross-Origin Resource Sharing) policy is waved through with *. In API9 you don't know the thing you publish itself: a forgotten v1, a copy running with real data in staging, an endpoint labelled "internal" but open to the internet. Both are solved by the same habit: making configuration and inventory visible through a test or a CI (Continuous Integration) step.

API8: An API-Specific Checklist, with Tests

I put together this list for the lab. Each row has the check, the defence in the lab and the test that guards it:

CheckDefence in the labTest
OpenAPI and Scalar UI closed in productionMapOpenApi() only in DevelopmentOpenApi_document_is_not_served_in_production
No internal details in error responsesUseExceptionHandler() and ProblemDetails in productionUnhandled_exception_returns_problem_details_without_internals
CORS open only to known originsA single policy with explicit origins, methods and headersCors_allows_only_known_origins
Responses that need identity aren't cachedCache-Control: no-store, X-Content-Type-Options: nosniffAuthenticated_responses_are_not_cacheable_and_not_sniffable
Unused HTTP (Hypertext Transfer Protocol) methods closedA method not in routing gets 405Unused_methods_are_rejected
An old version is announcedDeprecation, Sunset and Link headersDeprecated_version_announces_deprecation_and_sunset

I leave the details of HSTS (HTTP Strict Transport Security), CSP (Content Security Policy) and other headers to the security header post of the upcoming ASP.NET Core Security series; the two most important for a JSON (JavaScript Object Notation) API are here. The middleware that adds the response headers:

// Responses that need authentication are not stored in shared caches; the content type is not sniffed
public static IApplicationBuilder UseApiResponseHeaders(this IApplicationBuilder app) => app.Use((context, next) =>
{
    context.Response.OnStarting(() =>
    {
        context.Response.Headers.XContentTypeOptions = "nosniff";
        if (context.Request.Headers.Authorization.Count > 0 && !context.Response.Headers.ContainsKey("Cache-Control"))
        {
            context.Response.Headers.CacheControl = "no-store";
        }
        return Task.CompletedTask;
    });
    return next(context);
});

The CORS policy is deliberately narrow: the Angular app's origin, the three methods and three headers it uses. You can't use AllowAnyOrigin() together with AllowCredentials(), ASP.NET Core doesn't allow it; but a hand-written middleware that echoes the incoming Origin value back produces the same result silently. We saw the mechanics of CORS in the second half of the A01 post of the Web Security series.

Pipeline order burned me here once. In the first version I had put the app.UseCors(...) call in the middle of the middleware list, and even a preflight from the allowed origin didn't get Access-Control-Allow-Origin. The reason: WebApplication adds the authentication and authorization middleware at the start of the pipeline if you don't call them explicitly. The preflight request carries no Authorization header, so it hit the fallback policy first and never reached the CORS middleware. The fix is writing the order explicitly:

if (!app.Environment.IsDevelopment())
{
    // In production, only ProblemDetails instead of exception details
    app.UseExceptionHandler();
}
app.UseApiResponseHeaders();
// Order matters: preflight requests must be answered by CORS before they hit authorization
app.UseCors(ApiHardening.ShopCorsPolicy);
app.UseAuthentication();
app.UseAuthorization();
app.UseMiddleware<MethodOverrideGuard>();
app.UseRateLimiter();
app.UseRequestTimeouts();

Two preflights against the API running with production settings:

$ curl -s -D - -o /dev/null -X OPTIONS http://localhost:19519/orders -H "Origin: https://evil.example" -H "Access-Control-Request-Method: GET"
HTTP/1.1 204 No Content
Date: Sun, 08 Nov 2026 06:27:50 GMT
Server: Kestrel
X-Content-Type-Options: nosniff

$ curl -s -D - -o /dev/null -X OPTIONS http://localhost:19519/orders -H "Origin: https://shop.bookstore.test" -H "Access-Control-Request-Method: GET"
HTTP/1.1 204 No Content
Date: Sun, 08 Nov 2026 06:27:50 GMT
Server: Kestrel
Access-Control-Allow-Headers: Authorization,Content-Type,Idempotency-Key
Access-Control-Allow-Methods: GET,POST,PATCH
Access-Control-Allow-Origin: https://shop.bookstore.test
X-Content-Type-Options: nosniff

Both get 204; the only difference is the Access-Control-Allow-* headers. In the first case the browser will never send the real request.

Testing in the Production Environment

The most common trap for configuration tests is running in the Development environment. WebApplicationFactory uses Development by default; in that environment the developer exception page is on and the OpenAPI document is published. To test production behaviour we change the environment explicitly:

// The API8 checklist: these tests run in the Production environment
public sealed class ConfigurationTests(OrdersApiFactory factory) : IClassFixture<OrdersApiFactory>
{
    private CancellationToken Ct => TestContext.Current.CancellationToken;

    private HttpClient Production(string? actor = "alan")
    {
        HttpClient client = factory.WithWebHostBuilder(b => b.UseEnvironment("Production")).CreateClient();
        if (actor is not null)
        {
            client.DefaultRequestHeaders.Add(TestAuthHandler.UserHeader, actor);
        }
        return client;
    }

    [Fact]
    public async Task Unhandled_exception_returns_problem_details_without_internals()
    {
        factory.Carrier.Respond = _ => throw new InvalidOperationException("Server=db.internal;Password=dev-only-secret");

        using HttpResponseMessage response = await Production().GetAsync($"/orders/{SeedData.AlanOrder}/shipping-quote", Ct);
        string body = await response.Content.ReadAsStringAsync(Ct);

        Assert.Equal(HttpStatusCode.InternalServerError, response.StatusCode);
        Assert.Equal("application/problem+json", response.Content.Headers.ContentType?.MediaType);
        Assert.DoesNotContain("InvalidOperationException", body);
        Assert.DoesNotContain("dev-only-secret", body);
    }
}

The exception test is deliberately realistic: the shipping API's fake handler throws an exception carrying connection details in its message. The shipping client doesn't catch that exception type, the request falls through to 500, and the test's question is whether the response contains the exception type or message. In production only ProblemDetails comes back.

In the OpenAPI test I learned something I didn't expect:

$ curl -s -o /dev/null -w "%{http_code}\n" http://localhost:19519/openapi/v1.json
401
$ curl -s -o /dev/null -w "%{http_code}\n" http://localhost:19519/openapi/v1.json -H "Authorization: Bearer $ALAN"
404

In production the endpoint doesn't exist, yet the anonymous request got 401 instead of 404. The fallback policy is applied to requests that match no endpoint too; for someone who isn't signed in, existing and non-existing paths look the same. That's a nice side effect that makes inventory discovery harder. I wrote the test accordingly, with two assertions.

API9: The OpenAPI Document Is an Inventory

The API9 page of OWASP (Open Worldwide Application Security Project) lists two sides of a missing inventory: not knowing which hosts and versions are live, and not knowing which data flows to which third party. For the first we have a ready-made tool: the OpenAPI document. But a document generated only at runtime and open only in Development doesn't count as an inventory. An inventory should be a file versioned with the code that is visible when it changes. The Microsoft.Extensions.ApiDescription.Server package can generate the document at build time and write it to a folder:

<!-- Generate the OpenAPI document at build time and write it to the inventory folder in the repository -->
<OpenApiGenerateDocuments>true</OpenApiGenerateDocuments>
<OpenApiDocumentsDirectory>$(MSBuildProjectDirectory)/../../inventory</OpenApiDocumentsDirectory>

After the first build two things happened: inventory/Bookstore.Orders.Api.json was created, and an orders.db appeared in the API project's folder. Build-time generation actually runs the application's Program.cs to obtain the document; our EnsureCreated() and seed code at startup ran too. For this situation the Microsoft documentation suggests checking the entry assembly name:

// The app is started while the OpenAPI document is generated at build time: don't touch the database
bool generatingDocument = System.Reflection.Assembly.GetEntryAssembly()?.GetName().Name == "GetDocument.Insider";
if (!generatingDocument)
{
    using IServiceScope scope = app.Services.CreateScope();
    OrdersDbContext db = scope.ServiceProvider.GetRequiredService<OrdersDbContext>();
    db.Database.EnsureCreated();
    SeedData.Seed(db);
    SeedData.SeedBulkOrders(db, app.Configuration.GetValue("Lab:BulkOrders", 0));
}

This matters even more in a real project: an application that runs migrations at startup, connects to a message broker or reads secrets from Key Vault will try to do those on every build. ASP.NET Core 11 brings the OpenApiGenerationEnvironment MSBuild property for this area.

The generated file is committed to the repository. Now every pull request shows whether the inventory changed. Let's try it: someone adds a GET /orders/export that returns all orders with their addresses, saying "we need it for a report", and builds:

$ dotnet build src/Bookstore.Orders.Api && git diff --stat -- inventory/
 inventory/Bookstore.Orders.Api.json | 50 +++++++++++++++++++++++++++++++++++++
 1 file changed, 50 insertions(+)
$ git diff -U0 -- inventory/ | head -12
diff --git a/inventory/Bookstore.Orders.Api.json b/inventory/Bookstore.Orders.Api.json
index 01d01e6..c02c7d2 100644
--- a/inventory/Bookstore.Orders.Api.json
+++ b/inventory/Bookstore.Orders.Api.json
@@ -171,0 +172,22 @@
+    "/orders/export": {
+      "get": {
+        "tags": [
+          "Bookstore.Orders.Api"
+        ],
+        "responses": {
+          "200": {

Running git diff --exit-code -- inventory/ in CI after the build makes it impossible to add a new endpoint without updating the inventory: if the file changed, the build breaks, the developer commits the up-to-date file and the change shows up in review. The inventory test from the sixth post already demanded matrix rows for a new endpoint; this step separately makes visible that the endpoint has been added to the external contract. The file difference between two release tags also answers "which endpoints were added or removed in this release".

Looking at the file I noticed one more thing: the admin endpoints are in the document too. As an inventory that's right, but if you publish this file on a developer portal you also publish the map of the admin surface. You need separate documents for public and admin endpoints, and only the public one in the published document.

Retiring Old Versions: Deprecation and Sunset

The most common form of API9 is an old version someone forgot to shut down. Old versions usually don't get new security controls: when rate limits are added, BOPLA (Broken Object Property Level Authorization) is fixed or a new authorization rule is written, v1 gets skipped. Shutting a version down, though, means giving clients time. Two standard headers exist for that: the RFC (Request for Comments) 9745 Deprecation header (March 2025) says since when a resource has been deprecated, and the RFC 8594 Sunset header says when it will stop responding. According to RFC 9745 the Sunset date can't be earlier than the Deprecation date.

The 10 series of Asp.Versioning brought RFC 9745 support. I added a small two-version catalog endpoint to the lab and defined two policies for v1:

services.AddApiVersioning(options =>
{
    options.ReportApiVersions = true;
    // v1 has been deprecated since 1 November 2026 and shuts down on 30 June 2027; dates are explicitly UTC
    options.Policies.Deprecate(1.0)
        .Effective(new DateTimeOffset(2026, 11, 1, 0, 0, 0, TimeSpan.Zero))
        .Link("https://docs.bookstore.test/catalog/v1-deprecation").Type("text/html");
    options.Policies.Sunset(1.0)
        .Effective(new DateTimeOffset(2027, 6, 30, 0, 0, 0, TimeSpan.Zero))
        .Link("https://docs.bookstore.test/catalog/v1-sunset").Type("text/html");
});

"Explicitly UTC (Coordinated Universal Time)" in the comment is the result of a lesson. In the first version I used the .Effective(2026, 11, 1) overload. The lab machine's time zone is UTC+3 and the response was:

Sunset: Tue, 29 Jun 2027 21:00:00 GMT
Deprecation: @1793480400

@1793480400 is 31 October 2026 21:00 UTC. The overload that takes year, month and day interprets the date in the server's local time zone; the same code announces different dates on servers in different regions. After giving UTC explicitly with DateTimeOffset:

$ curl -s -D - -o /dev/null http://localhost:19519/v1/catalog/books -H "Authorization: Bearer $ALAN"
HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
Date: Sun, 08 Nov 2026 06:28:08 GMT
Server: Kestrel
Cache-Control: no-store
Transfer-Encoding: chunked
api-supported-versions: 2.0
api-deprecated-versions: 1.0
Sunset: Wed, 30 Jun 2027 00:00:00 GMT
Link: <https://docs.bookstore.test/catalog/v1-sunset>; rel="sunset"; type="text/html"
Link: <https://docs.bookstore.test/catalog/v1-deprecation>; rel="deprecation"; type="text/html"
Deprecation: @1793491200
X-Content-Type-Options: nosniff

The Deprecation value is in RFC 9745's structured field date format: @ and Unix time. Sunset is in RFC 8594's HTTP date format; RFC 9745 notes that this difference remains for historical reasons. The v2 request doesn't have these headers, only the lists of supported and deprecated versions. The headers warn clients but don't move anyone on their own: finding the clients that receive them in the logs and reaching their owners is the real part of the job. We covered versioning itself, i.e. the URL (Uniform Resource Locator), query string and header options, in the versioning post of the ASP.NET Core Web API series.

In the inventory file these two versions appear under a single /v{version}/catalog/books path, with no deprecated information. Producing a document per version requires Asp.Versioning's API explorer integration. With ASP.NET Core 11, endpoints marked [Obsolete] will be reflected in the OpenAPI document as deprecated: true automatically.

Forgotten Environments and Shadow APIs

The part of the inventory you can't see in code is environments. The Development versus Production difference we saw in the lab (the OpenAPI endpoint exists in only one) is a small example; in real life the difference can be much bigger. The three cases I run into most often: a staging environment running with a copy of production data that isn't protected as well as production. A subdomain opened for a campaign and never closed after it ended. And an endpoint named "internal", written for service-to-service use, but opened to the internet by a wrong load balancer rule.

The common fix is checking the inventory from the outside as well as from code: regularly scanning DNS (Domain Name System) records and certificate transparency logs, comparing the gateway's route list with the inventory file, using anonymised data instead of real personal data in staging. The admin port separation from the sixth post is an inventory decision too: the admin surface never appears in the public load balancer's list.

Production Notes

Run the configuration tests against the deployed artifact too. WebApplicationFactory tests tell you what the code will do in production; they don't tell you how the environment is actually configured. A few curl checks against staging after deploy (is OpenAPI 401 or 404, is there a stack trace in an error response, does CORS return headers to an unknown origin) are the outside verification of the same list.

Watch out for the loose settings in appsettings.Development.json leaking into production. A container with a wrongly set ASPNETCORE_ENVIRONMENT variable starts with development settings; the cheapest way to catch that is for the application to write its environment and critical settings in a single log line at startup, and for monitoring to check that line.

When the sunset date arrives, instead of deleting the endpoint, first release a version that returns 410 Gone or an explanatory ProblemDetails, and watch the callers for a while. That's when a client nobody expected shows up.

Common Pitfalls

The first pitfall is testing configuration only in Development. The developer exception page, open OpenAPI and loose CORS are normal there; the value of the test is in proving production behaviour.

The second is generating the OpenAPI document only at runtime and calling it an inventory. Nobody sees a document that isn't committed when it changes.

The third is leaving an old version open on the assumption that "nobody uses it", or the opposite, shutting it down without notice. The Deprecation and Sunset headers, usage data from the logs and an announcement that reaches clients must work together.

The fourth is leaving middleware order to the framework's defaults and not knowing in which order CORS, authentication and the exception handler run. Change the order and preflights get 401 or the exception handler catches nothing.

A Small Experiment

★☆☆ Start the lab in the Production environment and verify each check in the table with curl, one by one. Which of them would you not have noticed without a test?

★★☆ Generate two documents with AddOpenApi("public") and AddOpenApi("admin"); put only endpoints starting with /admin into the admin document. Did build-time generation write two files?

★★★ Write a small CI script: compare the inventory files at two git tags and list the endpoints that were added, removed or whose authorization requirement changed. Add the output to the release notes.

In the next post we bring the series' controls together: a test pyramid for authorization regression, running OpenAPI-driven scanning tools only against our own environment, abuse telemetry that tracks 401, 403 and 404 rates per tenant, and a one-page API security checklist.