My ASP.NET Core App Called Its Own API Over HTTP
MobiShare's Razor Pages, SignalR hub, and MQTT handler all call the app's own Web API over loopback HTTP instead of in-process — forcing a cookie-forwarding handler and an AsyncLocal hack. An accidental distributed monolith, and what it cost.
MobiShare is a single ASP.NET Core application. The Razor Pages front-end and the JSON Web API live in the same project, compiled into the same assembly, running in the same process. And yet the pages don’t call the application layer directly — they open an HttpClient, serialize a request, and call the app’s own API controllers over loopback HTTP on https://localhost:7027. The process talks to itself across a TCP socket. I didn’t set out to build a distributed monolith; I built one anyway, and the tell was that I had to write two separate hacks just to keep authentication working across a boundary that doesn’t physically exist.
What the code actually does
There’s a named HttpClient called CityApi, and its base address points back at the app that registers it:
builder.Services.AddHttpClient("CityApi", client =>
{
client.BaseAddress = new Uri(builder.Configuration["ApiBaseUrl"] ?? "https://localhost:7027/");
client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
})
.AddHttpMessageHandler<CookieForwardingHandler>();
Nine page models take that client and use it to do their work. The wallet page is representative — topping up a balance is a PUT to api/Balance followed by a POST to api/HistoryCredit, both going out over HTTP to the same process they’re running in:
_httpClient = httpClientFactory.CreateClient("CityApi");
// ...
var updateBalance = await _httpClient.PutAsJsonAsync("api/Balance", balanceDto);
var createHistory = await _httpClient.PostAsJsonAsync("api/HistoryCredit", historyDto);
The API controllers on the other end are [Authorize]-protected — which is correct, they’re a real HTTP surface — but it means the page can’t just call them. It has to arrive authenticated, as the signed-in user, the same way a browser would. So the request has to carry that user’s auth cookie. That’s the first hack.
The first hack: forwarding your own cookie to yourself
Because the controllers demand an authenticated caller, every internal call has to smuggle the current user’s cookie onto the outgoing request. That’s a DelegatingHandler sitting on the CityApi client:
protected override Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request, CancellationToken cancellationToken)
{
var cookie = _httpContextAccessor.HttpContext?.Request.Headers.Cookie.ToString();
// ...
if (!string.IsNullOrEmpty(cookie))
request.Headers.Add("Cookie", cookie);
return base.SendAsync(request, cancellationToken);
}
Read that back slowly. The application takes the cookie off the incoming request, and attaches it to an outgoing request it’s about to make to itself, so that its own authorization middleware will re-validate the same user it already validated one stack frame ago. Every internal operation pays for a second full authentication pass against a security boundary that only exists because I chose to draw it in HTTP.
The second hack: SignalR doesn’t have an HttpContext
The cookie trick relies on IHttpContextAccessor — fine for a Razor Page, which runs inside a request. But MobiShare’s chatbot runs over a SignalR hub, and hub method invocations don’t flow an HttpContext through IHttpContextAccessor. So the same self-HTTP call from inside the hub arrives with no cookie, hits [Authorize], and gets a 401.
The fix that shipped was to stash the cookie in an AsyncLocal and have the handler fall back to it:
public static class HttpClientContext
{
// The "CityApi" HttpClient now requires an authenticated caller.
// Hub invocations don't flow HttpContext, so ChatHub stashes the
// caller's cookie here per message; CookieForwardingHandler reads it.
private static readonly AsyncLocal<string?> _authCookie = new();
public static string? AuthCookie
{
get => _authCookie.Value;
set => _authCookie.Value = value;
}
}
The hub writes the cookie into a static AsyncLocal before doing its work; the handler reads it as a fallback when there’s no HttpContext. It works. It’s also a global mutable slot holding a user’s session credential mid-request, threaded through static state because the real state — the identity of the caller — was thrown away the moment I decided the hub should reach the rest of the app through HTTP instead of a method call. The MQTT handler that ingests GPS pings from the vehicles does the same self-HTTP round-trip to persist a position, and inherits the same problem.
Why this is a distributed monolith
The phrase “distributed monolith” usually describes microservices so tangled they have to deploy together — you paid for the network boundary and got none of the independence. MobiShare is the mirror image: one deployable unit that paid for a network boundary it never needed, inside itself.
Every one of those internal calls serializes a DTO to JSON, opens (or reuses) a TLS connection to loopback, runs the request back through routing, model binding, authorization, and controller dispatch, then deserializes the response — to do something the caller could have done with a direct method call, because the handler is right there in the same process. And the app already has the in-process seam for it: MobiShare uses MediatR, so the wallet page could call _mediator.Send(new UpdateBalance(...)) and hit the exact same handler the API controller hits, minus the socket, the JSON, and the cookie theater. I wrote about that pattern in CQRS with MediatR in a Real ASP.NET Core App — the irony is that the clean in-process path existed the whole time; the front-end just wasn’t using it.
The cost that isn’t performance
The loopback overhead is real but it’s the least interesting cost. Two things hurt more.
You lose the transaction. That wallet top-up is two separate HTTP calls: update the balance, then write the history row. Two calls means two independent requests, each with its own DbContext scope and its own SaveChanges. There is no ambient transaction spanning them, because there’s no shared unit of work — one is a PUT, the other a POST, as far apart as if they’d hit two different servers. If the second call fails, the first is already committed. In-process, both could sit inside one handler and one transaction; over self-HTTP, that guarantee is structurally impossible to add without inventing a distributed-transaction protocol against yourself.
You lose the caller’s identity and have to reconstruct it. Both hacks above exist for exactly one reason: HTTP threw away the fact that the caller was already authenticated, so I had to serialize the proof of identity (the cookie) and replay it. A method call carries the ClaimsPrincipal for free. The boundary didn’t add security — the caller and callee trust each other completely, they’re the same process — it just added work to prove a trust that was never in question.
The numbers
| What I found | Count |
|---|---|
| Page models calling the app’s own API over HTTP | 9 |
| Non-page callers doing the same (SignalR hub, MQTT handler) | 2 |
| Hacks required to keep auth working across the fake boundary | 2 (CookieForwardingHandler + AuthCookie AsyncLocal) |
| Multi-step operations split across separate self-HTTP calls with no shared transaction | at least 1 (wallet top-up: balance + history) |
| In-process alternative already present in the codebase | MediatR (_mediator.Send) |
Verdict
An HTTP API is the right boundary for callers who are actually remote — a mobile app, a third party, a separate service. It is the wrong boundary for a Razor Page calling a handler in its own process, and the signal that you’ve drawn it in the wrong place is unmistakable in hindsight: you find yourself writing infrastructure to forward your own credentials to yourself, and stashing session state in AsyncLocal because a component can’t produce an HttpContext it should never have needed. The fix isn’t to delete the API — external clients still need it — it’s to stop being one of its clients from inside the same process, and call the application layer (here, MediatR) directly. If your app authenticates to itself to talk to itself, the boundary is imaginary, and you’re paying real costs to maintain the illusion. I’d keep the controllers for the outside world and route every internal caller straight to the handler — the seam was already there; I just hadn’t used it where it mattered most.
Related
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.
Mobishare splits every operation into a MediatR command or query, one file each, thin controllers throughout. Here's what that buys you — and the swallowed-exception bug it let slip through.
Mobishare's vehicle-reservation timer was a singleton service holding one connection ID for the entire app, with expiry driven by client-side JavaScript. Here's why that's a concurrency bug, not a UI detail.
Get new posts by email
No hype, unsubscribe anytime. · Powered by Buttondown