Skip to content

CWE-287: Improper Authentication - C

Overview

In ASP.NET Core, authentication is normally handled by ASP.NET Core Identity (cookie-based sign-in) or the JWT bearer handler (Microsoft.AspNetCore.Authentication.JwtBearer), both of which build a ClaimsPrincipal that the rest of the pipeline is meant to trust. Improper authentication shows up when that trust boundary is broken in application code: an endpoint missing [Authorize], a claims transformation or custom middleware that reads an identity flag straight from a cookie or header instead of the validated principal, or TokenValidationParameters configured so loosely that a forged or expired JWT still passes. Each of these lets a request reach protected code without ever proving who it is.

The fix is almost always the same shape: let the framework's authentication middleware build the ClaimsPrincipal, require [Authorize] (or an equivalent policy) on every protected endpoint, and configure TokenValidationParameters to validate signature, issuer, audience, and lifetime explicitly rather than relying on defaults.

Common Vulnerable Patterns

Missing [Authorize] on a Controller or Minimal API Endpoint

// VULNERABLE - no authentication check; anyone can call this action directly
[ApiController]
[Route("api/accounts")]
public class AccountsController : ControllerBase
{
    private readonly IAccountService _accounts;

    public AccountsController(IAccountService accounts) => _accounts = accounts;

    [HttpGet("{accountId}/balance")]
    public IActionResult GetBalance(string accountId)
    {
        // Reachable without ever authenticating - the UI just happens not to link here
        return Ok(_accounts.GetBalance(accountId));
    }
}

// Attack example:
// curl https://app.example.com/api/accounts/12345/balance
// Result: balance data returned with no credentials of any kind

Why this is vulnerable: A missing [Authorize] attribute (or missing fallback policy) is invisible in the UI, which only calls endpoints it links to, but any endpoint is directly reachable by URL. Relying on "the UI doesn't expose this" is not an authentication control.

Trusting a Client-Supplied Identity Claim

// VULNERABLE - trusts a cookie value the client fully controls
[HttpGet("admin/dashboard")]
public IActionResult AdminDashboard()
{
    if (Request.Cookies["IsAdmin"] == "true")
    {
        return View("Dashboard");
    }
    return Forbid();
}

// Attack example:
// Set-Cookie in browser dev tools: IsAdmin=true
// Result: attacker reaches the admin dashboard with no valid session at all

Why this is vulnerable: Nothing here proves the request came from an authenticated, authorized administrator - IsAdmin is a plain cookie the client can set to any value. Identity and role checks must come from the ClaimsPrincipal that authentication middleware built after validating a credential, never from raw request state.

JWT Validation Missing Signature, Issuer, Audience, or Expiration Checks

// VULNERABLE - a custom SignatureValidator that returns the token without checking it
services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
    .AddJwtBearer(options =>
    {
        options.TokenValidationParameters = new TokenValidationParameters
        {
            ValidateIssuer = false,
            ValidateAudience = false,
            ValidateLifetime = false,
            SignatureValidator = (token, parameters) => new JwtSecurityToken(token) // never checks the signature
        };
    });

// Attack example:
// A token with an expired exp claim, a forged issuer, or no signature at all
// still produces a valid ClaimsPrincipal because nothing was actually verified

Why this is vulnerable: SignatureValidator overrides the handler's built-in verification entirely - any string that parses as a JWT is accepted. Turning off ValidateIssuer, ValidateAudience, and ValidateLifetime means a token minted for a different application, or one that expired hours ago, is still treated as a valid, current session.

Secure Patterns

Require Authentication by Default, Not Per-Endpoint

// SECURE - a fallback policy makes "authenticated" the default for every endpoint
var builder = WebApplication.CreateBuilder(args);

builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
    .AddJwtBearer(/* see TokenValidationParameters example below */);

builder.Services.AddAuthorization(options =>
{
    options.FallbackPolicy = new AuthorizationPolicyBuilder()
        .RequireAuthenticatedUser()
        .Build();
});

var app = builder.Build();
app.UseAuthentication();
app.UseAuthorization();

// Individual endpoints that must be public opt out explicitly and visibly:
app.MapGet("/health", () => Results.Ok()).AllowAnonymous();

Why this works: With a fallback policy, every endpoint requires an authenticated principal unless it is explicitly marked [AllowAnonymous]/.AllowAnonymous(). The default flips from "public unless protected" to "protected unless public": a forgotten [Authorize] attribute can no longer leave an endpoint open, and each public endpoint is a deliberate, reviewable exception.

Read Identity Only From the Validated ClaimsPrincipal

// SECURE - role/identity decisions come from the authenticated principal, not client state
[Authorize(Roles = "Administrator")]
[HttpGet("admin/dashboard")]
public IActionResult AdminDashboard()
{
    // User.Identity is populated by authentication middleware from a verified
    // cookie or JWT - it cannot be set by an arbitrary client-supplied cookie/header
    var adminId = User.FindFirstValue(ClaimTypes.NameIdentifier);
    return View("Dashboard", adminId);
}

Why this works: [Authorize(Roles = ...)] and User.FindFirstValue read claims from the ClaimsPrincipal that ASP.NET Core's authentication middleware built after validating a cookie or JWT signature. An attacker cannot forge a role claim by setting a cookie or header value directly; the claim only exists if it was present in a credential the server already verified.

Pin JWT Validation to Explicit Algorithms, Issuer, Audience, and Lifetime

// SECURE - every validation dimension is explicit; nothing falls back to a permissive default
services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
    .AddJwtBearer(options =>
    {
        options.TokenValidationParameters = new TokenValidationParameters
        {
            ValidateIssuerSigningKey = true,
            IssuerSigningKey = new SymmetricSecurityKey(
                Convert.FromBase64String(builder.Configuration["Jwt:SigningKey"]!)),
            ValidAlgorithms = new[] { SecurityAlgorithms.HmacSha256 },

            ValidateIssuer = true,
            ValidIssuer = builder.Configuration["Jwt:Issuer"],

            ValidateAudience = true,
            ValidAudience = builder.Configuration["Jwt:Audience"],

            ValidateLifetime = true,
            ClockSkew = TimeSpan.FromMinutes(1)
            // No SignatureValidator/TokenReader override - the built-in validator runs
        };
    });

Why this works: Leaving the default signature validator in place means every token is cryptographically verified against the configured key before any claim is trusted. ValidAlgorithms stops an attacker from switching the token's alg header to a weaker or unexpected algorithm. ValidIssuer/ValidAudience stop a token minted for a different application or environment from being accepted here, and ValidateLifetime with a tight ClockSkew enforces exp/nbf instead of accepting stale tokens indefinitely.

Framework-Specific Guidance

ASP.NET Core Identity - Account Lockout and Sign-In

// SECURE - lockout is enabled, and an unknown username costs what a wrong password costs
var user = await _userManager.FindByNameAsync(username);
if (user is null)
{
    // PasswordSignInAsync(string userName, ...) returns SignInResult.Failed without
    // hashing at all when the name is unknown, which times every username for free.
    _decoy.Verify(password);
    return Unauthorized("Invalid username or password.");
}

var result = await _signInManager.PasswordSignInAsync(
    user, password, isPersistent: false, lockoutOnFailure: true);

if (result.IsLockedOut)
{
    return Unauthorized("Account locked due to repeated failed sign-in attempts.");
}
if (!result.Succeeded)
{
    return Unauthorized("Invalid username or password.");
}

// DecoyCredential.cs - register with builder.Services.AddSingleton<DecoyCredential>()
// so the hash is produced once at start-up rather than on every request.
public sealed class DecoyCredential
{
    private static readonly ApplicationUser Placeholder = new() { UserName = "decoy" };
    private readonly IPasswordHasher<ApplicationUser> _hasher;
    private readonly string _hash;

    public DecoyCredential(IPasswordHasher<ApplicationUser> hasher)
    {
        _hasher = hasher;
        // A real Identity hash from the application's own hasher, so it costs what a
        // real verification costs and stays correct if PasswordHasherOptions raises
        // the iteration count. It is not a secret, so a fresh value per process is fine.
        _hash = hasher.HashPassword(Placeholder, Guid.NewGuid().ToString());
    }

    // Spends what a real verification spends; the result is deliberately discarded.
    public void Verify(string password) =>
        _hasher.VerifyHashedPassword(Placeholder, _hash, password);
}

// Program.cs / Startup.cs
services.Configure<IdentityOptions>(options =>
{
    options.Lockout.MaxFailedAccessAttempts = 5;
    options.Lockout.DefaultLockoutTimeSpan = TimeSpan.FromMinutes(15);
});

PasswordSignInAsync(..., lockoutOnFailure: false) is a common finding - it disables Identity's built-in lockout counter, leaving an unlimited guessing window against a single account. lockoutOnFailure: true combined with IdentityOptions.Lockout turns repeated failures into a temporary lockout instead.

The lookup before the sign-in call is not tidying. SignInManager<TUser>.PasswordSignInAsync(string userName, ...) resolves the name with FindByNameAsync and returns SignInResult.Failed when it comes back null, without hashing anything - so the two failures cost different amounts. Measured on .NET 10 with the default PasswordHasher<TUser> at 100,000 iterations, a wrong password took 63 ms and an unknown username 0.003 ms, a 22,000x difference that enumerates the user table without a single distinguishable response body. Looking the user up yourself, hashing a decoy when the row is missing, and then calling the TUser overload brings all three timings to roughly 71 ms. The decoy hash has to be produced by the configured hasher rather than pasted in as a literal, because Identity v3 reads the iteration count out of the stored hash: a literal minted at 100,000 iterations keeps costing 100,000 even after PasswordHasherOptions.IterationCount is raised, and the gap quietly reopens. Both branches now pay full hashing cost, so keep the rate limiting below.

The same shape applies to every lookup-then-verify flow on the authentication surface, not just sign-in: password reset token redemption, API key checks, and TOTP validation all answer "does this record exist" through response time if the missing-record branch is the cheap one.

// SECURE - sign out any existing authentication cookie before establishing a new one
await HttpContext.SignOutAsync(IdentityConstants.ApplicationScheme);
var result = await _signInManager.PasswordSignInAsync(username, password, isPersistent: false, lockoutOnFailure: true);
// SignInManager issues a brand-new authentication cookie/ticket on success -
// it is never reused from a pre-login request

ASP.NET Core Identity's SignInManager already issues a new authentication cookie on every successful sign-in rather than reusing an existing one, which is the framework's built-in defense against session fixation for cookie authentication. If a custom session store (for example a server-side session ID kept in ISession or a bespoke session table) is layered on top of Identity, regenerate that session identifier at the same point - on successful sign-in, not before - so a session ID an attacker set before login cannot be inherited by the authenticated user.

Rate Limiting and Authentication Logging

Neither control stops a credential check from being wrong, but both change how long an attacker can keep trying and whether anyone notices. Apply Microsoft.AspNetCore.RateLimiting to the login and token endpoints specifically rather than globally - a limit generous enough for normal API traffic is useless against password guessing. Log authentication outcomes, successes as well as failures, with enough context to distinguish one account under attack from broad credential stuffing, and never log the submitted credential itself.

Testing

  • Call a protected endpoint with no Authorization header - expect 401 Unauthorized, not a redirect to a login page that a script would otherwise follow silently.
  • Call it with a JWT whose exp is in the past - expect 401.
  • Re-sign a valid token's header to alg: none or swap HS256 for a mismatched key, and confirm AddJwtBearer rejects it.
  • Attempt the cookie/header trust bypass directly - set IsAdmin=true or a similar client-controlled flag and confirm it has no effect on authorization decisions.
  • Write an integration test (WebApplicationFactory<Program>) that submits six consecutive wrong passwords and asserts the sixth attempt returns a lockout result, not another "invalid credentials" response.
  • Time three sign-ins - known username with the right password, known username with a wrong password, unknown username - and assert all three are within noise of each other. A sub-millisecond answer for the unknown username is the enumeration oracle, and a re-scan cannot see it.
  • Re-scan with the security tool that originally reported the finding to confirm it no longer fires.

Common Pitfalls

  • Protecting the MVC controller but not a sibling minimal API or Razor Page route that exposes the same data - a fallback authorization policy (see Secure Patterns) closes this gap; per-endpoint [Authorize] audits miss endpoints added later.
  • Setting ValidateIssuer/ValidateAudience to false "temporarily" for local testing and leaving that configuration reachable in a shared appsettings.json - split configuration per environment so relaxed validation cannot ship to production by omission.
  • Adding a custom IClaimsTransformation that enriches the principal with data read from a request header or cookie instead of a data store keyed by the already-authenticated identity - this reintroduces a client-trust bypass one layer downstream of a correctly configured JWT handler.
  • Disabling lockoutOnFailure on an API login endpoint "because it's an API, not the UI" while the web login form enforces lockout - both surfaces authenticate the same accounts, so both need the same brute-force protection.

Dependencies and Installation

  • Microsoft.AspNetCore.Authentication.JwtBearer - install the package version matching the target ASP.NET Core version; it ships as part of the shared framework reference for in-process hosting in most templates. Target .NET 10 for new work: it is the current LTS, where .NET 6 left support in November 2024 and .NET 8 leaves it in November 2026.
  • Microsoft.AspNetCore.Identity.EntityFrameworkCore - only needed if persisting Identity users/roles with Entity Framework Core.
  • Store Jwt:SigningKey and any Identity connection strings in an IConfiguration provider backed by a secret store (Azure Key Vault, or dotnet user-secrets for local development), never as a literal in source. An environment variable is a way for the deployment platform to hand the process the key at start-up, not a place to keep it - see CWE-526.

Migration Considerations

Tightening TokenValidationParameters (adding ValidAlgorithms, enabling ValidateIssuer/ValidateAudience, or removing a custom SignatureValidator) will reject tokens that a looser configuration previously accepted, including tokens already issued to active clients. Roll this out with monitoring for a spike in 401 responses, coordinate the change with whatever issues the tokens (an internal auth service, an external identity provider) so newly issued tokens already match the tightened rules, and communicate a cutover window to client teams before the relaxed validation is fully removed. Enabling lockoutOnFailure similarly changes behavior for legitimate users who mistype a password repeatedly - document the lockout duration and self-service unlock/reset path before enabling it in production.

Additional Resources