argon2.hash(password, {type: argon2.argon2id, memoryCost: 65536, timeCost: 3, parallelism: 1}) for password hashing; FIDO2/Passkeys for phishing-resistant MFA.| # | Vulnerability | Risk | Vulnerable Code | Secure Code |
|---|---|---|---|---|
| 1 | Plaintext/weak password storage | Critical | INSERT INTO users(pass) VALUES('${password}') | argon2.hash(password, {type: argon2id, memoryCost: 65536}) |
| 2 | No MFA on privileged accounts | Critical | Password-only admin login | TOTP/FIDO2 required for all admin accounts |
| 3 | Username enumeration via login | High | "Invalid password" / "User not found" | "Login failed; invalid user ID or password" |
| 4 | Username enumeration via registration | High | "This email is already registered" | "A link to activate your account has been emailed" |
| 5 | No rate limiting on login | High | Unlimited login attempts | 5 attempts per account per 15min, then exponential backoff |
| 6 | Session fixation | High | Reusing session ID after login | Regenerate session ID on authentication state change |
| 7 | Insecure session cookies | High | Set-Cookie: sid=abc123 | Set-Cookie: sid=abc123; HttpOnly; Secure; SameSite=Lax |
| 8 | Password length too short | Medium | minLength: 6 | minLength: 8 (with MFA) or minLength: 15 (without MFA) |
| 9 | Bcrypt with raw long passwords | Medium | bcrypt.hash(longPassword) (truncates at 72 bytes) | bcrypt(base64(hmac-sha384(password, pepper))) |
| 10 | No session timeout | Medium | Sessions never expire | Idle timeout: 15-30min; absolute timeout: 8-24hr |
| 11 | Missing breached password check | Medium | Accept any password meeting length rule | Check against HaveIBeenPwned Pwned Passwords API |
| 12 | Credential recovery reveals info | Medium | "No account with that email" | "If that email is in our database, we will send a reset link" |
START: What authentication concern are you addressing?
├── Password storage?
│ ├── New project → Use Argon2id (memoryCost: 65536, timeCost: 3, parallelism: 1)
│ ├── Existing bcrypt → Keep bcrypt (cost factor 10+), plan migration to Argon2id
│ └── Legacy MD5/SHA → Implement layered hashing: bcrypt(md5(password)), re-hash on next login
├── Multi-factor authentication?
│ ├── Consumer app → Offer TOTP + Passkeys, require for account recovery changes
│ ├── Enterprise/admin → Require FIDO2/Passkeys (phishing-resistant, NIST AAL2+)
│ └── FIPS-140 required → Use NIST-approved authenticators at AAL2/AAL3
├── Brute force protection?
│ ├── Public-facing login → Rate limit per-account: 5 attempts/15min + CAPTCHA after 3
│ ├── API authentication → Rate limit per-key + per-IP, return 429 with Retry-After
│ └── Admin login → Lock after 3 failures + email alert + MFA mandatory
├── Session management?
│ ├── Web app → HttpOnly + Secure + SameSite=Lax cookies, regenerate on login
│ ├── SPA/API → Short-lived access token (15min) + HttpOnly refresh token (7d)
│ └── Mobile app → Secure storage (Keychain/Keystore) + biometric unlock
└── DEFAULT → Apply all checklist items from Quick Reference table above
Argon2id is the OWASP first-choice algorithm. It resists both GPU attacks (memory-hard) and side-channel attacks (hybrid mode). OWASP recommends a minimum of 19 MiB memory and 2 iterations, but 64 MiB with 3 iterations provides a better security margin. [src2]
const argon2 = require('argon2'); // ^0.41.0
async function hashPassword(password) {
return argon2.hash(password, {
type: argon2.argon2id,
memoryCost: 65536, // 64 MiB
timeCost: 3, // 3 iterations
parallelism: 1, // 1 thread
hashLength: 32 // 256-bit output
});
}
async function verifyPassword(hash, password) {
return argon2.verify(hash, password);
// Constant-time comparison built in
}
Verify: Hash output should start with $argon2id$v=19$m=65536,t=3,p=1$
Every authentication response must be identical regardless of whether the account exists. This prevents attackers from enumerating valid usernames. [src1]
// SAME response for invalid user AND invalid password
const dummyHash = '$argon2id$v=19$m=65536,t=3,p=1$...';
const isValid = user
? await argon2.verify(user.passwordHash, password)
: await argon2.verify(dummyHash, password); // Always compare
if (!user || !isValid) {
return res.status(401).json({
error: 'Login failed; invalid user ID or password'
});
}
Verify: Non-existent email and wrong password for existing email must return identical error and similar response time.
Apply rate limiting per-account (not just per-IP) because credential stuffing attacks use distributed IPs. [src6]
const rateLimit = require('express-rate-limit'); // ^7.0.0
const authLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15-minute window
max: 20,
standardHeaders: true,
legacyHeaders: false
});
app.use('/auth/', authLimiter);
// Per-account lockout with exponential backoff
async function recordFailedAttempt(email) {
const key = `login:fail:${email}`;
const attempts = await redis.incr(key);
const lockoutSec = Math.min(Math.pow(2, attempts), 900);
await redis.expire(key, lockoutSec);
}
Verify: 6th rapid login attempt should return 429 or lockout message.
Offer TOTP as baseline MFA and FIDO2/Passkeys as the phishing-resistant option. NIST SP 800-63-4 requires phishing-resistant MFA at AAL2+. [src5] [src3]
const speakeasy = require('speakeasy'); // ^2.0.0
function generateTOTPSecret(userEmail) {
return speakeasy.generateSecret({
name: `MyApp:${userEmail}`,
issuer: 'MyApp',
length: 32 // 256-bit secret
});
}
function verifyTOTP(secret, token) {
return speakeasy.totp.verify({
secret, encoding: 'base32',
token, window: 1 // +-30s tolerance
});
}
Verify: Generate secret, scan QR with authenticator app, enter 6-digit code -- should return true.
Session cookies must use HttpOnly (prevents XSS theft), Secure (HTTPS only), and SameSite=Lax (CSRF protection). Regenerate the session ID after every authentication state change. [src4]
app.use(session({
secret: process.env.SESSION_SECRET,
name: '__Host-sid',
resave: false,
saveUninitialized: false,
cookie: {
httpOnly: true,
secure: true,
sameSite: 'lax',
maxAge: 30 * 60 * 1000, // 30-min idle timeout
path: '/'
}
}));
Verify: Response headers should include Set-Cookie: __Host-sid=...; HttpOnly; Secure; SameSite=Lax.
Block known-compromised passwords by checking the HaveIBeenPwned Pwned Passwords API using k-anonymity (only sends first 5 chars of SHA-1 hash prefix). [src1]
async function isPasswordBreached(password) {
const sha1 = crypto.createHash('sha1')
.update(password).digest('hex').toUpperCase();
const prefix = sha1.slice(0, 5);
const suffix = sha1.slice(5);
const res = await fetch(
`https://api.pwnedpasswords.com/range/${prefix}`
);
const body = await res.text();
return body.split('\n').some(l => l.startsWith(suffix));
}
Verify: isPasswordBreached('password123') should return true.
# settings.py -- set Argon2id as primary hasher
# pip install django[argon2]
PASSWORD_HASHERS = [
'django.contrib.auth.hashers.Argon2PasswordHasher',
'django.contrib.auth.hashers.BCryptSHA256PasswordHasher',
'django.contrib.auth.hashers.PBKDF2PasswordHasher',
]
# views.py -- secure login with generic errors
from django.contrib.auth import authenticate, login
from django.http import JsonResponse
def login_view(request):
email = request.POST.get('email')
password = request.POST.get('password')
user = authenticate(request, username=email, password=password)
if user is not None:
login(request) # Regenerates session ID automatically
return JsonResponse({'success': True})
return JsonResponse(
{'error': 'Login failed; invalid user ID or password'},
status=401
)
const argon2 = require('argon2'); // ^0.41.0
const rateLimit = require('express-rate-limit'); // ^7.0.0
const helmet = require('helmet'); // ^7.1.0
app.use(helmet());
app.use('/auth', rateLimit({
windowMs: 15 * 60 * 1000, max: 20, standardHeaders: true
}));
const hashOpts = {
type: argon2.argon2id,
memoryCost: 65536, timeCost: 3, parallelism: 1
};
app.post('/auth/register', async (req, res) => {
const hash = await argon2.hash(req.body.password, hashOpts);
await db.createUser(req.body.email, hash);
res.json({ message: 'A link to activate your account has been emailed' });
});
// SecurityConfig.java -- Spring Security 6.x
import org.springframework.security.crypto.argon2.Argon2PasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
@Bean
public PasswordEncoder passwordEncoder() {
// Argon2id: saltLength=16, hashLength=32, parallelism=1,
// memory=65536 KiB, iterations=3
return new Argon2PasswordEncoder(16, 32, 1, 65536, 3);
}
# BAD -- fast hashes enable billions of guesses per second
import hashlib
password_hash = hashlib.sha256(password.encode()).hexdigest()
# GPU can compute ~10 billion SHA-256 hashes/second
# GOOD -- memory-hard hash, ~300ms per attempt on server
from argon2 import PasswordHasher
ph = PasswordHasher(time_cost=3, memory_cost=65536, parallelism=1)
password_hash = ph.hash(password)
# Attacker needs 64 MiB per guess -- massively slows GPU attacks
// BAD -- different messages leak whether account exists
if (!user) return res.json({ error: 'User not found' });
if (!validPassword) return res.json({ error: 'Wrong password' });
// GOOD -- identical response regardless of failure reason
if (!user || !validPassword) {
return res.status(401).json({
error: 'Login failed; invalid user ID or password'
});
}
// BAD -- cookie accessible to XSS, sent over HTTP, vulnerable to CSRF
res.cookie('session', token);
// Equivalent to: Set-Cookie: session=abc123 (no flags)
// GOOD -- defense-in-depth cookie configuration
res.cookie('__Host-session', token, {
httpOnly: true, // No document.cookie access
secure: true, // HTTPS only
sameSite: 'lax', // CSRF protection
maxAge: 1800000, // 30-min timeout
path: '/'
});
// BAD -- credential stuffing uses thousands of IPs
const limiter = rateLimit({ windowMs: 60000, max: 100 });
// An attacker with a botnet bypasses this trivially
// GOOD -- track failures per account, not just per IP
const key = `login:fail:${email}`;
const failures = await redis.incr(key);
await redis.expire(key, Math.min(Math.pow(2, failures), 900));
if (failures >= 5) throw new Error('Account temporarily locked');
bcrypt(base64(hmac-sha384(password, pepper))) or migrate to Argon2id. [src2]req.session.regenerate() (Express) or equivalent after every authentication state change. [src4]# Check if Argon2 is available in Node.js
node -e "try{require('argon2');console.log('argon2: OK')}catch(e){console.log('MISSING')}"
# Verify password hash format is Argon2id
echo '$argon2id$v=19$m=65536,t=3,p=1$...' | grep -o 'argon2id'
# Check bcrypt cost factor (should be >= 10)
echo '$2b$12$...' | cut -d'$' -f3
# Test session cookie security headers
curl -sI https://your-app.com/login -X POST | grep -i 'set-cookie'
# Should contain: HttpOnly; Secure; SameSite=Lax
# Scan for hardcoded passwords in codebase
grep -rn 'password\s*=\s*["\x27]' --include="*.js" --include="*.py" .
# Check TLS configuration
nmap --script ssl-enum-ciphers -p 443 your-app.com
# Verify HaveIBeenPwned API connectivity
curl -s https://api.pwnedpasswords.com/range/5BAA6 | head -5
| Standard/Tool | Version | Status | Key Feature |
|---|---|---|---|
| NIST SP 800-63-4 | Rev 4 | Current (Aug 2025) | Risk-based DIRM, phishing-resistant MFA at AAL2+ |
| NIST SP 800-63-3 | Rev 3 | Withdrawn | AAL1/2/3 framework, memorized secrets |
| OWASP ASVS | v4.0.3 | Current | Comprehensive auth verification standard |
| Argon2 | RFC 9106 | Current | IRTF-published, OWASP first-choice |
| bcrypt | -- | Maintained | 72-byte limit, cost factor 10+ |
| FIDO2/WebAuthn | Level 2 | W3C Rec | Phishing-resistant, passkey support |
| Passkeys | -- | GA | Apple (iOS 16+), Google (Android 9+), Windows (10+) |
| Use When | Don't Use When | Use Instead |
|---|---|---|
| Building any web app with user accounts | Machine-to-machine API calls only | API key management + mTLS |
| Need NIST/SOC2/PCI compliance for authentication | Building a static site with no user accounts | No authentication needed |
| Adding MFA to existing password-based system | Already using managed auth provider (Auth0, Clerk, Cognito) | Provider's built-in MFA/security features |
| Storing user credentials in your own database | Using OAuth/OIDC exclusively (no password storage) | OAuth2/OIDC implementation guide |
| Internal admin tools need hardening | Single-user personal project (non-public) | Basic authentication may suffice |