๐ OWASP Top 10 โ A07:2021 Identification and Authentication Failures
The Login Page Is Your Front Door: Why 80% of Breaches Start With Broken Authentication
"Passwords are like underwear: change them often, don't share them, and never leave them lying around. But most importantly โ stop relying on them alone."
>
This blog dissects A07:2021 โ Identification and Authentication Failures down to the atomic level. By the end, you will understand why credential stuffing attacks succeed 2% of the time (and that's a disaster at scale), how session hijacking actually works, and how to build authentication systems that attackers simply cannot break.
๐ Table of Contents
- Introduction โ Why Authentication Is the #1 Target
- What Exactly Is A07?
- Identification vs Authentication vs Authorization
- The Authentication Attack Landscape
- Real-World Breaches
- Technical Deep-Dive: CWE Mapping
- Password-Based Authentication: Done Right
- Multi-Factor Authentication (MFA)
- Session Management: The Forgotten Battlefield
- JWT Security: The Modern Pitfalls
- OAuth 2.0 & OIDC: Doing It Right
- Passwordless Authentication: The Future
- Account Recovery: The Weakest Link
- Code Examples: Vulnerable vs Secure
- The Ultimate Checklist
- Conclusion
๐ช Introduction โ Why Authentication Is the #1 Target
Authentication is the front door of every application. It's the one component that every user interacts with, that every attacker probes first, and that is most often implemented incorrectly.
๐ด According to Verizon's 2023 Data Breach Investigations Report:
80% of hacking-related breaches involve stolen or brute-forced credentials
74% of all breaches include a human element (often tricked into revealing credentials)
Credential stuffing attacks increased 65% year-over-year
The average cost of a credential-related breach is $4.5 million
The Harsh Reality
Every day:
๐ค Bots make BILLIONS of login attempts across the internet
๐ Attackers test LEAKED credentials from previous breaches
๐ฑ Phishing kits capture MILLIONS of passwords
๐ Session tokens are stolen through XSS, MITM, or malware
๐ญ Account takeovers happen EVERY 2 SECONDS globallyThe Statistics
| Metric | Value |
|---|---|
| Incidence Rate (tested applications) | 2.55% |
| Mapped CWEs | 22 |
| Max Incidence Rate | 14.84% |
| Average Weighted CVSS Score | 7.40 |
| % of breaches involving credentials | 80%+ |
| Average credential stuffing success rate | 0.1% - 2% |
| Known compromised passwords (HIBP) | 850+ million |
A 0.1% success rate sounds small โ until you realize attackers try millions of credentials per hour. 0.1% of 10 million = 10,000 compromised accounts per day.
Why This Category Was Renamed
In OWASP 2017, this was called "Broken Authentication." In 2021, it was renamed to "Identification and Authentication Failures" to acknowledge that:
- Identification (knowing WHO is making a request) is distinct from
- Authentication (proving they ARE who they claim to be)
Both can fail independently, and both are equally dangerous.
๐ง What Exactly Is A07?
Identification and Authentication Failures covers vulnerabilities related to:
- ๐ด Confirming user identity incorrectly (or not at all)
- ๐ Weak credential management (passwords, tokens, keys)
- ๐ก Broken session handling (session fixation, hijacking)
- ๐ข Missing or weak MFA
- ๐ต Vulnerable password recovery mechanisms
- ๐ฃ Poor credential storage (plaintext passwords, weak hashing)
The Official OWASP Definition
"Confirmation of the user's identity, authentication, and session management is critical to protect against authentication-related attacks. There may be authentication weaknesses if the application permits automated attacks, uses default credentials, uses weak password recovery processes, stores passwords insecurely, or has broken session management."
The Three Pillars of Secure Authentication
โ๏ธ Identification vs Authentication vs Authorization
These three concepts are constantly confused, even by experienced developers. Understanding them is foundational.
The Analogy: Airport Security
| Step | Real World | Digital Equivalent |
|---|---|---|
| 1. Identification | "I am John Smith" (shows name on boarding pass) | Username/email entered |
| 2. Authentication | Shows passport matching the name | Password, MFA, biometric verification |
| 3. Authorization | Boarding pass says "Gate 5, Seat 12A" | Role/permission check determines access |
What A07 Covers
A07 โ Identification and Authentication Failures covers:
โโโ Identification: Weak user discovery/enumeration prevention
โโโ Authentication: Broken login mechanisms
โโโ Session Management: Compromised session handling
A01 โ Broken Access Control covers:
โโโ Authorization: Who can do what (separate concern)The Common Mistake
# ๐ด WRONG โ Confusing authentication with authorization
if user.username == "admin": # โ This is IDENTIFICATION only
allow_admin_access() # โ Should check AUTHORIZATION
# ๐ข RIGHT โ Separate concerns
if authenticate(username, password, mfa_token): # Authentication
if user.has_permission("admin:access"): # Authorization
allow_admin_access()๐ The Authentication Attack Landscape
Attackers have an entire arsenal targeting authentication. Here are the big ones:
Attack #1: Credential Stuffing
Why it works: 65% of people reuse passwords across sites.
Attack #2: Brute Force
Target: Admin account with 8-character alphanumeric password
Password space: 36^8 = ~2.8 trillion combinations
Attempts per second (modern GPU): ~100 billion
Time to crack: 2.8 trillion / 100 billion = 28 seconds
Reality check: Online brute force is slower (rate limiting),
but OFFLINE brute force (after stealing hash) is terrifying.Attack #3: Password Spraying
๐ด Traditional brute force (fails to lockout):
Try: [email protected], password1, password2, password3, ... (BLOCKED)
๐ข Password spraying (bypasses lockout):
Try: [email protected], Winter2024!
Try: [email protected], Winter2024!
Try: [email protected], Winter2024!
Try: ... 10,000 users, same password
Why it works: Account lockout triggers on failed attempts per USER,
not per PASSWORD. Trying one common password across many users
avoids triggering lockouts.Attack #4: Session Hijacking
Attack #5: Session Fixation
๐ด VULNERABLE FLOW:
1. Attacker visits site, gets session ID: SESSIONID=xyz789
2. Attacker tricks victim into using same session:
Email: "Click here! https:">//site.com/login?SESSIONID=xyz789"
3. Victim logs in โ session ID xyz789 is now authenticated
4. Attacker also uses SESSIONID=xyz789 โ authenticated as victim
๐ข FIX: Regenerate session ID on loginAttack #6: Phishing
๐ฃ Modern phishing kits:
- Clone target website perfectly (1-click deployment)
- Capture credentials in real-time
- Even bypass MFA (relay attacks)
- Use lookalike domains: paypal-security.com,
rnicrosoft.com (rn = m), amaz0n.com
Statistics:
- 1 in 4 people click phishing links
- 1 in 10 submit credentials
- Average phishing campaign ROI: 1500%Attack #7: MFA Bypass
Common MFA bypasses:
๐ด SMS interception (SIM swapping)
๐ด Push notification fatigue (spam approvals)
๐ด OAuth consent phishing
๐ด Session cookie theft (bypasses MFA entirely)
๐ด Account recovery flow (circumvents MFA)
๐ด MFA removal via support social engineeringAttack #8: Account Enumeration
๐ด VULNERABLE โ Different responses reveal account existence:
POST /login
email: [email protected], password: wrong
Response: "Invalid password" โ Account EXISTS
POST /login
email: [email protected], password: wrong
Response: "User not found" โ Account doesn't exist
Attacker now has a list of valid email addresses to target.๐ฅ Real-World Breaches
๐ Case 1: 23andMe (2023) โ 6.9 Million Users via Credential Stuffing
The Attack:
- Attackers used credentials leaked from other breaches
- Logged into 14,000 accounts directly via credential stuffing
- Through DNA relative sharing feature, accessed data of 6.9 million additional users
- Data included genetic ancestry, health predispositions, family trees
The Root Cause:
- No MFA enforcement
- No credential stuffing detection
- No anomalous login monitoring
- Users reused passwords from previous breaches
The Aftermath:
- Class action lawsuits
- $30 million settlement
- Permanent reputation damage
- Forced MFA rollout (too late)
๐ Case 2: Colonial Pipeline (2021) โ Single Password Shuts Down US Fuel Supply
The Attack:
- Attackers obtained a single VPN password (likely from dark web)
- VPN account had no MFA enabled
- Account was inactive but never deactivated
- Ransomware deployed, pipeline shut down for 6 days
The Impact:
- Fuel shortages across US East Coast
- Gas prices spiked
- State of emergency declared in 17 states
- $4.4 million ransom paid (partially recovered)
The Lesson: One missing MFA. One forgotten account. National infrastructure crisis.
๐ Case 3: Twitter (2020) โ Admin Panel via Social Engineering
The Attack:
- Attackers social-engineered Twitter employees via phone
- Gained access to internal admin panel
- Panel allowed changing email addresses on any account
- Took over accounts: Obama, Biden, Musk, Bezos, Apple, Uber, etc.
- Posted cryptocurrency scams, netted $120,000+ in minutes
The Root Cause:
- Admin panel didn't require MFA for sensitive actions
- Internal tools lacked proper access controls
- No anomaly detection on account takeovers
- Insider access was over-privileged
๐ Case 4: Microsoft Exchange (2021) โ ProxyLogon
The Attack:
- Chain of 4 vulnerabilities including authentication bypass
- Attackers forged server-side request to Exchange's internal API
- Bypassed authentication entirely
- Gained SYSTEM-level access
- 250,000+ servers compromised globally
The Lesson: Even well-designed authentication can be bypassed if any component fails.
๐ Case 5: Okta (2022) โ Session Token Theft
The Attack:
- Attackers compromised a Okta support engineer's laptop
- Stole session cookies from browser
- Used cookies to access customer admin panels
- 366 customers affected
The Lesson: Session tokens are bearer tokens โ whoever has them, IS the user. Protect them like passwords.
๐บ๏ธ Technical Deep-Dive: CWE Mapping
A07:2021 maps to 22 CWEs. The most critical:
The Critical CWEs Explained
๐ธ CWE-521: Weak Password Requirements
๐ด EXAMPLES:
- Minimum 6 characters
- No complexity requirements
- No breach check
- Common passwords allowed ("password123")
๐ข MODERN REQUIREMENTS (NIST SP 800-63B):
- Minimum 8 characters (12+ preferred)
- Check against breach databases (HaveIBeenPwned)
- No arbitrary complexity rules (per NIST)
- No periodic forced resets (unless breach suspected)
- Allow passphrases and all printable characters๐ธ CWE-307: Improper Restriction of Excessive Authentication Attempts
๐ด NO PROTECTION:
- Unlimited login attempts
- Enables brute force and credential stuffing
๐ข PROTECTION:
- Account lockout after N attempts (5-10 typically)
- Exponential backoff on failures
- CAPTCHA after 3 failed attempts
- IP-based rate limiting
- Device fingerprinting
- Anomaly detection (unusual location/time)๐ธ CWE-384: Session Fixation
# ๐ด VULNERABLE
def login(username, password):
if authenticate(username, password):
# Session ID stays the same before and after login
session['user_id'] = get_user_id(username)
return "Logged in"
# ๐ข SECURE
def login(username, password):
if authenticate(username, password):
# โ
Regenerate session ID on login
session.regenerate_id()
session['user_id'] = get_user_id(username)
return "Logged in"๐ธ CWE-916: Use of Password Hash With Insufficient Computational Effort
๐ด BROKEN:
- MD5 (cracked, instant)
- SHA-1 (cracked, instant)
- SHA-256 without salt (rainbow tables)
- SHA-256 with salt but 1 iteration (fast GPU attack)
๐ข SECURE (in order of preference):
- Argon2id (OWASP recommended)
- scrypt (good)
- bcrypt (classic, still secure)
- PBKDF2 (acceptable, use high iteration count)
Minimum settings:
- Argon2id: m=19MB, t=2, p=1
- bcrypt: cost=12
- PBKDF2: 600,000 iterations (SHA-256)๐ Password-Based Authentication: Done Right
The NIST 800-63B Modern Password Guidelines
Forget everything you knew about passwords. NIST updated their guidelines in 2017, and most sites still follow outdated advice.
โ OLD (and WRONG) ADVICE:
- Force password changes every 90 days
- Require 1 uppercase, 1 lowercase, 1 digit, 1 symbol
- Minimum 8 characters
- Prohibit sequential characters
- Password hints
โ
NEW (NIST 800-63B) GUIDELINES:
- Minimum 8 characters (12+ for high-security)
- Check against known breach databases
- Allow all printable characters AND spaces
- Allow long passphrases (up to 64 chars minimum)
- NO periodic forced changes (change only on compromise)
- NO password hints
- NO knowledge-based security questions
- NO SMS-based 2FA for high-security contextsWhy Forced Password Rotation Is HARMFUL
Research shows (and NIST confirms):
When forced to change passwords every 90 days, users:
๐ด Create weaker passwords (easier to remember new ones)
๐ด Use predictable patterns: Password1 โ Password2 โ Password3
๐ด Reuse passwords from personal accounts
๐ด Write passwords down (sticky notes under keyboards)
๐ด Use password managers less effectively
Result: SECURITY DECREASES, not increases.The Password Lifecycle
Password Storage: The Right Way
# โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
# ๐ด CATASTROPHIC โ Plaintext storage
# โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
def register(username, password):
db.execute(
"INSERT INTO users (username, password) VALUES (?, ?)",
(username, password) # ๐ด PLAINTEXT!
)
# โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
# ๐ด BROKEN โ MD5 (cracked instantly)
# โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
import hashlib
def register(username, password):
hashed = hashlib.md5(password.encode()).hexdigest() # ๐ด
db.execute("INSERT INTO users VALUES (?, ?)", (username, hashed))
# โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
# ๐ด STILL BROKEN โ SHA-256 without salt (rainbow tables)
# โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
def register(username, password):
hashed = hashlib.sha256(password.encode()).hexdigest() # ๐ด
db.execute("INSERT INTO users VALUES (?, ?)", (username, hashed))
# โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
# ๐ก OKAY โ bcrypt (acceptable, but Argon2id is better)
# โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
import bcrypt
def register(username, password):
salt = bcrypt.gensalt(rounds=12)
hashed = bcrypt.hashpw(password.encode(), salt)
db.execute("INSERT INTO users VALUES (?, ?)", (username, hashed))
# โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
# ๐ข BEST โ Argon2id (OWASP recommended)
# โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
from argon2 import PasswordHasher
from argon2.exceptions import VerifyMismatchError, VerificationError
# Create hasher with OWASP-recommended parameters
ph = PasswordHasher(
time_cost=2, # Iterations
memory_cost=19456, # 19 MB
parallelism=1, # Threads
hash_len=32, # Hash output length
salt_len=16 # Salt length
)
def register(username, password):
# โ
Validate password strength FIRST
if not is_strong_password(password):
raise ValueError("Password too weak")
# โ
Check against breach database
if is_breached_password(password):
raise ValueError("Password found in data breaches")
# โ
Hash with Argon2id
hashed = ph.hash(password)
# โ
Store only the hash (contains salt + params)
db.execute(
"INSERT INTO users (username, password_hash) VALUES (?, ?)",
(username, hashed)
)
def login(username, password):
user = db.query_one(
"SELECT password_hash FROM users WHERE username = ?",
(username,)
)
if not user:
# โ
Prevent timing attacks โ hash a dummy password
ph.hash("dummy_password_for_timing")
raise AuthenticationError("Invalid credentials")
try:
ph.verify(user['password_hash'], password)
except (VerifyMismatchError, VerificationError):
raise AuthenticationError("Invalid credentials")
# โ
Check if hash needs upgrading (if we increase parameters later)
if ph.check_needs_rehash(user['password_hash']):
new_hash = ph.hash(password)
db.execute(
"UPDATE users SET password_hash = ? WHERE username = ?",
(new_hash, username)
)
return "Logged in"Checking Against Breached Passwords
import hashlib
import requests
def is_breached_password(password: str) -> bool:
"""
Check password against HaveIBeenPwned API.
Uses k-anonymity: only sends first 5 chars of SHA-1 hash.
The password itself NEVER leaves your server.
"""
# Hash the password
sha1_hash = hashlib.sha1(password.encode()).hexdigest().upper()
prefix = sha1_hash[:5]
suffix = sha1_hash[5:]
# Query HIBP API (returns all hashes with same prefix)
response = requests.get(
f'https://api.pwnedpasswords.com/range/{prefix}',
timeout=5
)
if response.status_code != 200:
# Fail secure โ if we can't check, assume it's breached
return True
# Check if our suffix is in the response
for line in response.text.splitlines():
hash_suffix, count = line.split(':')
if hash_suffix == suffix:
return True # Found in breaches
return False๐ก๏ธ Multi-Factor Authentication (MFA)
MFA is the single most effective defense against account takeovers. Microsoft reports MFA blocks 99.9% of automated attacks.
The Three Factors
MFA Methods Ranked
| Method | Security | Usability | Phishing Resistant | Notes |
|---|---|---|---|---|
| FIDO2 / WebAuthn | ๐ข๐ข๐ข๐ข๐ข | ๐ข๐ข๐ข๐ข | โ YES | Best option โ hardware keys or biometric |
| Hardware tokens (YubiKey) | ๐ข๐ข๐ข๐ข๐ข | ๐ข๐ข๐ข | โ YES | Great for high-security |
| TOTP apps (Authy, Google) | ๐ข๐ข๐ข๐ข | ๐ข๐ข๐ข๐ข | โ NO | Good, but phishable |
| Push notifications | ๐ข๐ข๐ข | ๐ข๐ข๐ข๐ข๐ข | โ NO | Fatigue attacks possible |
| SMS codes | ๐ก๐ก | ๐ข๐ข๐ข๐ข | โ NO | Deprecated by NIST |
| Email codes | ๐ก | ๐ข๐ข๐ข | โ NO | Only slightly better than SMS |
| Security questions | ๐ด | ๐ข๐ข | โ NO | Never use |
Why SMS 2FA Is Bad (But Still Better Than Nothing)
๐ด SMS 2FA vulnerabilities:
1. SIM swapping (social engineer carrier โ steal phone number)
2. SS7 attacks (telecom protocol flaws โ intercept SMS)
3. Phishing (user types code into fake site)
4. Malware on phone (reads SMS)
5. Phone theft
NIST deprecated SMS 2FA in 2016 for government/high-security use.
But: SMS 2FA still blocks 76% of automated attacks.
Better than NO MFA. But WebAuthn blocks 100%.Implementing TOTP (Time-based One-Time Password)
import pyotp
import qrcode
from io import BytesIO
import base64
def enable_mfa(user_id: str):
"""Enable TOTP MFA for a user"""
# โ
Generate a cryptographically random secret
secret = pyotp.random_base32()
# โ
Store encrypted in database (NOT plaintext!)
encrypted_secret = encrypt(secret, get_encryption_key())
db.execute(
"UPDATE users SET mfa_secret = ?, mfa_enabled = FALSE WHERE id = ?",
(encrypted_secret, user_id)
)
# โ
Generate provisioning URI
user = get_user(user_id)
totp = pyotp.TOTP(secret)
uri = totp.provisioning_uri(
name=user['email'],
issuer_name='MySecureApp'
)
# โ
Generate QR code
qr = qrcode.make(uri)
buffer = BytesIO()
qr.save(buffer, format='PNG')
qr_base64 = base64.b64encode(buffer.getvalue()).decode()
# โ
Also provide backup codes
backup_codes = generate_backup_codes(10)
store_backup_codes(user_id, backup_codes)
return {
'qr_code': f'data:image/png;base64,{qr_base64}',
'secret': secret, # Show once for manual entry
'backup_codes': backup_codes
}
def verify_mfa(user_id: str, token: str) -> bool:
"""Verify TOTP token"""
user = db.query_one(
"SELECT mfa_secret FROM users WHERE id = ? AND mfa_enabled = TRUE",
(user_id,)
)
if not user:
return False
secret = decrypt(user['mfa_secret'], get_encryption_key())
totp = pyotp.TOTP(secret)
# โ
Allow 1 window drift (30 seconds) for clock skew
# โ
Verify once โ prevents token reuse
if totp.verify(token, valid_window=1):
# Check for replay attack
if is_token_already_used(user_id, token):
return False
mark_token_used(user_id, token)
return True
return False
def generate_backup_codes(count: int = 10) -> list:
"""Generate single-use backup codes"""
import secrets
import string
codes = []
for _ in range(count):
# Format: XXXX-XXXX (readable)
code = '-'.join(
''.join(secrets.choice(string.ascii_uppercase + string.digits)
for _ in range(4))
for _ in range(2)
)
codes.append(code)
return codesWebAuthn / FIDO2: The Future of Authentication
// ๐ข WEBAUTHN REGISTRATION (Browser)
async function registerPasskey() {
// Server generates challenge
const options = await fetch('/api/webauthn/register-options', {
method: 'POST'
}).then(r => r.json());
// Browser creates credential (uses platform authenticator or security key)
const credential = await navigator.credentials.create({
publicKey: {
challenge: base64ToBuffer(options.challenge),
rp: {
name: 'MySecureApp',
id: 'example.com'
},
user: {
id: base64ToBuffer(options.user.id),
name: options.user.email,
displayName: options.user.name
},
pubKeyCredParams: [
{ alg: -7, type: 'public-key' }, // ES256
{ alg: -257, type: 'public-key' } // RS256
],
authenticatorSelection: {
authenticatorAttachment: 'platform', // Prefer built-in
userVerification: 'required', // Require biometric/PIN
residentKey: 'preferred' // Discoverable credentials
},
timeout: 60000,
attestation: 'direct'
}
});
// Send credential to server for verification
await fetch('/api/webauthn/register', {
method: 'POST',
body: JSON.stringify({
id: credential.id,
rawId: bufferToBase64(credential.rawId),
response: {
clientDataJSON: bufferToBase64(credential.response.clientDataJSON),
attestationObject: bufferToBase64(credential.response.attestationObject)
}
})
});
}Why WebAuthn is revolutionary:
- ๐ข Phishing-resistant โ Tied to the domain, can't be used on fake sites
- ๐ข No shared secrets โ Private key never leaves device
- ๐ข Biometric-friendly โ Uses Touch ID, Face ID, Windows Hello
- ๐ข No codes to steal โ Nothing to type, nothing to intercept
- ๐ข Resistant to replay โ Each authentication is unique
๐ช Session Management: The Forgotten Battlefield
Even perfect authentication is useless if your session management is broken.
The Session Lifecycle
Secure Cookie Configuration
"text-slate-500 font-normal italic"># ๐ข PRODUCTION-READY SESSION COOKIE
Set-Cookie: __Host-sessionid=abc123xyz...;
Secure; "text-slate-500 font-normal italic"># Only sent over HTTPS
HttpOnly; "text-slate-500 font-normal italic"># No JavaScript access (XSS protection)
SameSite=Strict; "text-slate-500 font-normal italic"># No cross-site requests (CSRF protection)
Path=/; "text-slate-500 font-normal italic"># Available site-wide
Max-Age=3600; "text-slate-500 font-normal italic"># Expires in 1 hour
"text-slate-500 font-normal italic"># No Domain attribute (default: current host only)
"text-slate-500 font-normal italic"># Why __Host- prefix?
"text-slate-500 font-normal italic"># - Requires Secure flag
"text-slate-500 font-normal italic"># - Prohibits Domain attribute
"text-slate-500 font-normal italic"># - Requires Path=/
"text-slate-500 font-normal italic"># - Maximum cookie securitySession Security Checklist
# ๐ข SECURE SESSION CONFIGURATION
from flask import Flask
from datetime import timedelta
import secrets
app = Flask(__name__)
app.config.update(
# โ
Strong secret key (never hardcode!)
SECRET_KEY=os.environ['SECRET_KEY'], # 64+ random bytes
# โ
Secure cookie settings
SESSION_COOKIE_NAME='__Host-session',
SESSION_COOKIE_SECURE=True, # HTTPS only
SESSION_COOKIE_HTTPONLY=True, # No JS access
SESSION_COOKIE_SAMESITE='Strict', # CSRF protection
# โ
Short expiration
PERMANENT_SESSION_LIFETIME=timedelta(minutes=30),
# โ
Session regeneration
SESSION_REFRESH_EACH_REQUEST=True,
)
def generate_session_id() -> str:
"""Generate cryptographically secure session ID"""
# โ
256 bits of entropy minimum
return secrets.token_urlsafe(32)
def login(username, password, mfa_token):
# โ
Verify credentials
user = authenticate(username, password, mfa_token)
if not user:
raise AuthenticationError()
# โ
CRITICAL: Regenerate session ID on login
# Prevents session fixation attacks
session.clear()
session.regenerate() # New ID
# โ
Store minimal data in session
session['user_id'] = user.id
session['authenticated_at'] = time.time()
session['ip_address'] = request.remote_addr
session['user_agent_hash'] = hash_user_agent(request.user_agent)
# โ
Store session server-side (not just in cookie)
session_store.set(session.sid, {
'user_id': user.id,
'created_at': datetime.utcnow(),
'last_activity': datetime.utcnow(),
'ip_address': request.remote_addr
}, ex=1800) # 30 min TTL
# โ
Log authentication event
audit_log.info(f"Login success: user={user.id} ip={request.remote_addr}")
def validate_session():
"""Validate session on every request"""
if 'user_id' not in session:
return None
# โ
Check session age
if time.time() - session['authenticated_at'] > 86400: # 24 hours max
session.clear()
return None
# โ
Check idle timeout (30 min)
last_activity = session.get('last_activity', 0)
if time.time() - last_activity > 1800:
session.clear()
return None
# โ
Detect session hijacking
# Check if IP or User-Agent changed (session binding)
if session.get('ip_address') != request.remote_addr:
audit_log.warning(f"Session IP mismatch: user={session['user_id']}")
session.clear()
return None
if session.get('user_agent_hash') != hash_user_agent(request.user_agent):
audit_log.warning(f"User-Agent change: user={session['user_id']}")
session.clear()
return None
# โ
Update last activity
session['last_activity'] = time.time()
return get_user(session['user_id'])
def logout():
user_id = session.get('user_id')
# โ
Delete session server-side
if hasattr(session, 'sid'):
session_store.delete(session.sid)
# โ
Clear client-side cookie
session.clear()
# โ
Audit log
if user_id:
audit_log.info(f"Logout: user={user_id}")Session Timeout Strategy
๐ข LAYERED TIMEOUTS:
1. Idle Timeout: 15-30 minutes
โ User inactive = logged out
โ Prevents unattended session abuse
2. Absolute Timeout: 8-24 hours
โ Maximum session lifetime
โ Forces re-authentication regardless of activity
3. Re-authentication: For sensitive actions
โ Change password, transfer money, delete data
โ Require password/MFA even in active session
4. Session Invalidation on:
- Password change
- MFA setup/removal
- Email change
- Suspicious activity
- Logout from any device (if "logout all" chosen)๐ซ JWT Security: The Modern Pitfalls
JSON Web Tokens (JWTs) are everywhere in modern APIs. They're also routinely implemented incorrectly.
JWT Structure
header.payload.signature
eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0NSJ9.X_signature
Header (base64): {"alg": "HS256", "typ": "JWT"}
Payload (base64): {"sub": "12345", "exp": 1234567890}
Signature: HMAC-SHA256(header + "." + payload, secret)The "None" Algorithm Attack
# ๐ด CRITICAL VULNERABILITY โ "none" algorithm bypass
# Attacker sends:
# Header: {"alg": "none"}
# Payload: {"sub": "admin", "role": "superuser"}
# Signature: (empty)
# ๐ด VULNERABLE CODE
import jwt
def verify_token(token):
payload = jwt.decode(token, verify=False) # ๐ด DISASTER
return payload
# ๐ข SECURE CODE
def verify_token(token):
try:
payload = jwt.decode(
token,
SECRET_KEY,
algorithms=['HS256'], # โ
Explicitly specify algorithm
options={
'verify_signature': True,
'verify_exp': True,
'verify_iat': True,
'verify_nbf': True,
'require': ['exp', 'iat', 'sub']
}
)
return payload
except jwt.InvalidTokenError:
return NoneThe Algorithm Confusion Attack
Scenario: Server uses RS256 (asymmetric)
Attacker tricks server to use HS256 (symmetric)
1. Attacker gets server's PUBLIC key (it's public!)
2. Attacker crafts token:
- Header: {"alg": "HS256"} (changed from RS256)
- Payload: {"sub": "admin"}
- Signature: HMAC-SHA256(header + payload, PUBLIC_KEY)
3. Server receives token, uses PUBLIC key to verify HS256
4. Signature matches! โ Attacker is admin
๐ข FIX: Hard-code expected algorithm, reject tokens with mismatched algSecure JWT Implementation
import jwt
import datetime
import secrets
from functools import wraps
# โ
Use STRONG secret (256+ bits for HS256)
JWT_SECRET = os.environ['JWT_SECRET'] # Loaded from secrets manager
JWT_ALGORITHM = 'HS256'
JWT_ISSUER = 'myapp.com'
JWT_AUDIENCE = 'myapp.com/api'
# โ
Short access token lifetime
ACCESS_TOKEN_EXPIRE_MINUTES = 15
# โ
Longer refresh token lifetime (but still limited)
REFRESH_TOKEN_EXPIRE_DAYS = 7
def create_access_token(user_id: str, roles: list) -> str:
"""Create short-lived access token"""
now = datetime.datetime.utcnow()
payload = {
'sub': user_id, # Subject (user ID)
'iat': now, # Issued at
'exp': now + datetime.timedelta( # Expiration
minutes=ACCESS_TOKEN_EXPIRE_MINUTES
),
'nbf': now, # Not before
'iss': JWT_ISSUER, # Issuer
'aud': JWT_AUDIENCE, # Audience
'jti': secrets.token_urlsafe(16), # Unique token ID
'type': 'access',
'roles': roles
}
return jwt.encode(payload, JWT_SECRET, algorithm=JWT_ALGORITHM)
def create_refresh_token(user_id: str) -> str:
"""Create refresh token (stored server-side)"""
now = datetime.datetime.utcnow()
token_id = secrets.token_urlsafe(32)
payload = {
'sub': user_id,
'iat': now,
'exp': now + datetime.timedelta(days=REFRESH_TOKEN_EXPIRE_DAYS),
'nbf': now,
'iss': JWT_ISSUER,
'jti': token_id,
'type': 'refresh'
}
# โ
Store refresh token server-side for revocation
refresh_store.set(f"refresh:{token_id}", {
'user_id': user_id,
'created_at': now.isoformat()
}, ex=REFRESH_TOKEN_EXPIRE_DAYS * 86400)
return jwt.encode(payload, JWT_SECRET, algorithm=JWT_ALGORITHM)
def verify_token(token: str, token_type: str = 'access') -> dict:
"""Verify and decode JWT"""
try:
payload = jwt.decode(
token,
JWT_SECRET,
algorithms=[JWT_ALGORITHM], # โ
Explicit algorithm
issuer=JWT_ISSUER,
audience=JWT_AUDIENCE,
options={
'verify_signature': True,
'verify_exp': True,
'verify_iat': True,
'verify_nbf': True,
'verify_iss': True,
'verify_aud': True,
'require': ['exp', 'iat', 'sub', 'jti', 'type']
}
)
# โ
Verify token type
if payload.get('type') != token_type:
raise jwt.InvalidTokenError("Wrong token type")
# โ
Check revocation list
if is_token_revoked(payload['jti']):
raise jwt.InvalidTokenError("Token revoked")
# โ
For refresh tokens, verify server-side existence
if token_type == 'refresh':
if not refresh_store.exists(f"refresh:{payload['jti']}"):
raise jwt.InvalidTokenError("Refresh token not found")
return payload
except jwt.ExpiredSignatureError:
raise AuthenticationError("Token expired")
except jwt.InvalidTokenError as e:
raise AuthenticationError(f"Invalid token: {e}")
def revoke_token(jti: str, exp: datetime.datetime):
"""Add token to revocation list"""
ttl = int((exp - datetime.datetime.utcnow()).total_seconds())
if ttl > 0:
revocation_store.set(f"revoked:{jti}", "1", ex=ttl)
def is_token_revoked(jti: str) -> bool:
return revocation_store.exists(f"revoked:{jti}")JWT Best Practices Summary
โ
DO:
- Use short expiration (15 min for access tokens)
- Implement refresh token rotation
- Store refresh tokens server-side for revocation
- Use strong secrets (256+ bits)
- Explicitly specify algorithm when verifying
- Validate ALL claims (exp, iat, iss, aud, nbf)
- Use HTTPS exclusively
- Store in httpOnly, Secure, SameSite cookies (not localStorage)
โ DON'T:
- Store JWTs in localStorage (XSS vulnerability)
- Use "none" algorithm
- Trust the alg header without validation
- Use long-lived access tokens
- Store sensitive data in payload (it's base64, not encrypted)
- Skip signature verification "for performance"
- Use JWTs for session management without revocation strategy๐ OAuth 2.0 & OIDC: Doing It Right
OAuth 2.0 is authorization. OpenID Connect (OIDC) is authentication built on OAuth. Both are commonly misused.
The OAuth 2.0 Flows
Authorization Code Flow + PKCE (Modern Standard)
Common OAuth Pitfalls
๐ด PITFALL 1: Using Implicit Flow in SPAs
OLD: Implicit flow returned tokens in URL fragment (leaked)
NEW: Use Authorization Code + PKCE for SPAs
๐ด PITFALL 2: Not validating redirect_uri
Attacker: /authorize?redirect_uri=attacker.com
Server: Sends code to attacker.com
FIX: Exact match whitelist of redirect URIs
๐ด PITFALL 3: Using OAuth for authentication (without OIDC)
OAuth = "This app can access your data"
NOT = "This person is who they say they are"
FIX: Use OIDC (which adds id_token for authentication)
๐ด PITFALL 4: Not verifying state parameter (CSRF)
FIX: Generate random state, verify on callback
๐ด PITFALL 5: Treating access tokens as session tokens
Access tokens = short-lived (minutes)
Sessions = can be longer with proper management
๐ด PITFALL 6: Storing tokens in localStorage
XSS = instant token theft
FIX: httpOnly cookies or BFF (Backend for Frontend) pattern๐ฎ Passwordless Authentication: The Future
The password is dying. Here's what's replacing it:
Passkeys (FIDO2 + WebAuthn)
๐ How Passkeys Work:
1. User registers: Device generates public/private key pair
2. Private key: NEVER leaves the device (secured by biometric/PIN)
3. Public key: Sent to server, stored with user account
4. Login: Server sends challenge โ device signs with private key
5. Server verifies signature with public key โ authenticated
Benefits:
๐ข Phishing-impossible (tied to domain cryptographically)
๐ข No shared secret to steal
๐ข Works across devices (iCloud/Google sync)
๐ข Biometric unlock (Face ID, Touch ID, Windows Hello)
๐ข Faster than passwordsMagic Links
# โ
MAGIC LINK AUTHENTICATION
import secrets
from datetime import datetime, timedelta
def send_magic_link(email: str):
# โ
Rate limit
if is_rate_limited(email):
return # Don't reveal if user exists
# โ
Generate cryptographically random token
token = secrets.token_urlsafe(32)
# โ
Store with short TTL (10-15 minutes)
magic_link_store.set(
f"magic:{token}",
{'email': email, 'created_at': datetime.utcnow().isoformat()},
ex=900 # 15 minutes
)
# โ
Send email with link
link = f"https://app.example.com/auth/verify?token={token}"
send_email(
to=email,
subject="Your login link",
body=f"Click to log in: {link}\n\nExpires in 15 minutes."
)
def verify_magic_link(token: str):
data = magic_link_store.get(f"magic:{token}")
if not data:
raise AuthenticationError("Invalid or expired link")
# โ
Delete immediately (single-use)
magic_link_store.delete(f"magic:{token}")
# โ
Create session
user = get_or_create_user(data['email'])
create_session(user.id)
return user๐ง Account Recovery: The Weakest Link
The security of an account is the security of its recovery process. Attackers often skip the login page and go straight to "Forgot Password."
The Recovery Hierarchy (Best to Worst)
Secure Password Reset Flow
def request_password_reset(email: str):
"""Initiate password reset"""
# โ
Rate limit (per email AND per IP)
if is_rate_limited(f"reset:{email}") or is_rate_limited(f"ip:{request.ip}"):
# Silent fail โ don't reveal rate limiting
return
user = get_user_by_email(email)
# โ
ALWAYS respond the same way (prevent enumeration)
# Even if user doesn't exist, pretend we sent email
response_delay() # Consistent timing
if user:
# โ
Generate cryptographically secure token
token = secrets.token_urlsafe(32)
token_hash = hashlib.sha256(token.encode()).hexdigest()
# โ
Store HASH of token (not plaintext)
db.execute("""
INSERT INTO password_resets (user_id, token_hash, expires_at)
VALUES (?, ?, ?)
""", (user.id, token_hash, datetime.utcnow() + timedelta(minutes=15)))
# โ
Send email with link
link = f"https://app.com/reset?token={token}"
send_email(
to=email,
subject="Reset your password",
body=f"Click to reset: {link}\n\n"
f"Expires in 15 minutes.\n"
f"If you didn't request this, ignore this email.\n"
f"Request from IP: {request.ip} at {datetime.utcnow()}"
)
# โ
Notify user on ALL channels
if user.phone:
send_sms(user.phone, "Password reset requested. Not you? Reply STOP.")
# โ
Audit log
audit_log.info(f"Password reset requested: user={user.id}")
def verify_reset_token(token: str, new_password: str):
"""Complete password reset"""
token_hash = hashlib.sha256(token.encode()).hexdigest()
# โ
Look up by hash (constant time comparison)
reset = db.query_one("""
SELECT user_id, expires_at FROM password_resets
WHERE token_hash = ? AND used_at IS NULL
""", (token_hash,))
if not reset:
raise ValueError("Invalid or expired token")
if reset.expires_at < datetime.utcnow():
raise ValueError("Token expired")
# โ
Validate new password
if not is_strong_password(new_password):
raise ValueError("Password too weak")
if is_breached_password(new_password):
raise ValueError("Password found in breaches")
user = get_user(reset.user_id)
# โ
Check it's not the same as current password
if verify_password(new_password, user.password_hash):
raise ValueError("Must be different from current password")
# โ
Update password
user.password_hash = hash_password(new_password)
db.save(user)
# โ
Mark token as used (single-use)
db.execute(
"UPDATE password_resets SET used_at = ? WHERE token_hash = ?",
(datetime.utcnow(), token_hash)
)
# โ
Invalidate ALL existing sessions (force re-login everywhere)
invalidate_all_sessions(user.id)
# โ
Notify user of change
send_email(
to=user.email,
subject="Your password was changed",
body=f"Your password was changed at {datetime.utcnow()}.\n"
f"If this wasn't you, contact support IMMEDIATELY.\n"
f"All active sessions have been terminated."
)
# โ
Require MFA re-enrollment if MFA was reset
if was_mfa_bypassed_during_recovery:
require_mfa_setup(user.id)
audit_log.info(f"Password reset completed: user={user.id}")๐ป Code Examples: Vulnerable vs Secure
Example 1: Complete Login Implementation
# โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
# ๐ด VULNERABLE LOGIN
# โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
@app.route('/login', methods=['POST'])
def login():
username = request.form.get('username')
password = request.form.get('password')
# ๐ด SQL injection + plaintext comparison
user = db.execute(
f"SELECT * FROM users WHERE username='{username}' AND password='{password}'"
).fetchone()
if user:
session['user'] = username # ๐ด No session regeneration
return "Logged in"
else:
# ๐ด Different messages = enumeration
if db.execute(f"SELECT * FROM users WHERE username='{username}'").fetchone():
return "Wrong password"
else:
return "User not found"
# โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
# ๐ข SECURE LOGIN
# โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
from flask import Flask, request, session, abort
from flask_limiter import Limiter
from flask_limiter.util import get_remote_address
from argon2 import PasswordHasher
from argon2.exceptions import VerifyMismatchError
import secrets
import time
from datetime import datetime, timedelta
limiter = Limiter(app, key_func=get_remote_address)
ph = PasswordHasher(time_cost=2, memory_cost=19456, parallelism=1)
@app.route('/login', methods=['POST'])
@limiter.limit("5 per minute") # โ
IP rate limit
@limiter.limit("10 per hour") # โ
Hourly limit
def login():
data = request.get_json()
username = data.get('username', '').strip().lower()
password = data.get('password', '')
mfa_token = data.get('mfa_token')
# โ
Input validation
if not username or not password:
return {'error': 'Credentials required'}, 400
if len(username) > 254 or len(password) > 128:
return {'error': 'Invalid credentials'}, 401
# โ
Check if account is locked
if is_account_locked(username):
audit_log.warning(f"Login attempt on locked account: {username}")
# Same response as invalid credentials
time.sleep(0.5) # Consistent timing
return {'error': 'Invalid credentials'}, 401
# โ
Check if IP is blocked
if is_ip_blocked(request.remote_addr):
audit_log.warning(f"Login from blocked IP: {request.remote_addr}")
abort(403)
# โ
Parameterized query
user = db.query_one(
"SELECT id, password_hash, mfa_enabled, mfa_secret, failed_attempts "
"FROM users WHERE username = ? AND is_active = TRUE",
(username,)
)
# โ
Timing attack prevention
# Always perform hash verification, even if user doesn't exist
if not user:
# Dummy verification to maintain consistent timing
try:
ph.verify(
"$argon2id$v=19$m=19456,t=2,p=1$dummysalt$dummyhashvalue",
password
)
except:
pass
time.sleep(0.1) # Add jitter
audit_log.info(f"Login failed (no user): {username} from {request.remote_addr}")
return {'error': 'Invalid credentials'}, 401
# โ
Verify password
try:
ph.verify(user['password_hash'], password)
except VerifyMismatchError:
# โ
Increment failed attempts
increment_failed_attempts(user['id'])
# โ
Lock account after 5 failed attempts
if user['failed_attempts'] + 1 >= 5:
lock_account(user['id'], duration_minutes=30)
send_security_alert(user['id'], 'account_locked')
audit_log.info(f"Login failed (bad password): user={user['id']}")
return {'error': 'Invalid credentials'}, 401
# โ
Password correct โ check if MFA required
if user['mfa_enabled']:
if not mfa_token:
# Return partial authentication (needs MFA)
temp_token = create_mfa_pending_token(user['id'])
return {'mfa_required': True, 'mfa_token_id': temp_token}, 200
if not verify_totp(user['mfa_secret'], mfa_token):
audit_log.warning(f"MFA failed: user={user['id']}")
return {'error': 'Invalid credentials'}, 401
# โ
Successful login
reset_failed_attempts(user['id'])
# โ
Check for suspicious login (new location/device)
if is_suspicious_login(user['id'], request):
send_security_alert(user['id'], 'new_device_login')
# โ
Rehash if parameters have changed
if ph.check_needs_rehash(user['password_hash']):
new_hash = ph.hash(password)
db.execute(
"UPDATE users SET password_hash = ? WHERE id = ?",
(new_hash, user['id'])
)
# โ
Regenerate session ID (prevent fixation)
session.clear()
session.regenerate()
# โ
Set session data
session['user_id'] = user['id']
session['authenticated_at'] = time.time()
session['ip'] = request.remote_addr
session['ua_hash'] = hash_user_agent(request.headers.get('User-Agent'))
session.permanent = True
# โ
Audit log
audit_log.info(
f"Login success: user={user['id']} "
f"ip={request.remote_addr}"
)
# โ
Update last login
db.execute(
"UPDATE users SET last_login_at = ?, last_login_ip = ? WHERE id = ?",
(datetime.utcnow(), request.remote_addr, user['id'])
)
return {'success': True, 'user': {'id': user['id']}}, 200โ The Ultimate Checklist
๐ Authentication Security Checklist
๐ Password Management
- [ ] Minimum 12 characters required (NIST recommends 8+, prefer longer)
- [ ] Allow all printable characters including spaces
- [ ] Allow passphrases up to 64+ characters
- [ ] Check against breached password databases (HaveIBeenPwned)
- [ ] NO periodic forced password changes (unless breach suspected)
- [ ] NO arbitrary complexity rules (per NIST guidelines)
- [ ] NO password hints
- [ ] NO security questions
- [ ] Passwords hashed with Argon2id (preferred), scrypt, or bcrypt
- [ ] Hash parameters meet OWASP recommendations
- [ ] Salt generated per-password with crypto-random
- [ ] Automatic rehashing when parameters upgraded
๐ก๏ธ Multi-Factor Authentication
- [ ] MFA available for all users
- [ ] MFA REQUIRED for admin accounts
- [ ] MFA REQUIRED for sensitive operations
- [ ] FIDO2/WebAuthn supported (preferred method)
- [ ] TOTP (Authy, Google Authenticator) supported
- [ ] Backup codes generated (single-use)
- [ ] SMS 2FA avoided (or marked as weaker option)
- [ ] MFA cannot be bypassed via account recovery
- [ ] MFA setup/removal requires re-authentication
- [ ] User notified when MFA settings changed
๐ซ Brute Force Protection
- [ ] Account lockout after 5-10 failed attempts
- [ ] Progressive delays between attempts
- [ ] CAPTCHA after 3 failed attempts
- [ ] IP-based rate limiting
- [ ] User-based rate limiting
- [ ] Password spraying detection
- [ ] Anomaly detection (unusual location/time/device)
- [ ] Alerts sent to user on suspicious activity
- [ ] Honeypot accounts to detect attacks
๐ช Session Management
- [ ] Session IDs: 256+ bits of entropy
- [ ] Session IDs regenerated on login
- [ ] Session IDs regenerated on privilege change
- [ ] Idle timeout: 15-30 minutes
- [ ] Absolute timeout: 8-24 hours max
- [ ] Session cookies: Secure, HttpOnly, SameSite=Strict
- [ ] Sessions stored server-side (not just in cookie)
- [ ] Sessions invalidated on logout
- [ ] Sessions invalidated on password change
- [ ] Session binding (IP, User-Agent) with care for mobile users
- [ ] "Logout everywhere" functionality
- [ ] Active sessions visible to user
๐ซ Token Security (JWT/OAuth)
- [ ] JWT algorithm explicitly specified on verification
- [ ] "none" algorithm rejected
- [ ] Short access token lifetime (15 min)
- [ ] Refresh tokens with rotation
- [ ] Refresh tokens revocable server-side
- [ ] Tokens stored in httpOnly cookies (not localStorage)
- [ ] All JWT claims validated (exp, iat, iss, aud, nbf)
- [ ] OAuth: Authorization Code + PKCE (not Implicit)
- [ ] OAuth: Exact-match redirect URI validation
- [ ] OAuth: State parameter for CSRF protection
- [ ] OIDC used for authentication (not raw OAuth)
๐ Account Recovery
- [ ] Recovery process is as strong as authentication
- [ ] Time-limited recovery tokens (15 min)
- [ ] Single-use recovery tokens
- [ ] Recovery tokens stored as hashes
- [ ] Rate limiting on recovery requests
- [ ] Same response regardless of email existence
- [ ] Multi-channel notification on recovery
- [ ] MFA cannot be fully bypassed via recovery
- [ ] All sessions invalidated after password reset
- [ ] Audit trail of all recovery events
๐ Audit & Monitoring
- [ ] All authentication events logged
- [ ] Failed login attempts logged
- [ ] Suspicious patterns detected automatically
- [ ] Account lockouts tracked
- [ ] MFA changes audited
- [ ] Password changes audited
- [ ] New device/location notifications
- [ ] Real-time alerting on compromise indicators
- [ ] Login history visible to user
๐ Conclusion
The Hard Truth
Key Takeaways
๐ด Passwords alone are not enough. Period.
MFA blocks 99.9% of automated attacks. If you don't offer MFA, you don't take security seriously.
๐ก Sessions are as important as passwords.
A compromised session = compromised account. Treat session tokens like gold.
๐ข Modern authentication is passwordless.
WebAuthn/Passkeys are phishing-impossible. The future is here.
๐ต Recovery is often weaker than login.
If "Forgot Password" doesn't require MFA, MFA is useless.
๐ฃ Credentials in a breach are now PUBLIC.
Check against HaveIBeenPwned. Reject compromised passwords.
The Developer's Oath
I will hash passwords with Argon2id, not MD5.
I will require MFA for all privileged accounts.
I will regenerate session IDs on login.
I will rate limit every authentication endpoint.
I will give consistent responses to prevent enumeration.
I will secure cookies with HttpOnly, Secure, and SameSite.
I will treat JWT like plaintext โ because the payload IS plaintext.
I will offer passkey-based authentication.
I will make recovery as secure as login.
I will audit every authentication event.The Cost of Inaction
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ THE COST OF AUTHENTICATION FAILURES โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโค
โ โ
โ Colonial Pipeline (1 missing MFA): $4.4M + national crisisโ
โ 23andMe (credential stuffing): $30M + 6.9M victims โ
โ Twitter 2020 (admin takeover): $120K scam + chaos โ
โ Average account takeover cost: $1,200 per account โ
โ Average breach involving credentials: $4.5M total โ
โ โ
โ Cost of implementing MFA: ~$0 (free providers) โ
โ Cost of implementing WebAuthn: ~$0 (open standard) โ
โ Cost of using Argon2id: ~$0 (library) โ
โ โ
โ ROI of secure authentication: INFINITE โ
โ โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโFinal Words
Authentication is the foundation. If it fails, nothing else matters. The best encryption, the best firewall, the best code โ all worthless if an attacker can simply log in as a legitimate user.
In 2024, there is no excuse for:
- โ Storing passwords in plaintext or MD5
- โ Not offering MFA
- โ Using SMS as the only MFA option
- โ Allowing credential stuffing without rate limiting
- โ Not checking against breach databases
- โ Session fixation vulnerabilities
- โ JWT "none" algorithm acceptance
- โ Security questions as account recovery
Build authentication like your business depends on it. Because it does.
Every login is a battle. Every session is a target. Every password reset is an opportunity for attack.
Make your authentication a fortress, not a front door with a sticky note that says "key under mat."
<div align="center">
๐ Further Reading
| Resource | URL |
|---|---|
| OWASP Top 10 โ 2021 | https://owasp.org/Top10/ |
| OWASP Authentication Cheat Sheet | https://cheatsheetseries.owasp.org/cheatsheets/Authentication_Cheat_Sheet.html |
| OWASP Session Management Cheat Sheet | https://cheatsheetseries.owasp.org/cheatsheets/Session_Management_Cheat_Sheet.html |
| OWASP Password Storage Cheat Sheet | https://cheatsheetseries.owasp.org/cheatsheets/Password_Storage_Cheat_Sheet.html |
| NIST SP 800-63B Digital Identity Guidelines | https://pages.nist.gov/800-63-3/sp800-63b.html |
| HaveIBeenPwned API | https://haveibeenpwned.com/API/v3 |
| FIDO Alliance (WebAuthn) | https://fidoalliance.org/ |
| WebAuthn.io (Demo) | https://webauthn.io/ |
| OAuth 2.0 Security Best Current Practice | https://datatracker.ietf.org/doc/html/draft-ietf-oauth-security-topics |
| JWT Best Practices (RFC 8725) | https://datatracker.ietf.org/doc/html/rfc8725 |
Category: Application Security ยท Authentication ยท Identity Management ยท OWASP Tags: #OWASP #Authentication #MFA #WebAuthn #JWT #OAuth #Passkeys #A07 #SessionManagement
"The user's password is the key to their digital life. Treat it with the respect it deserves โ and build systems that don't rely on it alone."
"MFA is not optional. It's not advanced. It's not enterprise-grade. It's the bare minimum." โ Modern Security Reality
"The best password is one you don't have to remember โ because it doesn't exist." โ The Passkey Era
</div>