Password Attacks & Cryptography · beginner · ~10 min
**What you will learn** - Distinguish the three online password attacks — brute force, credential stuffing, and password spraying — by their guess pattern and the defenses each one defeats. - Explain why naive account lockout both fails against spraying and creates a denial-of-service risk, and name better controls (throttling, backoff, anomaly detection, breached-password screening). - Describe how MFA changes the attacker's cost, and correctly reason about its real bypasses (MFA fatigue, SIM-swap, OTP phishing) instead of treating it as magic. - Read authentication logs to *detect* a spray in progress: the low-and-slow, one-attempt-per-account fingerprint. - Recommend a layered defense (defense in depth) and verify each control actually blocks the attack in an authorized lab.
Security objective. The asset is the login boundary of a live service — the point where a username and password (and ideally a second factor) decide whether someone becomes an authenticated user. The threat is an attacker who never steals your database but simply guesses credentials against the running application. By the end you will be able to detect an online guessing campaign in the logs and prescribe controls that stop it without locking out your own users.
This builds directly on your prereq, Password cracking concepts (authorized labs only). That lesson was about offline attacks: the attacker already has the password hashes and grinds them on their own hardware, as fast as the algorithm allows. This lesson is the mirror image — online attacks, where the attacker has no hashes and must knock on the real front door one guess at a time. Online attacks are slower and noisier, but they target people and their password habits, and they work even when your hashing is perfect.
There are two shapes to know. Brute force / credential stuffing throws many guesses at one account (stuffing reuses passwords leaked from other sites). Password spraying flips it: one common password tried against many accounts, deliberately slow, to slip under lockout thresholds. Spraying is the quiet, practical attack that actually breaches organizations.
Lockout feels like the obvious fix, but it is a double-edged sword — it can be weaponized to lock everyone out, and it does nothing against a one-attempt-per-account spray. The durable answer is defense in depth: slow hashing, length-based policy, breached-password screening, sane throttling, anomaly detection, and above all MFA, which makes a single guessed password no longer enough. Everything here is defensive and lab-only — you will study attacks to build detection and controls, never to run them against systems you do not own.
Spraying and credential stuffing are not exotic — they are how real accounts actually get taken over. Attackers do not need to break your cryptography when a fraction of your users reuse Summer2025! or a password already sitting in a public breach corpus. In authorized professional work this shows up constantly:
MFA is the single highest-impact recommendation most engineers will ever make. But recommending it well means understanding both its strength (a stolen password is no longer sufficient) and its limits (fatigue, SIM-swap, OTP phishing). Advising "turn on MFA" without knowing that SMS codes are phishable, or that push prompts get fatigue-bombed, is how real deployments fail. This lesson gives you the vocabulary to give correct, defensible advice.
Each attack below is defined the same way: what it is, how it works, when it applies, and the pitfall that makes it dangerous. Read attacks as detection and prevention material — the goal is to recognize and block them.
Definition. Repeatedly guessing many passwords for a single known username.
How it works. The attacker fixes the username and iterates a wordlist or character space against the live login form or API. Each wrong guess is one failed authentication event for that account.
When / when not. Useful only when there is no lockout and no throttling, or against a single high-value account. Against any modern service it is the loudest option: it stacks failures on one account and trips lockout almost immediately.
Pitfall. Because all the noise lands on one account, brute force is the easiest attack to detect and to stop with a simple failure counter — which is exactly why real attackers rarely use it as their first move.
Definition. Replaying username+password pairs leaked from other breaches against your service, betting on password reuse.
How it works. The attacker feeds a list of real (email, password) pairs from an unrelated breach. Each pair is tried once. Because people reuse passwords, a small percentage simply work — no "guessing" required.
When / when not. Effective anywhere users reuse passwords and where there is no breached-credential screening. Less effective if you force unique passwords and screen against known-breached corpora.
Pitfall. Stuffing can look like legitimate traffic — each user is tried with one plausible password — so a naive failure counter barely notices it. Detection needs behavioral signals (many distinct accounts from one source, impossible-travel, datacenter IPs).
Definition. Trying one (or a few) common passwords across many accounts, slowly.
How it works. The attacker enumerates or guesses many valid usernames, then tries a single common password — Winter2025!, Company@123 — against all of them, waits, and tries the next password later. Each account sees only one or two failures, so no per-account threshold is crossed.
When / when not. The go-to online attack inside organizations (also central to the Active Directory track). It thrives wherever per-account lockout is the only defense and where common/seasonal passwords are allowed.
Pitfall / insecure assumption. The whole attack exploits one bad assumption: "if no single account crosses the failure threshold, nothing is wrong." Spraying is invisible to per-account counting; you must correlate failures across accounts by source and time.
Definition. Disabling an account after N failed attempts within a window.
How it works. A counter increments on failure and, past a threshold, refuses further attempts for a lockout period.
Two weaknesses.
| Problem | Why it hurts |
|---|---|
| Denial of service | An attacker who knows usernames can deliberately fail logins to lock out real users — locking is now an attack, not just a defense. |
| Useless vs spraying | One attempt per account never reaches the threshold, so the counter never fires. |
Better controls: progressive throttling / exponential backoff, IP- and ASN-level rate limiting, anomaly detection (many accounts from one source), CAPTCHA on suspicion, and breached-password screening — layered, not lockout alone.
Definition. Requiring a second, independent proof of identity beyond the password (an authenticator-app code, a push approval, or a hardware security key).
How it works. Even a correct password only clears the first factor; the attacker still needs the second, which they do not possess.
Real bypasses (know them, so you recommend the right kind).
| Bypass | What happens | Mitigation |
|---|---|---|
| MFA fatigue / push bombing | User is spammed with push prompts until they tap Approve | Number-matching, limited prompts, FIDO2 |
| SIM-swap | Attacker hijacks the phone number to receive SMS codes | Avoid SMS as a factor; use app/hardware |
| OTP phishing | A fake site relays the user's typed code in real time | Phishing-resistant FIDO2 / WebAuthn |
Critical misconception to correct: MFA raises attacker cost dramatically but nothing is "completely secure." Prefer phishing-resistant factors (FIDO2 / WebAuthn hardware keys) over SMS.
UNTRUSTED (Internet) | TRUST BOUNDARY | TRUSTED (your service)
-------------------------------------------- | (login endpoint) | --------------------------------
Attacker
| guesses via public login form / auth API
| Entry point 1: POST /login (username+password)
| Entry point 2: SSO / OAuth token endpoint
| Entry point 3: legacy protocol w/o MFA (IMAP/SMTP/RDP)
v
==========================[ AUTH BOUNDARY ]==========================
[Factor 1: password check] -> throttle + breached-pw screen + lockout
[Factor 2: MFA check] -> phishing-resistant preferred
[Detection] -> auth logs, cross-account failure correlation
|
v
ASSET PROTECTED: authenticated session -> mailbox, files, admin console
Entry points to remember: the visible login form is only one door. Legacy protocols (IMAP, SMTP-AUTH, old RDP) and some token endpoints frequently bypass MFA entirely and are a favorite spray target.
Knowledge check.
This is a concept lesson, so the "syntax" is the shape of an authentication log line and the throttling decision — the two things you actually read and write when defending. Below is a lab-safe, annotated example of the log fields a defender should emit and then correlate.
# One structured auth log line (JSON) — lab example, no real data
{
"ts": "2026-07-07T09:14:02Z", # timestamp (UTC) — needed to see slow cadence
"event": "auth_attempt",
"username": "alice", # the account targeted
"result": "failure", # success | failure | locked | mfa_required
"reason": "bad_password", # security decision, not the password itself
"src_ip": "10.0.0.5", # source — correlate across accounts
"asn": "AS64500", # network origin — datacenter vs residential
"corr_id": "c1f2-..." # correlation id to stitch a session together
}
The defensive control expressed in words (pseudo-rule you will implement in code below):
# Spray detection heuristic (correlate ACROSS accounts, not within one)
IF distinct_failed_usernames(src_ip, window=10m) >= N_ACCOUNTS
AND failures_per_username <= 2
THEN raise "possible password spray" + throttle/deny src_ip + alert SOC
Never log the attempted password, the session cookie, or the MFA code — only the decision (result, reason).
Not every password attack happens offline. Online attacks guess against a live service. They are slower and noisier than offline cracking, but they target people, not stolen hashes.
Spraying is the more practical online attack inside organizations. (It is also covered in the Active Directory track.)
Locking an account after N failed attempts stops brute force. But it has two weaknesses:
Better controls include rate limiting, throttling and backoff, anomaly detection, and breached-password screening. Use these instead of relying on aggressive lockout alone.
Multi-factor authentication (MFA) requires a second proof of identity beyond the password, such as a code from an app or a hardware key.
This means a cracked or guessed password is not enough on its own. The attacker also needs the second factor. MFA is the single most effective control against password attacks.
Still, know the bypasses (covered in the web and API tracks):
Prefer phishing-resistant factors such as FIDO2 / WebAuthn.
Good defense combines several layers:
This is defense in depth, so that no single weak password is fatal.
The example is a tiny, self-contained Python auth simulator you run only on your own machine. It shows the insecure shape (per-account-only defense), the secure fix (throttling + cross-account spray detection + MFA gate), and a verification harness proving the fix rejects a spray while still accepting a legitimate login. No network, no real target.
# insecure_login.py -- per-account lockout ONLY. Blind to spraying.
from collections import defaultdict
USERS = {"alice": "S3cret-unique-A", "bob": "S3cret-unique-B", "carol": "Winter2025!"}
failures = defaultdict(int) # counts failures PER ACCOUNT only
LOCK_THRESHOLD = 5
def login(username, password):
if failures[username] >= LOCK_THRESHOLD:
return "locked"
if USERS.get(username) == password:
failures[username] = 0
return "success"
failures[username] += 1
return "failure"
# A spray: ONE common password across MANY accounts -> 1 failure each,
# threshold (5) never reached, carol (a reuser) is compromised silently.
if __name__ == "__main__":
spray_pw = "Winter2025!"
for user in USERS:
print(user, login(user, spray_pw)) # carol -> success. No alarm.
Expected output:
alice failure
bob failure
carol success
The spray wins: carol reused a common password, no single account hit 5 failures, and nothing was logged or alerted.
# secure_login.py -- layered defense. Still lab-only.
import time, logging
from collections import defaultdict, deque
logging.basicConfig(level=logging.INFO, format="%(message)s")
log = logging.getLogger("auth")
USERS = {"alice": "S3cret-unique-A", "bob": "S3cret-unique-B", "carol": "Winter2025!"}
MFA_ENABLED = {"alice", "bob", "carol"} # second factor required
BREACHED = {"Winter2025!", "Summer2025!", "Company@123", "Password1!"}
WINDOW = 600 # 10 minutes
SPRAY_ACCOUNTS = 3 # distinct accounts failed from one source -> suspicious
IP_RATE = 10 # max attempts per source per window
src_events = defaultdict(deque) # src_ip -> timestamps (rate limit)
src_failed_users = defaultdict(set) # src_ip -> distinct usernames failed (spray)
def _prune(dq, now):
while dq and now - dq[0] > WINDOW:
dq.popleft()
def verify_mfa(username, code):
# Lab stub: a real system verifies a TOTP/FIDO2 assertion here.
return code == "good-second-factor"
def login(username, password, src_ip, mfa_code=None, now=None):
now = time.time() if now is None else now
ev = src_events[src_ip]; _prune(ev, now)
# Control A: source rate limit (blunts brute force AND spraying volume)
if len(ev) >= IP_RATE:
log.info('{"event":"throttled","src_ip":"%s","result":"denied"}', src_ip)
return "throttled"
ev.append(now)
# Control B: cross-account spray detection (the key fix)
if len(src_failed_users[src_ip]) >= SPRAY_ACCOUNTS:
log.info('{"event":"spray_suspected","src_ip":"%s","accounts":%d}',
src_ip, len(src_failed_users[src_ip]))
return "blocked_spray"
ok_pw = USERS.get(username) == password
if not ok_pw:
src_failed_users[src_ip].add(username)
log.info('{"event":"auth","user":"%s","result":"failure","reason":"bad_password","src_ip":"%s"}',
username, src_ip)
return "failure"
# Control C: breached-password screening (blocks reuse of common/leaked pw)
if password in BREACHED:
log.info('{"event":"auth","user":"%s","result":"failure","reason":"breached_password"}', username)
return "failure_breached"
# Control D: MFA gate -- a correct password is NOT enough
if username in MFA_ENABLED and not verify_mfa(username, mfa_code):
log.info('{"event":"auth","user":"%s","result":"mfa_required"}', username)
return "mfa_required"
log.info('{"event":"auth","user":"%s","result":"success","src_ip":"%s"}', username, src_ip)
return "success"
# verify.py
from secure_login import login
# Bad input: a spray from one source across many accounts -> must NOT succeed
attacker = "203.0.113.9" # TEST-NET-3, documentation range (RFC 5737)
results = [login(u, "Winter2025!", attacker) for u in ("alice", "bob", "carol", "dave")]
assert "success" not in results, results
assert any(r in ("blocked_spray", "failure", "failure_breached") for r in results)
print("SPRAY REJECTED:", results)
# Good input: legitimate user, correct password, correct second factor -> success
ok = login("alice", "S3cret-unique-A", "192.0.2.10", mfa_code="good-second-factor")
assert ok == "success", ok
print("LEGIT LOGIN ACCEPTED:", ok)
# Correct password but NO second factor -> must be stopped at MFA
no_mfa = login("bob", "S3cret-unique-B", "192.0.2.11")
assert no_mfa == "mfa_required", no_mfa
print("PASSWORD-ONLY BLOCKED BY MFA:", no_mfa)
Expected output:
SPRAY REJECTED: ['failure', 'failure', 'blocked_spray', 'blocked_spray']
LEGIT LOGIN ACCEPTED: success
PASSWORD-ONLY BLOCKED BY MFA: mfa_required
The secure version stops the spray on the third distinct account from one source, screens out the reused breached password, and enforces MFA so a guessed password alone never yields a session — while a genuine user with the right factors still gets in. All addresses used are RFC 5737 documentation ranges, not real hosts.
Walkthrough of the secure example, top to bottom.
| Step | Code / field | What happens | Why it matters |
|---|---|---|---|
| 1 | USERS, MFA_ENABLED, BREACHED |
Lab data: valid creds, which accounts require a second factor, and a small known-bad password set | Models the three layers a defender controls |
| 2 | src_events, src_failed_users |
Per-source structures: a timestamp queue and a set of distinct failed usernames | The set is the whole trick — it correlates across accounts, which per-account lockout cannot |
| 3 | _prune |
Drops events older than WINDOW |
Makes rate and spray checks apply to a rolling 10-minute window, not all history |
| 4 | Control A rate limit | If a source made IP_RATE attempts this window, return throttled |
Caps the volume any single origin can push — hurts brute force and slows spraying |
| 5 | Control B spray check | If one source has already failed SPRAY_ACCOUNTS distinct users, return blocked_spray |
Fires even though no single account is near lockout — this is the fix for the insecure version's blind spot |
| 6 | password compare | On mismatch, add username to src_failed_users[src_ip] and log a failure with reason, not the password |
Feeds the spray detector; logs the decision, never the secret |
| 7 | Control C breached screen | Even a correct password is rejected if it is in BREACHED |
Kills credential stuffing / reuse of leaked or seasonal passwords |
| 8 | Control D MFA gate | Correct password still returns mfa_required unless the second factor verifies |
Makes a guessed password insufficient — the core value of MFA |
| 9 | success | Only a correct password + passing MFA + clean source reaches success |
The asset (session) is released only after every layer clears |
Tracing the verify spray: attempt 1 (alice, wrong pw) → failure, set = {alice}. Attempt 2 (bob) → failure, set = {alice, bob}. Attempt 3 (carol) → set now has 3 → blocked_spray; carol is not compromised even though her password was the sprayed one. Attempt 4 (dave) → still blocked_spray. Contrast the insecure version, where attempt 3 returned success and no alarm fired.
Real mistakes, each shown as WRONG → WHY → CORRECTED → how to recognize/prevent.
1. "Account lockout is our brute-force defense, so we're covered."
2. "We decoded the JWT / read the token, so the login is verified."
3. "The vulnerability scan passed, so we're secure."
4. "We enabled SMS MFA, so phishing and takeover are solved."
5. "Let's log the attempted password so we can debug bad logins."
result, reason), source, timestamp, and correlation id — never the password, cookie, or MFA code.When your lab defense behaves unexpectedly, work through these.
src_failed_users[src_ip] is a set of usernames), not per account. A per-account counter will never catch a one-attempt spray. Print the set size on each attempt.SPRAY_ACCOUNTS/IP_RATE thresholds may be too tight, or many users share one NAT/proxy IP so the source looks like a spray. Correlate on richer signals (ASN, device, geo) and consider CAPTCHA/step-up instead of outright deny. Ask: is this one attacker, or one office behind one IP?verify_mfa fails closed (returns False) on missing/blank codes. A gate that defaults to allow is worse than none._prune uses the same now you pass into login; mismatched clocks make old events linger or fresh ones vanish. In tests, pass an explicit now so timing is deterministic.reason. Silent failures mean your SOC sees nothing.Questions to ask when auth defense fails: Am I correlating within one account or across many? Does any auth path (legacy protocol, token endpoint) skip these controls? Does the control fail open or closed? Can a normal shared IP trip my spray rule? Am I accidentally logging a secret?
Security & safety — detection and logging. For this topic the "safety" work is making the attack visible without creating new exposure.
What to log on every auth attempt:
success | failure | locked | throttled | mfa_required | blocked_spray, plus a reason.What to NEVER log: the attempted or actual password, session cookies or tokens, MFA/OTP codes, private keys, full payment card numbers (PANs), or unneeded PII. Log the decision, not the secret.
Events that signal abuse:
How false positives arise: many real users behind one corporate NAT or VPN egress IP can mimic a spray; a mail client retrying a stale password looks like brute force; travel and mobile networks trip impossible-travel rules. Tune thresholds, enrich with device/ASN context, and prefer step-up (CAPTCHA, extra MFA) over hard blocks so you protect users without locking them out.
Authorized real-world use case. A penetration tester with a signed engagement scope assesses a client's Microsoft 365 tenant. In a controlled window they simulate a single-password spray against a provided test set of accounts to demonstrate exposure, then deliver the real value: findings showing which legacy protocols bypassed MFA, which accounts used breached/seasonal passwords, and a remediation plan (disable legacy auth, enforce phishing-resistant MFA, add breached-password screening, enable spray-aware alerting). The blue team then verifies each control by re-running the simulation and confirming it is now detected and blocked.
Professional best-practice habits.
| Practice | Beginner | Advanced |
|---|---|---|
| Validation | Enforce length-based policy; reject known-breached passwords | Continuous screening against updated breach corpora; passwordless where possible |
| Least privilege | Don't hand every account admin | Just-in-time elevation; conditional access by risk |
| Secure defaults | MFA on by default for all users | Phishing-resistant FIDO2/WebAuthn as the default factor; number-matching for push |
| Logging | Log auth decisions (never secrets) | Cross-account/per-source correlation, impossible-travel, SIEM alerting |
| Error handling | Fail closed; identical messages for bad user vs bad password | Progressive throttling + step-up instead of blunt lockout |
Beginner takeaway: turn on MFA everywhere, block breached passwords, and log auth decisions. Advanced takeaway: kill MFA-bypassing legacy paths, adopt phishing-resistant factors, and build detection that correlates failures across accounts and sources — then prove it works by simulating the attack in an authorized lab.
All tasks are lab-only: run against your own scripts, localhost, containers, or intentionally-vulnerable VMs (e.g. a lab VM you built). Never target systems you do not own or lack written authorization to test.
Authorization checklist (before any lab):
Beginner 1 — Read the fingerprint.
Beginner 2 — Break the naive defender.
insecure_login.py and show, in output, that a spray compromises the reused-password account without tripping lockout.USERS to real credentials.Intermediate 1 — Add spray detection.
blocked_spray/alert before any account is compromised.set per source; choose a window.Intermediate 2 — Prove MFA closes the gap.
mfa_required, and password+valid-factor → success. Make verify_mfa fail closed.Challenge — Detection + remediation report (defensive conclusion).