Authentication Security Checklist

Type: Software Reference Confidence: 0.94 Sources: 7 Verified: 2026-02-27 Freshness: 2026-02-27

TL;DR

Constraints

Quick Reference

#VulnerabilityRiskVulnerable CodeSecure Code
1Plaintext/weak password storageCriticalINSERT INTO users(pass) VALUES('${password}')argon2.hash(password, {type: argon2id, memoryCost: 65536})
2No MFA on privileged accountsCriticalPassword-only admin loginTOTP/FIDO2 required for all admin accounts
3Username enumeration via loginHigh"Invalid password" / "User not found""Login failed; invalid user ID or password"
4Username enumeration via registrationHigh"This email is already registered""A link to activate your account has been emailed"
5No rate limiting on loginHighUnlimited login attempts5 attempts per account per 15min, then exponential backoff
6Session fixationHighReusing session ID after loginRegenerate session ID on authentication state change
7Insecure session cookiesHighSet-Cookie: sid=abc123Set-Cookie: sid=abc123; HttpOnly; Secure; SameSite=Lax
8Password length too shortMediumminLength: 6minLength: 8 (with MFA) or minLength: 15 (without MFA)
9Bcrypt with raw long passwordsMediumbcrypt.hash(longPassword) (truncates at 72 bytes)bcrypt(base64(hmac-sha384(password, pepper)))
10No session timeoutMediumSessions never expireIdle timeout: 15-30min; absolute timeout: 8-24hr
11Missing breached password checkMediumAccept any password meeting length ruleCheck against HaveIBeenPwned Pwned Passwords API
12Credential recovery reveals infoMedium"No account with that email""If that email is in our database, we will send a reset link"

Decision Tree

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

Step-by-Step Guide

1. Configure password hashing with Argon2id

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$

2. Implement generic error messages

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.

3. Add rate limiting and brute force protection

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.

4. Enforce MFA with TOTP and Passkeys

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.

5. Configure secure session cookies

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.

6. Check passwords against breached databases

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.

Code Examples

Python/Django: Argon2id Password Hashing

# 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
    )

Node.js/Express: Complete Auth Middleware

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' });
});

Java/Spring Boot: Argon2 Configuration

// 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);
}

Anti-Patterns

Wrong: MD5/SHA-256 for password storage

# 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

Correct: Argon2id with proper parameters

# 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

Wrong: Revealing username existence

// BAD -- different messages leak whether account exists
if (!user) return res.json({ error: 'User not found' });
if (!validPassword) return res.json({ error: 'Wrong password' });

Correct: Generic error for all auth failures

// GOOD -- identical response regardless of failure reason
if (!user || !validPassword) {
  return res.status(401).json({
    error: 'Login failed; invalid user ID or password'
  });
}

Wrong: Session cookie without security flags

// BAD -- cookie accessible to XSS, sent over HTTP, vulnerable to CSRF
res.cookie('session', token);
// Equivalent to: Set-Cookie: session=abc123 (no flags)

Correct: Fully secured session cookie

// 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: '/'
});

Wrong: Rate limiting by IP only

// BAD -- credential stuffing uses thousands of IPs
const limiter = rateLimit({ windowMs: 60000, max: 100 });
// An attacker with a botnet bypasses this trivially

Correct: Per-account rate limiting

// 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');

Common Pitfalls

Diagnostic Commands

# 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

Version History & Compatibility

Standard/ToolVersionStatusKey Feature
NIST SP 800-63-4Rev 4Current (Aug 2025)Risk-based DIRM, phishing-resistant MFA at AAL2+
NIST SP 800-63-3Rev 3WithdrawnAAL1/2/3 framework, memorized secrets
OWASP ASVSv4.0.3CurrentComprehensive auth verification standard
Argon2RFC 9106CurrentIRTF-published, OWASP first-choice
bcrypt--Maintained72-byte limit, cost factor 10+
FIDO2/WebAuthnLevel 2W3C RecPhishing-resistant, passkey support
Passkeys--GAApple (iOS 16+), Google (Android 9+), Windows (10+)

When to Use / When Not to Use

Use WhenDon't Use WhenUse Instead
Building any web app with user accountsMachine-to-machine API calls onlyAPI key management + mTLS
Need NIST/SOC2/PCI compliance for authenticationBuilding a static site with no user accountsNo authentication needed
Adding MFA to existing password-based systemAlready using managed auth provider (Auth0, Clerk, Cognito)Provider's built-in MFA/security features
Storing user credentials in your own databaseUsing OAuth/OIDC exclusively (no password storage)OAuth2/OIDC implementation guide
Internal admin tools need hardeningSingle-user personal project (non-public)Basic authentication may suffice

Important Caveats

Related Units