Skip to main content
Web Security (OWASP Top 10, fix-side) · lesson 6 of 6
Web Security (OWASP Top 10, fix-side) intermediate Lesson 6 of 6

Also part of: Ethical Hacking (methodology, lab-only)

Authentication Failures: Username Enumeration and Login Rate Limiting

A Flask login reveals which usernames exist and allows unlimited guesses. Fix it with one generic error, equal timing and a per-account attempt limit.

By SkillByExample · Published

web-security owasp authentication rate-limiting flask pytest

A login form has two jobs beyond checking a password: it must not tell strangers which accounts exist, and it must not let them guess forever. This lesson builds a Flask login that fails both, proves it with pytest, and fixes it with one generic error, a dummy hash check and a per-account attempt limit. Everything runs through Flask’s test client, with no server and no network. To learn the attack side, see PortSwigger’s authentication labs.

The lab uses the same environment as the first lesson: Python 3.12, Flask 3.1.3 and pytest 9.1.1. Passwords are stored with Werkzeug’s generate_password_hash, which uses scrypt by default.

A login that answers too precisely

Two users, each with a hashed password. The route checks the username first, then the password, and says which one was wrong.

# app.py
from flask import Flask, request
from werkzeug.security import check_password_hash, generate_password_hash

app = Flask(__name__)
USERS = {
    "alice": generate_password_hash("correct horse battery staple"),
    "bob": generate_password_hash("hunter2-but-longer"),
}


@app.post("/login")
def login():
    user, pw = request.form["username"], request.form["password"]
    if user not in USERS:
        return {"error": "no such user"}, 401
    if not check_password_hash(USERS[user], pw):
        return {"error": "wrong password"}, 401
    return {"ok": True}

Two tests for two rules

A failed login must look the same whether or not the user exists. After five wrong passwords, even the right password must be refused for a while.

# test_login.py
from app import app

client = app.test_client()


def login(user, pw):
    return client.post("/login", data={"username": user, "password": pw})


def test_errors_do_not_reveal_which_part_was_wrong():
    assert login("mallory", "x").json == login("bob", "x").json


def test_account_locks_after_five_failures():
    for _ in range(5):
        assert login("alice", "wrong").status_code == 401
    r = login("alice", "correct horse battery staple")
    assert r.status_code == 429
$ pytest -q --tb=short
FF                                                                       [100%]
================================== FAILURES ===================================
_______________ test_errors_do_not_reveal_which_part_was_wrong ________________
test_login.py:12: in test_errors_do_not_reveal_which_part_was_wrong
    assert login("mallory", "x").json == login("bob", "x").json
E   AssertionError: assert {'error': 'no such user'} == {'error': 'wrong password'}
E     
E     Differing items:
E     {'error': 'no such user'} != {'error': 'wrong password'}
E     Use -v to get more diff
___________________ test_account_locks_after_five_failures ____________________
test_login.py:19: in test_account_locks_after_five_failures
    assert r.status_code == 429
E   assert 200 == 429
E    +  where 200 = <WrapperTestResponse streamed [200 OK]>.status_code
=========================== short test summary info ===========================
FAILED test_login.py::test_errors_do_not_reveal_which_part_was_wrong - Assert...
FAILED test_login.py::test_account_locks_after_five_failures - assert 200 == 429
2 failed in 1.02s

The first failure is username enumeration: “no such user” for mallory and “wrong password” for bob tell anyone that bob is a real account. The second shows the login took five wrong guesses and then let the sixth attempt in. Nothing slows a caller down, so a script could keep trying passwords against alice indefinitely.

The response time leaks too

Even with identical messages, this login gives the answer away. Time one failed login for an unknown user and one for a real user:

# timing.py
import time
from app import app

client = app.test_client()
for user in ["mallory", "bob"]:
    start = time.perf_counter()
    client.post("/login", data={"username": user, "password": "x"})
    print(f"{user:8} {(time.perf_counter() - start) * 1000:6.1f} ms")
$ python timing.py
mallory     2.2 ms
bob        89.7 ms

Your numbers will differ, but the gap will still be large. scrypt is slow on purpose, to make stolen hashes expensive to crack. The route only runs it for users that exist, so an unknown username comes back about 40 times faster. A caller can tell real accounts apart without reading the message at all.

The fix

Replace app.py. Every failure gets the same message and status. An unknown user’s password is checked against a dummy hash, so both paths do the same work. A per-account counter refuses logins after five failures.

# app.py
from flask import Flask, request
from werkzeug.security import check_password_hash, generate_password_hash

app = Flask(__name__)
USERS = {
    "alice": generate_password_hash("correct horse battery staple"),
    "bob": generate_password_hash("hunter2-but-longer"),
}
DUMMY_HASH = generate_password_hash("never-matches")  # same cost for unknown users
MAX_FAILURES = 5
FAILURES = {}  # username -> count; keep this in Redis or the database in production


@app.post("/login")
def login():
    user, pw = request.form["username"], request.form["password"]
    if FAILURES.get(user, 0) >= MAX_FAILURES:
        return {"error": "too many attempts, try again later"}, 429
    ok = check_password_hash(USERS.get(user, DUMMY_HASH), pw) and user in USERS
    if not ok:
        FAILURES[user] = FAILURES.get(user, 0) + 1
        return {"error": "invalid username or password"}, 401
    FAILURES.pop(user, None)
    return {"ok": True}
$ pytest -q --tb=short
..                                                                       [100%]
2 passed in 1.00s
$ python timing.py
mallory    97.8 ms
bob        93.8 ms

Both tests pass, and the timing gap is gone because both requests now pay for one scrypt check. The order inside ok = ... matters: the hash check runs first, so user in USERS can’t short-circuit it. The counter is keyed by the submitted username, whether or not it exists, so an unknown user is locked after five failures too and a 429 reveals nothing either. A successful login clears the count.

This lock never expires, which hands an attacker a different weapon: five wrong guesses lock the real Alice out. Production logins add an expiry (task 4 below), a per-IP limit alongside the per-account one, and multi-factor authentication, so a guessed password alone isn’t enough. Libraries such as Flask-Limiter provide the per-IP part. The counter also lives in this process’s memory, so it resets on restart and isn’t shared between workers; real counters belong in Redis or the database.

Practice

  1. Does a successful login reset the counter? Write a test: four wrong passwords, the right one, four more wrong ones, then the right one again.
Solution
from app import FAILURES


def test_success_resets_counter():
    FAILURES.clear()
    for _ in range(4):
        login("alice", "wrong")
    assert login("alice", "correct horse battery staple").status_code == 200
    for _ in range(4):
        login("alice", "wrong")
    assert login("alice", "correct horse battery staple").status_code == 200

It passes: FAILURES.pop(user, None) clears the count on success, so the second run of four starts from zero. FAILURES.clear() makes the test independent of whatever ran before it.

  1. Is an unknown username locked out after five failures? Write the test, and explain why that’s the behaviour you want.
Solution
def test_unknown_users_lock_too():
    FAILURES.clear()
    for _ in range(5):
        assert login("mallory", "x").status_code == 401
    assert login("mallory", "x").status_code == 429

It passes. If only real accounts were locked, a caller could make six attempts against any name and read the answer from the status code: 429 for real users, 401 for everyone else. That would be username enumeration again.

  1. Swap the condition to ok = user in USERS and check_password_hash(USERS[user], pw) and run timing.py. What happens?
Solution
$ python timing.py
mallory     2.4 ms
bob        89.4 ms

The timing leak is back. and stops at the first false operand, so for an unknown user the hash check never runs. Both versions return the same answers, so the tests still pass; only timing shows the difference. Keep the expensive check on the left.

  1. Make the lock expire after 15 minutes. Store the time of the last failure with each count, and test the expiry without waiting by replacing the clock.
Solution

Put the clock in a function the test can replace (add import time at the top):

LOCK_SECONDS = 15 * 60
FAILURES = {}  # username -> (count, time of last failure)


def now():
    return time.monotonic()

In login, read both values and only refuse while the lock is fresh:

    count, last = FAILURES.get(user, (0, 0.0))
    if count >= MAX_FAILURES and now() - last < LOCK_SECONDS:
        return {"error": "too many attempts, try again later"}, 429
    ok = check_password_hash(USERS.get(user, DUMMY_HASH), pw) and user in USERS
    if not ok:
        FAILURES[user] = (count + 1, now())
        return {"error": "invalid username or password"}, 401
import app as m


def test_lock_expires(monkeypatch):
    m.FAILURES.clear()
    clock = [1000.0]
    monkeypatch.setattr(m, "now", lambda: clock[0])
    for _ in range(5):
        login("alice", "wrong")
    assert login("alice", "correct horse battery staple").status_code == 429
    clock[0] += m.LOCK_SECONDS
    assert login("alice", "correct horse battery staple").status_code == 200

It passes, along with the two original tests, whichever order they run in. FAILURES.clear() matters: the original lockout test leaves alice locked against the real clock. After the lock expires, each further wrong guess restarts the 15 minutes, so a caller gets at most one guess per window.

Frequently Asked Questions

What is username enumeration?
Username enumeration is when a login, signup or password-reset form behaves differently for accounts that exist and accounts that don't. The difference can be the error message, the status code or the response time. It lets an attacker build a list of real usernames, which makes password guessing far more effective.
Why should a login form say 'invalid username or password'?
A single generic message for every failure means the response no longer tells the caller whether the username exists. Separate messages such as 'no such user' and 'wrong password' confirm valid accounts. The status code and response body should be identical for both cases.
How can response time reveal whether a username exists?
Password hashes such as scrypt and bcrypt are deliberately slow to compute. If a login skips the hash check for unknown users, those requests return much faster than requests for real users. The fix is to check the submitted password against a dummy hash when the user doesn't exist, so both paths cost the same.
Should a login lock the account after failed attempts?
Limiting failed attempts per account stops unlimited password guessing, but a permanent lock lets anyone lock a victim out. Use a lock that expires, or growing delays, and combine it with a per-IP limit and multi-factor authentication. Store the counters in a shared store such as Redis or the database, not in process memory.
What HTTP status code should a rate-limited login return?
429 Too Many Requests is the standard status for rate limiting, optionally with a Retry-After header saying when to try again. Return it for unknown usernames as well as real ones, otherwise the 429 itself reveals which accounts exist.