Web Security (OWASP Top 10, fix-side) · lesson 6 of 6
Beginner
3Intermediate
3Also part of Web Security (OWASP Top 10, fix-side)
12- + Python Security from Python
- + Database Security from SQL
- + JavaScript Security Best Practices from JavaScript
- + Java Security from Java
- + Bash Security Best Practices from Bash
- + FastAPI Authentication: Password Hashing, OAuth2 and JWT from FastAPI
- + FastAPI Roles, CORS and Settings from Environment Variables from FastAPI
- + Spring Security — Authentication and Authorization from Java
- + Testing Bearer Tokens, Basic Auth, and 401 vs 403 from API Testing
- + C Secure Coding: Buffer Overflows, Sanitizers, and Safe Functions from C
- + LLM Security Beyond Guardrails from LLM Engineering
- + Dependency Scanning (SCA) and SBOMs with pip-audit, Syft and Grype from DevSecOps (AppSec in the pipeline)
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
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
- 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.
- 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.
- Swap the condition to
ok = user in USERS and check_password_hash(USERS[user], pw)and runtiming.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.
- 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.