OWASP A07: Authentication Failures (1) — Brute Force, Lockout and NIST Password Rules
We start with a login endpoint that accepts unlimited attempts and reveals whether a user exists through its message and its response time. We separate brute force from credential stuffing, compare Identity's default password rules with NIST SP 800-63B-4, fix it with a blocklist validator, lockout and a uniform response, and take a short look at MFA and passkeys.
Version note: This post follows the OWASP Top 10:2025; the code was tested with ASP.NET Core 10 (.NET 10).
Guess First
You send twenty wrong passwords in a row for the same account to the Bookstore API login endpoint. What happens if you type the right password on the twenty-first request? Now try an email address that doesn't exist: does the response tell you that address isn't registered? Even if it doesn't, does the time it takes for the response to arrive tell you anything?
You probably guessed the first two answers. The third one, the response time, is the part most code reviews miss, and in a moment we will see it in milliseconds.
A07:2025 Authentication Failures
A07:2025 Authentication Failures is the shortened form of 2021's "Identification and Authentication Failures"; it stayed in seventh place and maps 36 CWEs. The notable ones are improper authentication (CWE-287), session fixation (CWE-384) and hard-coded credentials (CWE-798). We leave sessions and tokens to the next post and look at the front door itself in this one.
Knowing the difference between the two classic attacks on a login screen matters when you choose a defense. Brute force tries many passwords against a single account. Credential stuffing takes email and password pairs leaked from another site and tries each account once or a few times. Because people reuse passwords across sites, the second is far more effective, and because there are few attempts per account, account lockout alone doesn't stop it. That is why OWASP's A07 page points to multi-factor authentication (MFA) and NIST's password guidance.
We run every example in the lab on your own machine; only try these techniques on systems you own or have written permission to test. The passwords in the lab are obviously fake, too.
The Vulnerable Login Endpoint
This time the lab uses ASP.NET Core Identity with EF Core and SQLite. AppDbContext derives from IdentityDbContext<IdentityUser>, and on startup the user ada@example.com is created with the password Dev-only-Bookstore-2026. The first version looks like this:
builder.Services.AddDbContext<AppDbContext>(o => o.UseSqlite("Data Source=identity.db"));
builder.Services.AddIdentityCore<IdentityUser>()
.AddEntityFrameworkStores<AppDbContext>();
...
app.MapPost("/login", async (LoginRequest req, UserManager<IdentityUser> users) =>
{
IdentityUser? user = await users.FindByEmailAsync(req.Email);
if (user is null) return Results.NotFound(new { error = "No user with this email" });
if (!await users.CheckPasswordAsync(user, req.Password))
return Results.BadRequest(new { error = "Wrong password" });
return Results.Ok(new { message = "Welcome" });
});
Passwords are stored with Identity's PasswordHasher, so the storage side I covered in the A04 post is right. The problem is the door itself. Start the app with dotnet run --urls http://localhost:5142 and send the right password after twenty wrong attempts:
$ for i in $(seq 1 20); do curl -s -o /dev/null -w "%{http_code} " -X POST http://localhost:5142/login -H "Content-Type: application/json" -d "{\"email\":\"ada@example.com\",\"password\":\"guess-$i\"}"; done
400 400 400 400 400 400 400 400 400 400 400 400 400 400 400 400 400 400 400 400
$ curl -i -X POST http://localhost:5142/login -H "Content-Type: application/json" -d '{"email":"ada@example.com","password":"Dev-only-Bookstore-2026"}'
HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
...
{"message":"Welcome"}
There is no limit at all. Twenty attempts took less than a second; an attacker can run the same loop with a password list for hours, and the system's only reaction is the same 400 every time. CheckPasswordAsync never touches the lockout counter; the methods that drive the counter live on SignInManager.
Responses That Give Users Away
The second problem is the content of the response. Try the same wrong password with an address that doesn't exist and one that does:
$ curl -i -X POST http://localhost:5142/login -H "Content-Type: application/json" -d '{"email":"grace@example.com","password":"anything"}'
HTTP/1.1 404 Not Found
...
{"error":"No user with this email"}
$ curl -i -X POST http://localhost:5142/login -H "Content-Type: application/json" -d '{"email":"ada@example.com","password":"anything"}'
HTTP/1.1 400 Bad Request
...
{"error":"Wrong password"}
This is user enumeration: the attacker first runs an email list through this endpoint to learn which addresses belong to your customers, then goes after only those. Equalizing the messages isn't enough, because time talks too:
$ curl -s -o /dev/null -w "unknown %{time_total}\n" -X POST http://localhost:5142/login -H "Content-Type: application/json" -d '{"email":"nobody@example.com","password":"x"}'
unknown 0.000877
$ curl -s -o /dev/null -w "known %{time_total}\n" -X POST http://localhost:5142/login -H "Content-Type: application/json" -d '{"email":"ada@example.com","password":"x"}'
known 0.030895
When the user doesn't exist, the code returns without computing any hash and the response arrives in under a millisecond. When the user exists, PBKDF2's 100,000 iterations run and the response takes 30 milliseconds. A thirtyfold difference is measurable even through network noise.
NIST SP 800-63B-4 and Identity's Password Rules
Before moving to registration, let's look at Identity's defaults. The lab has a small endpoint that creates an empty IdentityOptions and prints its values:
IdentityOptions defaults = new();
PasswordOptions p = defaults.Password;
LockoutOptions l = defaults.Lockout;
$ curl http://localhost:5142/defaults
RequiredLength=6 RequiredUniqueChars=1
RequireDigit=True RequireLowercase=True RequireUppercase=True RequireNonAlphanumeric=True
MaxFailedAccessAttempts=5 DefaultLockoutTimeSpan=00:05:00 AllowedForNewUsers=True
Six characters, but a digit, a lowercase letter, an uppercase letter and a symbol are mandatory. This is the composition rule model taught for years as "strong passwords". Its result is easy to see on the register endpoint:
$ curl -X POST http://localhost:5142/register -H "Content-Type: application/json" -d '{"email":"alan@example.com","password":"correct horse battery staple"}'
["Passwords must have at least one digit ('0'-'9').","Passwords must have at least one uppercase ('A'-'Z')."]
$ curl -X POST http://localhost:5142/register -H "Content-Type: application/json" -d '{"email":"grace@example.com","password":"Password1!"}'
{"message":"Registered"}
A 28-character passphrase was rejected, while Password1!, the very pattern you keep seeing in leaked password lists, was accepted. NIST's SP 800-63B-4 guideline, final since July 2025, drops exactly this model. If the password is a single factor it must be at least 15 characters; if it is only used as part of MFA, at least 8; the permitted maximum should be at least 64. Imposing composition rules is explicitly forbidden (SHALL NOT), and so is requiring periodic password changes; a change is mandatory only when there is evidence of compromise. In return, every new password must be checked against a blocklist of breached passwords, dictionary words and context-specific words (the site name, the username), and the user must be told why it was rejected. Password managers and pasting should be allowed, and security questions must not be used.
In Identity this takes two pieces. First the options:
builder.Services.AddAuthentication(IdentityConstants.ApplicationScheme).AddIdentityCookies();
builder.Services.AddIdentityCore<IdentityUser>(options =>
{
// NIST SP 800-63B-4: length yes, composition rules no
options.Password.RequiredLength = 15;
options.Password.RequireDigit = false;
options.Password.RequireLowercase = false;
options.Password.RequireUppercase = false;
options.Password.RequireNonAlphanumeric = false;
options.Lockout.MaxFailedAccessAttempts = 5;
options.Lockout.DefaultLockoutTimeSpan = TimeSpan.FromMinutes(15);
})
.AddEntityFrameworkStores<AppDbContext>()
.AddSignInManager()
.AddPasswordValidator<BlocklistPasswordValidator>();
Then our own IPasswordValidator<TUser> for the blocklist:
public sealed class BlocklistPasswordValidator : IPasswordValidator<IdentityUser>
{
// In reality a list of breached passwords; a few samples here
private static readonly HashSet<string> Blocked = new(StringComparer.OrdinalIgnoreCase)
{
"123456789012345", "passwordpassword", "qwertyuiopasdfgh", "bookstorebookstore"
};
public Task<IdentityResult> ValidateAsync(UserManager<IdentityUser> manager, IdentityUser user, string? password)
{
if (password is not null && (Blocked.Contains(password)
|| password.Contains(user.UserName ?? "\0", StringComparison.OrdinalIgnoreCase)))
{
return Task.FromResult(IdentityResult.Failed(new IdentityError
{
Code = "BlockedPassword",
Description = "This password is too common or contains your email. Please choose another one."
}));
}
return Task.FromResult(IdentityResult.Success);
}
}
In a real application Blocked isn't a handful of words but a large list of breached passwords, read on the server from a file or a database table. Let's try the same three registrations again:
$ curl -X POST http://localhost:5142/register -H "Content-Type: application/json" -d '{"email":"alan@example.com","password":"correct horse battery staple"}'
{"message":"Registered"}
$ curl -X POST http://localhost:5142/register -H "Content-Type: application/json" -d '{"email":"grace@example.com","password":"Password1!"}'
["Passwords must be at least 15 characters."]
$ curl -X POST http://localhost:5142/register -H "Content-Type: application/json" -d '{"email":"linus@example.com","password":"passwordpassword"}'
["This password is too common or contains your email. Please choose another one."]
Lockout and a Uniform Response
The login endpoint now calls SignInManager.CheckPasswordSignInAsync with lockoutOnFailure: true and returns the same response for every failure:
app.MapPost("/login", async (LoginRequest req, UserManager<IdentityUser> users,
SignInManager<IdentityUser> signIn, IPasswordHasher<IdentityUser> hasher, ILogger<Program> logger) =>
{
IdentityUser? user = await users.FindByEmailAsync(req.Email);
if (user is null)
{
// Verify a hash even when the user doesn't exist so timing gives nothing away
hasher.VerifyHashedPassword(DummyUser.Instance, DummyUser.Hash, req.Password);
return Results.Problem("Invalid email or password.", statusCode: StatusCodes.Status401Unauthorized);
}
SignInResult result = await signIn.CheckPasswordSignInAsync(user, req.Password, lockoutOnFailure: true);
if (result.IsLockedOut)
logger.LogWarning("Account {UserId} is locked out", user.Id);
if (!result.Succeeded)
return Results.Problem("Invalid email or password.", statusCode: StatusCodes.Status401Unauthorized);
return Results.Ok(new { message = "Welcome" });
});
DummyUser.Hash is a fake password hash computed once at startup. Even when no user is found, a verification of the same cost runs. Because SignInManager needs the authentication services, the AddAuthentication line is required as well; delete it and the first request fails with Unable to resolve service for type 'Microsoft.AspNetCore.Authentication.IAuthenticationSchemeProvider'. Now one wrong attempt, four more, and then the right password:
$ curl -i -X POST http://localhost:5142/login -H "Content-Type: application/json" -d '{"email":"ada@example.com","password":"anything"}'
HTTP/1.1 401 Unauthorized
Content-Type: application/problem+json
...
{"type":"https://tools.ietf.org/html/rfc9110#section-15.5.2","title":"Unauthorized","status":401,"detail":"Invalid email or password."}
$ for i in $(seq 1 4); do curl -s -o /dev/null -w "%{http_code} " -X POST http://localhost:5142/login -H "Content-Type: application/json" -d "{\"email\":\"ada@example.com\",\"password\":\"guess-$i\"}"; done
401 401 401 401
$ curl -i -X POST http://localhost:5142/login -H "Content-Type: application/json" -d '{"email":"ada@example.com","password":"Dev-only-Bookstore-2026"}'
HTTP/1.1 401 Unauthorized
Content-Type: application/problem+json
...
{"type":"https://tools.ietf.org/html/rfc9110#section-15.5.2","title":"Unauthorized","status":401,"detail":"Invalid email or password."}
On the fifth failed attempt the account was locked for 15 minutes, and the right password was rejected too. The response for the nonexistent grace@example.com is byte-for-byte the same. We don't tell the client about the lockout, but we see it in the server log:
warn: Program[0]
Account 34cefe7c-803c-4fc5-b6b2-3a7371c93dd2 is locked out
Repeating the timing measurement with a nonexistent address and with the registered alan@example.com shows the gap is closed as well:
unknown 0.029102
known 0.030123
unknown 0.029194
known 0.033231
Lockout has a price: an attacker can deliberately make five wrong attempts with your email and keep you out of your account for 15 minutes. So keep the lockout short, notify the user of the lockout by email, and combine per-account lockout with per-IP or per-device rate limiting. Against credential stuffing, the main layer is a limit per request source, not per account; attach the rate limiter from the previous post to the login endpoint too.
MFA and Passkeys
Even the best password policy doesn't prevent passwords from leaking; MFA is what makes a leaked password insufficient on its own. Identity has built-in support for TOTP-based two-factor authentication with authenticator apps. If TwoFactorEnabled is on for a user, PasswordSignInAsync returns RequiresTwoFactor even for a correct password, and the flow moves on to the second step. Note: the CheckPasswordSignInAsync we use in this post only checks the password and the lockout; it doesn't look at the second factor. If you use MFA, make sure the flow that signs the user in also asks for the second factor.
NIST SP 800-63B-4 doesn't consider manually entered one-time codes phishing-resistant, and at AAL2 it requires at least one phishing-resistant option to be offered. Today the most practical answer is passkeys: WebAuthn and FIDO2 credentials whose key lives on the device or in a password manager. NIST doesn't use that name, but it allows such syncable authenticators up to AAL2. In .NET 10, ASP.NET Core Identity gained passkey support, and the Blazor Web App template ships with passkey management. A curious detail: OWASP's A07:2025 page recommends MFA but never mentions the word passkey.
Common Pitfalls
The first pitfall is thinking you turned lockout on. If you set MaxFailedAccessAttempts and call CheckPasswordAsync on login, the counter never increases. What drives the counter is the lockoutOnFailure: true parameter, and it exists only on SignInManager methods. Also check whether older users still have the LockoutEnabled column set to false.
The second is equalizing the message and forgetting the other channels. The status code, the response time, an "this email is already in use" message on the registration form and a "no such user" answer on the password reset screen all leak the same information. On password reset, always saying "if the address is registered, we sent an email" is the standard fix.
The third is keeping composition rules as "extra security". The rules push users toward predictable patterns like Password1!, and NIST explicitly forbids them. Likewise, a forced password change every 90 days leads users to Password2!. Length and a blocklist replace the rules.
The fourth is leaving the login endpoint out of rate limiting. Per-account lockout slows brute force down; it doesn't slow credential stuffing. The two have to work together, and both should produce logs; in the A09 post we will see how to turn those logs into alerts.
Checklist
On a failed attempt, does the login endpoint return the same status code, the same body and roughly the same timing whether or not the user exists? Is lockout really active, meaning is it called with lockoutOnFailure: true, and is a lockout logged?
Does your password policy require at least 15 characters, impose no composition rules and check new passwords against a blocklist? Has the forced periodic change been removed? Do the login, registration and password reset endpoints have rate limiting? Is MFA offered to users and required for admin accounts? If you are on .NET 10, have you considered the passkey option?
A Small Experiment
★☆☆ Repeat the timing measurement yourself on the vulnerable version, then comment out the VerifyHashedPassword line in the fixed version and measure again. Watch the gap come back.
★★☆ Change BlocklistPasswordValidator to read from a text file and put a sample list of ten thousand lines in it. Measure how the check affects registration time; see what changes when you keep the list in a List instead of a HashSet.
★★★ Add the rate limiter from the previous post to the login endpoint, with the client IP as the partition key. Then simulate a credential stuffing scenario with a curl loop that tries different emails from the same IP, and watch which layer (lockout or rate limiting) stops you.
We made sure the right person gets through the door. In the next post we look at how the identity of the user who got in is carried on every request: cookie flags, session fixation, JWT pitfalls, and CSRF, which no longer has its own Top 10 heading but is still very much alive.