A2:2017 - Broken Authentication - Prevention
Table of Contents
- Defense in Depth
- 1. Store Passwords Correctly
- 2. Sensible Password Policy (NIST)
- 3. Screen Against Breached Passwords
- 4. Rate Limiting & Lockout
- 5. Multi-Factor Authentication
- 6. Secure Session Management
- 7. Cookie Flags & Transport
- 8. Timeouts & Invalidation
- 9. Secure Password Recovery
- 10. Anti-Enumeration & Generic Errors
- Implementation Checklist
- Next Steps
Defense in Depth
No single control fixes Broken Authentication. The defenses below are layered so that a failure in one does not become an account takeover. The guiding principle: lean on your framework and identity provider for the hard parts (session generation, hashing, MFA) rather than hand-rolling them, and add the policy controls (rate limiting, breached-password screening, recovery hardening) on top.
Layer Stops
------------------------ -------------------------------------------------
Strong hashing mass cracking after a database leak
Breached-pw screening weak/known passwords (spraying, easy guessing)
Rate limit + backoff brute force and (partly) credential stuffing
Bot defense + MFA credential stuffing that survives rate limits
Session regeneration session fixation
Secure cookies + HSTS session sniffing and script theft
Timeouts + invalidation stolen / lingering sessions
Hardened recovery account takeover via the reset flow
Generic errors + timing username enumeration
1. Store Passwords Correctly
Use a slow, salted, memory-hard hash. Argon2id is the current first choice; bcrypt and scrypt are solid, widely-supported alternatives. Never use MD5, SHA-1, or a plain SHA-2 hash for passwords, and never store plaintext.
# Python — Argon2 (preferred) via argon2-cffi
from argon2 import PasswordHasher
from argon2.exceptions import VerifyMismatchError
ph = PasswordHasher() # sensible memory/time/parallelism defaults
hash = ph.hash(password) # salt is generated and embedded automatically
try:
ph.verify(hash, password) # constant-time, raises on mismatch
if ph.check_needs_rehash(hash):
hash = ph.hash(password) # transparently upgrade parameters over time
except VerifyMismatchError:
reject_login()
// Node.js — bcrypt (cost factor tuned so a hash takes ~250ms+)
const bcrypt = require('bcrypt');
const COST = 12; // raise as hardware improves
const hash = await bcrypt.hash(password, COST); // salt embedded in the hash
const ok = await bcrypt.compare(password, hash);// constant-time comparison
Rule of thumb: tune the cost/parameters so a single hash takes on the order of 0.25–0.5 seconds on your production hardware. That is negligible per login but devastating to an attacker doing billions of offline guesses.
2. Sensible Password Policy (NIST SP 800-63B)
Modern guidance favors length over composition. Require a reasonable minimum length, allow long passphrases and the full character set (including spaces and Unicode), and do not impose arbitrary complexity rules or forced periodic rotation—both push users toward predictable patterns.
| Do | Don't |
|---|---|
| Minimum length ~8–12; allow up to 64+ | Cap length at 16 or block spaces |
| Allow all Unicode and passphrases | Force upper+lower+digit+symbol rules |
| Screen against breached-password lists | Force rotation every 90 days "just because" |
| Offer a password-strength meter | Enforce cryptic composition users can't remember |
| Rotate only on evidence of compromise | Use knowledge-based "security questions" |
3. Screen Against Breached Passwords
The single highest-value policy control is rejecting passwords already known to be compromised. Use a local list or a k-anonymity API (which never sends the full password or its full hash).
# Python — k-anonymity check against a breached-password corpus.
# Only the first 5 hex chars of the SHA-1 are sent; the suffix is compared
# locally, so the full password/hash never leaves the server.
import hashlib, requests
def is_breached(password: str) -> bool:
digest = hashlib.sha1(password.encode()).hexdigest().upper()
prefix, suffix = digest[:5], digest[5:]
resp = requests.get(f"https://api.pwnedpasswords.com/range/{prefix}",
headers={"Add-Padding": "true"}, timeout=3)
return any(line.split(":")[0] == suffix for line in resp.text.splitlines())
# On register / password change:
if is_breached(new_password):
reject("That password has appeared in a data breach; choose another.")
Note: SHA-1 here is used only as a lookup key against a public corpus—not to store the password. Storage still uses Argon2/bcrypt.
4. Rate Limiting & Lockout
Throttle authentication attempts on multiple keys at once—per account, per IP, and globally—so no single dimension can be evaded. Prefer exponential backoff and CAPTCHA/step-up over hard permanent lockout, which invites denial-of-service.
# Flask example using a shared store (Redis) for counters.
# Two independent limits: per-account (stops vertical brute force) and
# per-IP (stops one host hammering many accounts).
import time, redis
r = redis.Redis()
def check_rate(username, ip):
now = int(time.time())
acc_key = f"fail:acc:{username}"
ip_key = f"fail:ip:{ip}"
acc_fails = int(r.get(acc_key) or 0)
ip_fails = int(r.get(ip_key) or 0)
if acc_fails >= 5 or ip_fails >= 50:
# Exponential backoff instead of permanent lock:
delay = min(2 ** acc_fails, 60)
raise TooManyAttempts(retry_after=delay, captcha_required=True)
def record_failure(username, ip):
for key in (f"fail:acc:{username}", f"fail:ip:{ip}"):
r.incr(key); r.expire(key, 900) # 15-minute rolling window
def record_success(username, ip):
r.delete(f"fail:acc:{username}") # reset on genuine success
# Nginx edge rate limit as a coarse first line of defense
limit_req_zone $binary_remote_addr zone=login:10m rate=10r/m;
location = /login {
limit_req zone=login burst=5 nodelay;
proxy_pass http://app_upstream;
}
5. Multi-Factor Authentication
MFA is the most effective single defense against credential stuffing and phishing. Prefer phishing-resistant factors (WebAuthn/FIDO2 security keys or platform authenticators). TOTP is a good widely-supported option; SMS is the weakest and should be a last resort. Critically, verify the second factor before issuing an authenticated session.
# Python — TOTP verification (RFC 6238) with pyotp, done BEFORE login completes
import pyotp
def complete_login(user, submitted_code):
totp = pyotp.TOTP(user.totp_secret)
# valid_window allows for small clock drift; throttle attempts separately.
if not totp.verify(submitted_code, valid_window=1):
raise InvalidSecondFactor() # do NOT elevate the session
# Only now create the authenticated session:
regenerate_session_id()
session["authenticated"] = True
session["uid"] = user.id
- Number-matching push defeats MFA-fatigue: the user types a number shown on screen, so blind "approve" spam fails.
- Throttle OTP entry (a 6-digit code has only 106 values) and expire codes quickly.
- Recovery codes should be single-use and generated once; recovery must be as strong as the primary factor.
6. Secure Session Management
Use your framework's session machinery—it generates high-entropy, CSPRNG-backed identifiers. The one rule developers most often miss: regenerate the session ID at every privilege change (especially login), which eliminates session fixation.
// PHP — regenerate the session ID on login (kills fixation)
session_start();
if (password_verify($password, $user['hash'])) {
session_regenerate_id(true); // true = delete the OLD session file
$_SESSION['uid'] = $user['id'];
$_SESSION['auth'] = true;
}
# Python/Flask — clear and reissue the session on login
from flask import session
def login_success(user):
session.clear() # drop any pre-auth (fixation) state
session.regenerate = True # (Flask-Session backends) new server-side id
session["uid"] = user.id
session["auth_at"] = time.time()
Never accept a session identifier from the URL or a request parameter—only from the session cookie. This closes both the URL-exposure and fixation-via-URL vectors.
7. Cookie Flags & Transport
The session cookie must carry the full set of protective attributes, and the whole site must be HTTPS-only with HSTS.
Set-Cookie: sessionid=<high-entropy value>;
Secure; # only sent over HTTPS
HttpOnly; # unreadable by JavaScript (blocks XSS theft)
SameSite=Lax; # or Strict; limits cross-site sending (CSRF aid)
Path=/;
Max-Age=1800 # aligns with idle timeout
Strict-Transport-Security: max-age=63072000; includeSubDomains; preload
// Node/Express — secure session cookie configuration
app.use(session({
secret: process.env.SESSION_SECRET, // long random, from a secret store
name: 'sid', // don't advertise the framework
resave: false,
saveUninitialized: false,
cookie: { httpOnly: true, secure: true, sameSite: 'lax', maxAge: 1800000 }
}));
8. Timeouts & Invalidation
Sessions must end—both from inactivity and after an absolute maximum. Logout and password changes must invalidate sessions server-side, not just delete the client cookie.
# Enforce idle + absolute timeout on each request
IDLE_LIMIT = 30 * 60 # 30 minutes of inactivity
ABSOLUTE_LIMIT = 12 * 3600 # 12 hours regardless of activity
def enforce_timeouts(session):
now = time.time()
if now - session["last_seen"] > IDLE_LIMIT: destroy(session)
if now - session["auth_at"] > ABSOLUTE_LIMIT: destroy(session)
session["last_seen"] = now
# On logout: remove the server-side record, not only the cookie
def logout(session_id):
server_side_store.delete(session_id) # attacker's copy dies too
# On password change: revoke ALL of the user's sessions
def on_password_change(user_id):
server_side_store.delete_all_for_user(user_id)
9. Secure Password Recovery
Treat the reset flow as a first-class authentication path, because it is one. Reset tokens should be long, random, single-use, and short-lived, delivered out-of-band, and the flow must not leak account existence or trust attacker-controlled headers.
# Generate a secure, single-use, expiring reset token
import secrets, hashlib, time
def create_reset_token(user):
raw = secrets.token_urlsafe(32) # 256 bits of entropy
# Store only a HASH of the token, like a password:
db.save_reset(user.id,
token_hash=hashlib.sha256(raw.encode()).hexdigest(),
expires_at=time.time() + 900, # 15 minutes
used=False)
email_out_of_band(user.email,
link=f"https://app.example/reset?token={raw}")
# ALWAYS respond identically whether or not the email exists:
return "If that account exists, we've sent reset instructions."
def consume_reset_token(raw):
h = hashlib.sha256(raw.encode()).hexdigest()
rec = db.find_reset(token_hash=h)
if not rec or rec.used or rec.expires_at < time.time():
raise InvalidOrExpiredToken()
rec.used = True; db.save(rec) # single use
return rec.user_id
- Build reset links from a server-configured base URL, never from the request
Hostheader (prevents host-header poisoning). - After a successful reset, invalidate all existing sessions for that user.
- Do not use knowledge-based "security questions"—the answers are often public.
10. Anti-Enumeration & Generic Errors
Give the same answer whether or not an account exists, and keep response timing uniform so attackers cannot distinguish valid users.
# Uniform error + uniform timing on login
DUMMY_HASH = ph.hash("a-fixed-throwaway-password") # precomputed once
def login(username, password):
user = db.find(username)
if user is None:
ph.verify_ignore_result(DUMMY_HASH, password) # spend the SAME time
return "Invalid username or password." # generic message
try:
ph.verify(user.hash, password)
except VerifyMismatchError:
return "Invalid username or password." # SAME generic message
return success(user)
Apply the same principle to registration ("if that email is new, we'll send a confirmation"), password reset, and any endpoint that could otherwise confirm which accounts exist. Compare tokens and MFA codes with constant-time functions (hmac.compare_digest, hash_equals).
Implementation Checklist
- Passwords hashed with Argon2id / bcrypt / scrypt; parameters tuned; auto-rehash on login.
- Length-based policy; full character set allowed; no forced arbitrary rotation.
- New/changed passwords screened against breached lists.
- Login throttled per-account, per-IP, and globally with exponential backoff; CAPTCHA/step-up on abuse.
- MFA available and enforced for sensitive accounts; factor verified before session elevation; OTP entry throttled.
- Session IDs framework-generated, high-entropy, cookie-only; regenerated at login.
- Cookies set
Secure; HttpOnly; SameSite; site is HTTPS-only with HSTS. - Idle and absolute timeouts enforced; logout and password change invalidate sessions server-side.
- Reset tokens random, hashed-at-rest, single-use, short-lived, out-of-band; base URL server-configured.
- Generic errors and uniform timing everywhere; constant-time secret comparison.
- Default/sample credentials removed before production.
Next Steps
- Examples: See these defenses as vulnerable-vs-secure pairs in four languages.
- Attack Vectors: Revisit each attack now that you know the fix.
- Overview: The concepts, impact, and 2017→2021 lineage.
- Launch the Lab: Apply and verify these fixes against the intentionally vulnerable app (port 5020).
Practice the fixes: In the lab at ./lab/broken-authentication/, harden the login, session, and reset flows, then re-run the attacks from the previous page to confirm they now fail.