Verify a credential on the server in your authentication handler, then require it on every protected endpoint. Here the handler looks the bearer token up in the sessions table and builds the principal from the stored row, or produces no identity so RequireAuthorization answers 401. It never trusts a userId query parameter or an X-Authenticated-User header.
- List the endpoints that return user or internal data (here /me and /admin/metrics) and the public ones (/health). Treat every endpoint as protected unless you decided otherwise.
- Register one authentication scheme whose handler verifies the credential against server-side state (a session store, or AddJwtBearer with issuer, audience and signing key validation) and returns AuthenticateResult.NoResult() or Fail() when it cannot.
- Call RequireAuthorization() on every protected endpoint or on a MapGroup that holds them, or set a FallbackPolicy that requires an authenticated user and mark public endpoints with AllowAnonymous().
- Build the ClaimsPrincipal only from the verified credential. Never read a user id, tenant or role from the query string, the body or a header such as X-Authenticated-User.
- Let the challenge answer 401 with no user data for an absent, unknown or malformed credential.
- Add regression tests that call each protected endpoint with no credential, a forged identity header and a made-up token, and assert 401.
The three implementations share one API and one test suite; only the code below changes. The tests load the one that IMPL names.
The vulnerable code
protected override Task<AuthenticateResult> HandleAuthenticateAsync()
{
// Nothing is verified: the caller picks who they are, or is let in as a guest.
string claimed = Request.Query["userId"].ToString();
string userId = claimed.Length > 0 ? claimed : "guest";
var identity = new ClaimsIdentity(new[] { new Claim(ClaimTypes.NameIdentifier, userId) }, Scheme.Name);
return Task.FromResult(AuthenticateResult.Success(new AuthenticationTicket(new ClaimsPrincipal(identity), Scheme.Name)));
}With IMPL=vulnerable, 7 of 9 tests fail (4 exploit tests).
The fix
protected override Task<AuthenticateResult> HandleAuthenticateAsync()
{
string header = Request.Headers.Authorization.ToString();
if (!header.StartsWith("Bearer ", StringComparison.Ordinal))
{
return Task.FromResult(AuthenticateResult.NoResult());
}
string token = header["Bearer ".Length..].Trim();
Account? account = token.Length == 0 ? null : _db.FindBySessionToken(token);
if (account is null)
{
return Task.FromResult(AuthenticateResult.Fail("unknown or expired session"));
}
var identity = new ClaimsIdentity(
new[] { new Claim(ClaimTypes.NameIdentifier, account.Id), new Claim(ClaimTypes.Role, account.Role) },
Scheme.Name);
return Task.FromResult(AuthenticateResult.Success(new AuthenticationTicket(new ClaimsPrincipal(identity), Scheme.Name)));
}With IMPL=fixed, 9 of 9 tests pass.
A fix that looks safe but is not
protected override Task<AuthenticateResult> HandleAuthenticateAsync()
{
// "The gateway strips and re-sets this header after it authenticates." Except the
// gateway can be bypassed, and then the header is whatever the client sent.
string claimed = Request.Headers["X-Authenticated-User"].ToString();
Account? account = claimed.Length == 0 ? null : _db.FindUser(claimed);
if (account is null)
{
return Task.FromResult(AuthenticateResult.NoResult());
}
var identity = new ClaimsIdentity(
new[] { new Claim(ClaimTypes.NameIdentifier, account.Id), new Claim(ClaimTypes.Role, account.Role) },
Scheme.Name);
return Task.FromResult(AuthenticateResult.Success(new AuthenticationTicket(new ClaimsPrincipal(identity), Scheme.Name)));
}With IMPL=wrong, 4 of 9 tests fail (2 exploit tests).
Routes: one scheme, RequireAuthorization on every protected endpoint
// /health is the one public endpoint. /me and /admin/metrics are critical functions: the
// authentication handler must establish who the caller is from a credential it verifies, and
// RequireAuthorization answers 401 when it cannot. Nothing here reads identity from the request.
app.MapGet("/health", () => Results.Ok(new { status = "ok" }));
app.MapGet("/me", (ClaimsPrincipal user, AccountsDb db) =>
db.FindUser(user.FindFirstValue(ClaimTypes.NameIdentifier) ?? "") is { } account
? Results.Ok(account)
: Results.NotFound(new { error = "not found" }))
.RequireAuthorization();
app.MapGet("/admin/metrics", (AccountsDb db) => Results.Ok(new { userCount = db.CountUsers() }))
.RequireAuthorization();The exploit tests send the attack and check that it fails; functional tests next to them keep ordinary requests working, so a fix that rejects everything fails too.
/// <summary>
/// Exploit: reachable without a credential, or defeated by a header the client sets. Both are
/// missing authentication; each request must get 401 and leak no profile.
/// </summary>
public sealed class ExploitTests(AccountsApiFactory factory) : AccountsApiTests(factory)
{
private async Task AssertRefused(string path, params (string, string)[] headers)
{
var (status, body) = await Get(path, headers);
Assert.True(status == HttpStatusCode.Unauthorized, $"expected 401, got {(int)status}: {body}");
Assert.DoesNotContain("@example.com", body);
}
[Fact]
public Task InternalMetricsAreNotReachableWithoutACredential() => AssertRefused("/admin/metrics");
[Fact]
public Task ProfileCannotBeReadByNamingAUserInTheQueryString() => AssertRefused("/me?userId=bob");
[Fact]
public Task ForgedIdentityHeaderDoesNotReachTheInternalMetrics() =>
AssertRefused("/admin/metrics", ("X-Authenticated-User", "sam"));
[Fact]
public Task ForgedIdentityHeaderDoesNotAuthenticateAProfileRead() =>
AssertRefused("/me", ("X-Authenticated-User", "bob"));
}- An AuthenticationHandler that always returns Success (here from ?userId= or a guest) satisfies RequireAuthorization for every request. RequireAuthorization checks that a principal exists, not how it was proven. That is this card's vulnerable implementation.
- Trusting a header that a gateway or reverse proxy is meant to set (X-Authenticated-User, X-Forwarded-User, X-MS-CLIENT-PRINCIPAL outside App Service). If the app is reachable without the proxy, the client sets it. That is this card's wrong implementation.
- Forgetting RequireAuthorization on a new endpoint: without a FallbackPolicy, an endpoint with no metadata is anonymous. Group protected endpoints or set a fallback policy so new ones are protected by default.
- Returning Success for an unknown token instead of Fail or NoResult, or catching exceptions in the handler and signing the caller in as a default user.
- A metrics or health endpoint that returns internal counts, versions or configuration is a critical function; keep it behind the policy.
- Authentication is not authorization: after the handler names the user, still check that the user may read the specific object.
- Run the tests in all three modes: IMPL=fixed must pass; IMPL=vulnerable and IMPL=wrong must fail (node ../../run-all.mjs missing-authentication/csharp-aspnetcore does this for you).
- Against your own API, call every protected endpoint with no Authorization header, with a forged X-Authenticated-User (or whatever header your proxy sets) and with a random bearer token. Each must get 401 and no data.
- Do not expect a scanner to find this: Semgrep's C# packs reported nothing here, and CodeQL's access-control queries look at MVC actions, not at what an authentication handler accepts. The regression tests are the control.
What each rule reported when it was run on this card's code, or what its source shows it covers where it was not run. A rule that exists for the weakness does not always catch it.
| Tool | Rule | What it found here |
|---|---|---|
| Semgrep | p/csharp | 0 findings and 0 parse errors on src/. The packs' one authentication-related C# rule (missing-or-broken-authorization) only matches classes deriving from Controller; this card uses minimal-API endpoints and AuthenticationHandler subclasses, so the three handlers look the same to every rule.Checked: Semgrep 1.180.0 (CE) in semgrep/semgrep:1.180.0 (semgrep scan --config p/csharp --config p/owasp-top-ten src) |
| CodeQL | cs/web/missing-function-level-access-control | Not expected to flag this card. It looks for MVC and Web Forms edit, delete and admin actions with no sign of an authorization check; it does not consider minimal-API endpoints, and it cannot tell a handler that verifies a session from one that trusts a header or the query string.Checked: github/codeql main; not executed |
No Semgrep rule, CodeQL query or .NET analyzer checks what an AuthenticationHandler accepts as proof of identity. RequireAuthorization only asks whether some handler produced a principal; a handler that signs in anyone satisfies it. The regression tests are the control.
| Component | Version |
|---|---|
| Language | C# 14 |
| Runtime | .NET 10 (SDK 10.0.112, runtime 10.0.12) |
| Framework | ASP.NET Core 10 minimal API (authentication handler + RequireAuthorization) |
| Microsoft.Data.Sqlite | 10.0.12 (SQLitePCLRaw 2.1.12) |
| Microsoft.AspNetCore.Mvc.Testing | 10.0.12 |
| xunit.v3 | 4.0.2 (Microsoft Testing Platform 2.5.1) |
Download the project, install its dependencies once (this step needs the network), then run the tests against each implementation from the missing-authentication/csharp-aspnetcorefolder.
dotnet restore tests/Accounts.Tests --locked-mode# Expected: every test passes
IMPL=fixed dotnet test --project tests/Accounts.Tests --no-restore --results-directory reports --report-xunit-junit --report-xunit-junit-filename junit.xml
# Expected: an exploit test fails
IMPL=vulnerable dotnet test --project tests/Accounts.Tests --no-restore --results-directory reports --report-xunit-junit --report-xunit-junit-filename junit.xml
# Expected: a test fails
IMPL=wrong dotnet test --project tests/Accounts.Tests --no-restore --results-directory reports --report-xunit-junit --report-xunit-junit-filename junit.xmlEach run writes a JUnit report to reports/junit.xml. From the top folder of the download, node run-all.mjs missing-authentication/csharp-aspnetcore runs all three modes and checks the expected results.
Verified
- Machine
- macOS 27.0 (Darwin 27.0.0, arm64)
- Toolchain
- dotnetSdk 10.0.112
- aspNetCoreRuntime 10.0.12
- microsoftDataSqlite 10.0.12
- aspnetMvcTesting 10.0.12
- xunitV3 4.0.2
- microsoftTestingPlatform 2.5.1
- Matrix
- fixed: 9 of 9 tests pass; vulnerable: 7 of 9 tests fail (4 exploit tests); wrong: 4 of 9 tests fail (2 exploit tests)
- Test results
- IMPL=fixed 9/9 pass; IMPL=vulnerable 7/9 fail (4 exploit: metrics open, a profile read by ?userId=, and the forged header reaching each endpoint, which the guest sign-in lets through to a 404 instead of a 401); IMPL=wrong 4/9 fail (2 exploit: the forged header reaching each endpoint, plus the two valid-session reads it cannot serve).
Versions and results hold for the toolchain above. The card's tests are the check: run them with your own versions before relying on the fix.
References
- OWASP Top 10:2025 A07 Authentication Failures
- OWASP API Security Top 10 2023: API2 Broken Authentication
- CWE-306: Missing Authentication for Critical Function
- OWASP Authentication Cheat Sheet
- Microsoft Learn: Authentication and authorization in Minimal APIs (ASP.NET Core 10)
- Microsoft Learn: Overview of ASP.NET Core authentication (handlers, challenge)
- Microsoft Learn: Require authenticated users (fallback policy)
Attack patterns: