Skip to main content
Dejan Demonjić brand logoDejan Demonjić
Decision log
Work
About
Contact

Decision log / Decision record

Decide from the endpoint, not from the path

Rate limits, security headers and access gates are per-endpoint decisions. ASP.NET Core accepts several spellings of one path, so a decision made by comparing the path is a decision made on incomplete information. Put it on the endpoint, and test that every endpoint is covered.

Filed
2026-10-16
Topic
Identity and security
Reading
6 min
Tags
#aspnetcore#routing#security#rate-limiting

Part 2 of 5 Identity and access

  1. One identity server for the cloud and for every site
  2. Decide from the endpoint, not from the path
  3. When the token store is down, the honest answer is 503
  4. A Content Security Policy for an identity server
  5. Mutual TLS and certificate-bound tokens for the links that matter
Previous One identity server for the cloud and for every siteNext When the token store is down, the honest answer is 503

The claim

In ASP.NET Core, any decision that depends on which endpoint a request reached (a rate limit, a security header, an authentication gate) must be made from the endpoint routing chose, never from the request path. The server and routing together accept more than one spelling of a path; a list of paths kept beside the routes cannot be made equal to them, and the endpoint can. A guarding test over the endpoint table makes the rule hold for every page added later.

What routing accepts

The documentation states the rule in one sentence: "Text matching is case-insensitive and based on the decoded representation of the URL's path." Here is what that means for a page declared at /blog, measured on 2026-10-16 against this site's ASP.NET Core 10 build running locally on Kestrel (the published copy is static files on Cloudflare and answers these spellings differently), with curl --path-as-is so the client sent each path exactly as written:

Request path Answer
/blog 200
/blog/ 200
/BLOG 200
/Blog/ 200
/%62log (the b percent-encoded) 200
/blog/. 200
/work/../blog 200
/blog// 404
/blog%2F 404

Seven spellings reach the same page. Kestrel decodes percent-escapes (all but an encoded slash) and removes dot segments before routing sees the path, as RFC 3986 describes; routing then matches that decoded path without regard to letter case, and accepts one trailing slash. Only a doubled slash and an encoded slash miss. None of this is a defect; it is how the server and routing are specified, and a reader of your code should expect it.

The rule

A request limit in Hexavera's identity host once compared the request path with a list of limited paths. One spelling of a limited page was outside the list and so outside the limit. The same shape turned up in four more places once it was looked for: a Content Security Policy chosen by path, an anonymous gate that tested a path prefix, a hub that read its token from the query by path, an ingress rule written as a regular expression over the raw path. Four more places, one shape: a decision about an endpoint, made from a string that routing had already interpreted differently.

The rule that replaced it has three parts.

1. The endpoint declares the decision on itself. A page or an endpoint carries metadata that names what applies to it: a rate-limiting policy, a header policy, an authentication requirement. ASP.NET Core already has the attributes and the builder methods; the point is to use nothing else.

// A Razor component page declares its limit on itself.
@attribute [EnableRateLimiting("sign-in")]

// A minimal API endpoint does the same through its builder.
app.MapPost("/connect/token", TokenEndpoint.HandleAsync)
   .RequireRateLimiting("token");

// A header policy is metadata too: an attribute the pages that need it carry.
[AttributeUsage(AttributeTargets.Class)]
public sealed class ScriptPageAttribute : Attribute;

2. Middleware reads the endpoint, after routing. The endpoint is null before UseRouting runs and set between UseRouting and the endpoint dispatch, which is where authentication and authorization already sit for the same reason. Anything that needs the decision reads it from there.

public static string? LimitOf(HttpContext context) =>
    context.GetEndpoint()?.Metadata.GetMetadata<EnableRateLimitingAttribute>()?.PolicyName;

public static string PolicyFor(Endpoint? endpoint) =>
    endpoint?.Metadata.GetMetadata<ScriptPageAttribute>() is null
        ? StrictContentSecurityPolicy
        : ScriptPageContentSecurityPolicy;

WebApplication wraps the pipeline in UseRouting and UseEndpoints on its own, but once a middleware has to run before routing (security headers that every response needs, say) and another has to read the endpoint, the order is yours to state: call UseRouting() explicitly where the matched endpoint must become visible. The routing documentation says the same: without the explicit call, custom middleware runs after the route matching middleware.

3. A test proves every endpoint is covered. This is the part that makes the rule survive the next page. The test reads the host's EndpointDataSource, the same table routing uses, sets aside the static files MapStaticAssets() serves, and fails for any other endpoint that neither declares a limit nor appears in a named list of endpoints that need none, the framework's own among them.

[Fact]
public void Every_endpoint_declares_its_limit_or_is_listed_as_needing_none()
{
    var endpoints = host.Services.GetRequiredService<EndpointDataSource>().Endpoints
        .OfType<RouteEndpoint>()
        .Where(endpoint => endpoint.Metadata.GetMetadata<StaticAssetDescriptor>() is null);

    var undeclared = endpoints
        .Where(endpoint => endpoint.Metadata.GetMetadata<EnableRateLimitingAttribute>() is null)
        .Select(endpoint => endpoint.RoutePattern.RawText)
        .Except(NeedsNoLimit, StringComparer.Ordinal)
        .ToList();

    undeclared.ShouldBeEmpty("a new page or endpoint must declare its limit, or be listed here as needing none");
}

Two more tests post with a trailing slash. One posts three times to every limited endpoint, each from an address of its own, and asserts that the third post is refused; the other posts to the sign-in page with and without the slash, and in another letter case, and asserts that every spelling counts toward one window. They are cheap, and they are the tests that would have caught the original defect.

Why not fix the list

The honest alternative is to normalise the path before comparing: lower-case it and strip one trailing slash, and trust that the server has already decoded it and resolved its dot segments. That reimplements part of routing's matcher in a second place, leans on the server for the rest, and has to be kept equal to both across framework releases. The endpoint is already the result of that work. Reading it costs one method call.

A related warning from the routing documentation applies here as well: route constraints are for disambiguating routes, not for validating input. A constraint that fails produces a 404 where the caller should have seen a 400. The same instinct that puts validation in a route constraint puts security decisions in a path list; both belong one step later, where the request has been resolved.

What it cost

  • A one-line declaration on every limited page and endpoint, and a named list of the ones that need none. The list is short and it is reviewed.
  • An explicit UseRouting() in the pipeline, with a comment that says why it is there.
  • Three integration tests that boot the real host. They fail loudly.

What a team can take from this

Grep your middleware for Request.Path. Every hit that feeds a decision about which endpoint this is should become endpoint metadata read through GetEndpoint(). Then write the one test that enumerates EndpointDataSource and refuses an undeclared endpoint. After that, the rule needs no reviewer to remember it.

DD

Dejan Demonjić

Principal software engineer, Belgrade

Designs and builds secure, multi-tenant .NET platforms. Questions about this entry, or a different view on it, are welcome by email.

  • Email
  • LinkedIn
  • GitHub
Previous When the token store is down, the honest answer is 503A resource server that cannot check a token must refuse it, and a 401 is the wrong refusal: it tells the client its token is bad and signs the user out. The design: fail closed, classify the fault by what the data provider says, answer 503 with a Retry-After drawn per response, and let anonymous routes ask before they say 'not signed in'.Next The tenant comes from the token, not from the headerIn a multi-tenant API the tenant a request acts in is decided from who is calling. A header may name one; the token says whether the caller may act there. The decision record: one middleware owns the header, the status code follows the caller, and authority that arrives through a replicated directory is trusted for one token lifetime.

In this entry

  1. The claim
  2. What routing accepts
  3. The rule
  4. Why not fix the list
  5. What it cost
  6. What a team can take from this
Dejan Demonjić

Designing and building secure, multi-tenant .NET platforms, and writing down the decisions behind them.

Read

  • Decision log
  • Work
  • RSS feed

Contact

  • dejan@demonjic.com
  • Write to me
  • About

Elsewhere

© Dejan Demonjić. Written in Belgrade.