The API Had No Lock: Finding an ASP.NET Core Auth Hole in My Own App

app.UseAuthentication() was never called and no API controller had [Authorize]. The whole JSON API was open to anyone. Here's how I found it, and what it actually took to fix it without breaking the app.

Simone Negro, Backend & AI Engineer
6 min read

Every admin page in MobiShare checks a policy — IsAdmin, IsStaff, IsTechnician — before rendering anything. The JSON API underneath those pages checked nothing at all. Any client, logged in or not, could hit api/Balance, api/Ride, or api/Vehicle directly, and the UI-level policies would never even see the request go by.

I found this a year after shipping the project, going back through the code with more experience than I had at the time. It’s a good bug to walk through precisely because the fix that looks obvious in isolation — “just add [Authorize]” — breaks the app the moment you apply it, for reasons that only show up once you actually enumerate who’s calling that API.

What “secure” looked like from the outside

MobiShare’s Razor Pages don’t talk to ApplicationDbContext directly in the common case. They call the app’s own REST API over HttpClient, at a loopback base address, exactly as if it were a third-party service — a legitimate BFF-style split, and a pattern I’d use again. It reads clean: the web layer renders pages and enforces who’s allowed to see them; the API layer is the single source of truth for the domain.

Except only one of those two layers was actually enforcing anything:

  • The Razor Pages had real authorization — [Authorize(Policy = PolicyNames.IsAdmin)] and its siblings, gating the entire admin area behind custom IAuthorizationHandler implementations.
  • The 16 API controllers underneath had zero authorization attributes between them. Not one [Authorize], anywhere.
  • Program.cs called app.UseAuthorization() — but never app.UseAuthentication(). Authorization middleware checks what the current user is allowed to do; without authentication middleware ahead of it, HttpContext.User is never populated from the auth cookie on an incoming request, so there was nothing meaningful for UseAuthorization() to check in the first place.

The practical result: PUT api/Balance would top up any wallet by ID, GET api/Ride/AllUserRides/{userId} would return any user’s ride history for any userId you typed in, and reservation or vehicle-status endpoints could be hit directly, skipping every check the Razor Page in front of them was supposedly making. Swagger was also enabled and browsable outside Development, which meant the entire unsecured surface was self-documenting for anyone who found /swagger.

None of this needed a security scanner to find — reading Program.cs top to bottom and grepping the controllers folder for [Authorize] was enough. That’s usually how these holes get found in practice: not by a clever attack, but by someone eventually reading the file that everyone assumed someone else had already checked.

The fix that looked done and wasn’t

The direct fix is short. Wire authentication into the pipeline, and instead of adding [Authorize] sixteen times and risking a seventeenth controller shipping unprotected later, require it by default for every controller through a global filter:

// Program.cs
builder.Services.AddControllers(options =>
{
    options.Filters.Add(new AuthorizeFilter());
});
app.UseAuthentication();
app.UseAuthorization();

I ran dotnet build, it compiled, and for about five minutes I considered this done. It wasn’t — because “requires an authenticated caller” only works if every legitimate caller actually is one, and I hadn’t enumerated who was calling this API yet.

Two callers that don’t look like a browser

MobiShare’s API has exactly two real callers beyond a browser hitting Swagger directly, and neither carries an auth cookie the way a normal request would:

The Razor Pages’ own HttpClient. A page handler does httpClientFactory.CreateClient("CityApi") and calls the internal API server-to-server. That outgoing request doesn’t automatically inherit the cookie from the incoming request that triggered it — .NET’s HttpClient has no idea there’s a logged-in user on the other end unless you tell it.

The chatbot’s tool-calling functions, which I covered separately in the RAG/tool-calling post. These run inside a SignalR Hub method, and Hub invocations don’t flow HttpContext through IHttpContextAccessor the way a normal MVC action does — the connection is a long-lived WebSocket, not a fresh per-message HTTP request through the usual middleware pipeline.

Turn on AuthorizeFilter without accounting for either of these, and you don’t harden the app — you break every Razor Page that calls the API (which is most of them) and every chatbot tool call (reservations, tickets, balance lookups) in one shot. That’s a strictly worse state than the original hole: at least the open API was working.

Carrying identity across the internal hop

The fix has to forward who’s actually asking onto the internal call, for both callers:

public class CookieForwardingHandler : DelegatingHandler
{
    protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken ct)
    {
        var cookie = _httpContextAccessor.HttpContext?.Request.Headers.Cookie.ToString();
        if (string.IsNullOrEmpty(cookie))
            cookie = HttpClientContext.AuthCookie; // Hub-call fallback, see below
        if (!string.IsNullOrEmpty(cookie))
            request.Headers.Add("Cookie", cookie);
        return base.SendAsync(request, ct);
    }
}

For a normal Razor Page request, IHttpContextAccessor already has what’s needed — attach this as a DelegatingHandler on the named HttpClient and every internal call carries the original cookie. For the Hub case there’s no HttpContext to read from IHttpContextAccessor, so the cookie needs to travel some other way that does survive across a Hub method’s async chain. The codebase already had exactly that mechanism, built for a different problem: an AsyncLocal<T>-backed static context class the tool-calling code uses to reach per-connection state from classes that can’t take constructor-injected dependencies. One more property on it closed the gap:

// set once, at the top of the Hub method that handles an incoming chat message
HttpClientContext.AuthCookie = Context.GetHttpContext()?.Request.Headers.Cookie.ToString();

Everything downstream in that message — the Hub’s own API calls and every tool-calling function invoked for it — now rides on the same ambient value, without either of them needing to know where it came from.

The one caller that genuinely isn’t a user

There’s a third caller I almost missed: a background service ingesting GPS telemetry over MQTT, calling GET api/Vehicle/{id} and POST api/Position on a timer, with no HttpContext and no user at all, ever — by design, not by oversight. That’s not a bug in the auth model; it’s infrastructure, and infrastructure isn’t “a user who forgot to log in.” Forcing it through the cookie-forwarding path would just be wrong. Those two endpoints get an explicit, commented [AllowAnonymous] instead of silently inheriting the global fix:

// Anonymous on purpose: called by the MQTT-ingestion background service,
// which has no HttpContext/user to authenticate as. Read-only, low sensitivity.
[AllowAnonymous]
public async Task<IActionResult> GetVehicleById(...)

The distinction matters: everything else is protected because I forgot to protect it before; these two are open because I decided, in writing, that they should be — which is the difference between a hole and a documented tradeoff.

What changedBeforeAfter
API controllers requiring an authenticated caller0 of 1616 of 16, via one global filter
app.UseAuthentication() in the pipelineabsentpresent
Endpoints intentionally open to anonymous callersall of them, by omission2, explicit and commented
Callers needing a code change to keep working2 (Razor→API HttpClient, Hub→API HttpClient)
Build errors after the change0 (dotnet build, clean, twice — before and after merging 10 unrelated upstream commits)

Verdict

UI-layer authorization and API-layer authorization are not the same control, and one does not imply the other the moment your frontend and your API are two independently reachable surfaces — even when they happen to share a process and a deploy. If a Razor Page can call an endpoint, so can anyone with curl and the URL; “the button is hidden unless you’re an admin” was never actually checking anything on the other side of that call. The lesson isn’t “add [Authorize] everywhere” as a reflex, either — blanket auth applied without first enumerating your actual callers (a BFF’s own HttpClient, a SignalR Hub, a background worker with no user in the loop) just trades one open API for a broken one, which is a worse demo than the hole was. Enumerate every real caller first. Secure by default. Carve out exceptions explicitly, in code, with a comment saying why — never by omission, which is how this happened the first time.

Get new posts by email

No hype, unsubscribe anytime. · Powered by Buttondown

Or follow along

Shorter takes, half-finished ideas, and whatever I'm building or breaking this week.