Authentication Failures — The Attack That Doesn't Need to Break Anything
March 14, 2026, 12:31 p.m.
When I started thinking about authentication security, I imagined hackers writing clever exploits, reverse-engineering cryptography, or finding obscure vulnerabilities in obscure code.
Then I read about how most accounts actually get compromised.
They don't break in. They log in.
What authentication failures actually means
OWASP A07 covers a broad category: any weakness in the process of verifying who a user is. Not just broken passwords — the entire surface of identity verification.
That includes: login forms, session management, password recovery, account creation, logout, and anything else that touches the question "is this person who they say they are?"
The attacks are less dramatic than you'd expect. They work because most systems don't defend against the obvious.
Attack 1: Credential stuffing
What it is: attackers don't guess passwords. They use them. Every major data breach — and there have been hundreds — produces a list of leaked username/password pairs that circulates on the internet. Tools exist to automatically try those pairs against any login form, at scale, in seconds.
If your user signed up to a forum in 2018 and used the same password as their email account, and that forum got breached, their email account is now at risk. Not because your email provider was hacked. Because someone just tried the leaked password and it worked.
This attack has a name: credential stuffing. It works because people reuse passwords. And it's almost entirely automated.
A more recent variant called password spray takes a slightly different approach: instead of trying many passwords for one account, it tries one common password — like Password2025! — against thousands of accounts. This avoids account lockouts while still finding weak credentials at scale.
What it looks like on a login form: hundreds or thousands of failed login attempts, often from many different IP addresses at once.
How to defend against it in Django:
Django doesn't rate-limit login attempts by default. You have to add it. The django-axes package is the standard solution:
pip install django-axes
# settings.py
INSTALLED_APPS = [
...
'axes',
]
MIDDLEWARE = [
...
'axes.middleware.AxesMiddleware',
]
AUTHENTICATION_BACKENDS = [
'axes.backends.AxesStandaloneBackend',
'django.contrib.auth.backends.ModelBackend',
]
# Lock account after 5 failed attempts
AXES_FAILURE_LIMIT = 5
After 5 failed login attempts from the same IP, django-axes blocks further attempts automatically. It logs every failure and can alert you when an attack is detected.
Attack 2: Session fixation
What it is: sessions are how Django knows who you are after you log in. When you authenticate, Django creates a session — a record stored server-side, identified by a random ID sent to your browser as a cookie. Every subsequent request carries that cookie, and Django looks up the session to find your identity.
Session fixation is when an attacker manages to set your session ID before you log in. They visit your site, get a session ID, then trick you into using that same ID — through a crafted link or by injecting it. You log in. Now the attacker knows your session ID and can use it to impersonate you.
The fix is simple: generate a new session ID immediately after login. Never reuse the pre-login session.
What Django does: Django calls request.session.cycle_key() automatically during authentication. If you're using django.contrib.auth.login(), this is handled for you.
from django.contrib.auth import login
def my_login_view(request):
user = authenticate(request, username=..., password=...)
if user:
login(request, user) # Django regenerates session ID here
...
When it doesn't protect you: if you're managing sessions manually without calling login(), you need to call request.session.cycle_key() yourself after authentication.
Attack 3: The logout that doesn't actually log you out
What it is: a user closes the browser tab instead of clicking logout. Or they click logout, but the server doesn't invalidate the session. The session cookie still works. Anyone who gets that cookie — through network interception, browser access, or session theft — can still use it to access the account.
This is a real-world problem. OWASP's own example: someone uses a public computer, closes the tab, and walks away. The next person opens the browser, finds the session still active, and has full access to the account.
What Django does: logout() calls session.flush(), which deletes the session from the database entirely. The cookie becomes useless.
from django.contrib.auth import logout
def my_logout_view(request):
logout(request) # Session deleted server-side
return redirect('login')
What you need to configure: session expiry. Django sessions don't expire by default unless you set it.
# settings.py
# Session expires when the browser closes
SESSION_EXPIRE_AT_BROWSER_CLOSE = True
# Or set an absolute timeout (in seconds) — here 2 hours
SESSION_COOKIE_AGE = 7200
# Session cookie not accessible via JavaScript
SESSION_COOKIE_HTTPONLY = True
# Session cookie only sent over HTTPS
SESSION_COOKIE_SECURE = True # Set to True in production
SESSION_COOKIE_HTTPONLY is particularly important: it prevents JavaScript from reading the session cookie, which blocks a common XSS-to-session-theft attack chain.
Attack 4: Account enumeration
What it is: your login form tells too much. If you show "User not found" for an unknown username and "Wrong password" for a known username with a wrong password, an attacker can use your own form to build a list of valid usernames on your platform.
Once they have valid usernames, credential stuffing becomes more efficient. They're no longer guessing both username and password — just the password.
The fix: always return the same error message, regardless of what went wrong.
# ❌ Reveals too much
if not user:
return "No account with that email."
if not user.check_password(password):
return "Wrong password."
# ✅ Reveals nothing
return "Invalid username or password."
Same principle applies to password reset: don't say "we sent a reset email" only when the account exists. Say it always. Otherwise the reset form becomes an account enumeration tool.
Attack 5: Weak passwords
What it is: Django enforces a minimum password length of 8 characters by default. That's not enough. A password like password is 8 characters and would pass that check.
What Django provides: a validator system in settings.
# settings.py
AUTH_PASSWORD_VALIDATORS = [
{
'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator',
# Rejects passwords too similar to username or email
},
{
'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator',
'OPTIONS': {'min_length': 12} # Raise from default 8
},
{
'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator',
# Rejects passwords from a list of 20,000 common passwords
},
{
'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator',
# Rejects fully numeric passwords
},
]
CommonPasswordValidator is the most useful one. It ships with a list of 20,000 commonly used passwords and rejects any match. It won't catch everything, but it will reject password, 123456789, qwerty, and the other passwords that appear in every breach dataset.
What I changed in my project
Going through this checklist on my blog, I found four things:
- No rate limiting on the login form. Added
django-axes. SESSION_COOKIE_SECUREwas not set. The session cookie was being sent over HTTP in development and would have been sent unencrypted in production.SESSION_COOKIE_HTTPONLYwas not set. JavaScript could read the session cookie.- My password reset view returned different messages depending on whether the email existed. Fixed to always return the same message.
None of these required rewriting anything significant. They were configuration gaps — not logic errors.
Takeaway
Authentication failures are ranked #7 on the OWASP Top 10 not because they're rare. Because they're common and routinely underestimated.
The attacks that compromise most accounts don't exploit cryptographic weaknesses. They try leaked passwords from other breaches, ride on sessions that were never properly closed, or use your own error messages to find valid usernames.
Django gives you solid foundations: password hashing, session management, login and logout utilities. But the configuration is yours to own.
Before you deploy: add rate limiting, set your session cookie flags, audit your error messages, and raise your minimum password length. It takes an hour. The alternative is watching someone walk into your users' accounts through the front door.
Part of a series on the OWASP Top 10 — written as I learn it, not after.