Introduction {#introduction}
Authentication remains the primary attack vector for cybercriminals in 2025. According to the Verizon Data Breach Investigations Report 2024, 70% of data breaches involve compromised credentials, while Microsoft's security analysis reveals that 99.9% of account compromises are preventable with multi-factor authentication. Yet the authentication landscape has fundamentally changed—passwords alone are no longer sufficient, and legacy security practices like forced password expiration and arbitrary complexity rules have been proven counterproductive.
In this evolving threat landscape, organizations face multiple authentication challenges: distributed brute force campaigns leveraging 2.8 million unique IPs daily, credential stuffing attacks exploiting password reuse across sites, and sophisticated phishing operations bypassing traditional MFA methods. The average cost of a data breach now exceeds $4.45 million (IBM Security 2024), with regulatory compliance requirements from GDPR, CCPA, HIPAA, SOC 2, and PCI-DSS mandating stronger authentication controls.
This guide walks you through the complete secure password and authentication workflow used by security engineers, full-stack developers, and DevOps teams to implement production-grade authentication systems. We'll cover the full implementation from NIST-compliant password policies through phishing-resistant multi-factor authentication, following the NIST Special Publication 800-63B Digital Identity Guidelines that have revolutionized modern authentication practices.
The NIST Password Guidelines Revolution {#the-nist-password-guidelines-revolution}
The National Institute of Standards and Technology (NIST) has fundamentally shifted from complexity-focused to usability-focused security with their 800-63B guidelines. Key principle changes include:
- Eliminate mandatory password expiration - Only require resets when compromise is detected
- Remove arbitrary complexity requirements - No forced special characters or mixed-case rules
- Support long passwords - Minimum 8 characters for standard accounts, 15+ for privileged access, support up to 64 characters
- Block compromised passwords - Real-time screening against breach databases
- Deprecate security questions - Knowledge-based authentication is easily exploited
- Encourage password managers - Strongly recommended for secure storage
- Support passphrases - Memorable phrases over complex passwords
These evidence-based guidelines recognize that length matters more than complexity, and that user-hostile policies like forced monthly password changes actually reduce security by encouraging predictable patterns like "Password123!" → "Password124!".
Authentication Workflow Overview {#authentication-workflow-overview}
This comprehensive 7-stage workflow covers everything needed for production-grade authentication:
- Password Policy Design & Validation (1-2 hours) - NIST-compliant requirements, breach detection, passphrase support
- Secure Password Hashing Implementation (2-3 hours) - Argon2id configuration, migration strategies, secure storage
- Multi-Factor Authentication (MFA) (3-4 hours) - WebAuthn/FIDO2, TOTP, backup codes
- Session & Token Management (2-3 hours) - JWT implementation, refresh token rotation, secure cookies
- OAuth 2.1 & OIDC Integration (3-4 hours) - Authorization Code + PKCE, social login providers
- Account Recovery & Password Reset (2-3 hours) - Secure tokens, rate limiting, enumeration prevention
- Brute Force Protection & Monitoring (2-3 hours) - Rate limiting, bot detection, behavioral analysis, alerting
Who This Guide Is For:
- Full-stack developers implementing authentication systems
- Security engineers conducting security audits and penetration tests
- Backend engineers migrating from legacy authentication schemes
- DevOps teams building security-first deployment pipelines
- Compliance teams preparing for SOC 2, ISO 27001, and PCI-DSS audits
Let's begin with password policy design—establishing the foundation for secure authentication.
Stage 1: Password Policy Design & Validation (1-2 hours) {#stage-1-password-policy-design-validation-1-2-hours}
Effective password policies balance security and usability. Overly restrictive policies frustrate users and paradoxically reduce security by encouraging password reuse, predictable patterns, and insecure workarounds. NIST's evidence-based approach provides clear guidance for creating policies that genuinely improve security.
Step 1.1: NIST-Compliant Password Requirements (30 minutes) {#step-11-nist-compliant-password-requirements-30-minutes}
Goal: Implement password length and validation requirements aligned with NIST SP 800-63B.
Minimum Length Configuration:
// Password validation following NIST SP 800-63B
const PASSWORD_MIN_LENGTH = 8; // Standard accounts
const PASSWORD_MIN_LENGTH_HIGH_SECURITY = 15; // Admin/privileged accounts
const PASSWORD_MAX_LENGTH = 64; // NIST recommends supporting up to 64
function validatePasswordLength(password, isHighSecurity = false) {
const minLength = isHighSecurity ? PASSWORD_MIN_LENGTH_HIGH_SECURITY : PASSWORD_MIN_LENGTH;
if (password.length < minLength) {
return {
valid: false,
error: `Password must be at least ${minLength} characters`
};
}
if (password.length > PASSWORD_MAX_LENGTH) {
return {
valid: false,
error: `Password must not exceed ${PASSWORD_MAX_LENGTH} characters`
};
}
return { valid: true };
}
Tool Integration: Password Strength Checker
Use our Password Strength Checker to evaluate password strength with:
- Visual strength meter (weak, fair, good, strong, excellent)
- Real-time entropy calculation (measure unpredictability)
- Dictionary word detection (identify common passwords)
- Pattern analysis (qwerty, 12345, abc123)
- Personalization check (username, email fragments in password)
- Character diversity scoring
- Actionable improvement recommendations
NIST-Prohibited Practices (Do NOT Implement):
// ❌ WRONG - Arbitrary composition rules (NIST discourages)
function validatePasswordComplexity(password) {
const hasUppercase = /[A-Z]/.test(password);
const hasLowercase = /[a-z]/.test(password);
const hasNumber = /[0-9]/.test(password);
const hasSpecial = /[!@#$%^&*]/.test(password);
// NIST says: Don't force this! Leads to predictable passwords
return hasUppercase && hasLowercase && hasNumber && hasSpecial;
}
// ❌ WRONG - Mandatory password expiration
const PASSWORD_EXPIRATION_DAYS = 90; // NIST: Only reset on compromise
// ❌ WRONG - Password hints
const passwordHint = "Your childhood pet's name"; // NIST: Easily exploited
// ❌ WRONG - Security questions
const securityQuestions = [
"Mother's maiden name",
"First pet's name"
]; // NIST: Deprecated (social engineering risk)
✅ CORRECT - NIST-Aligned Validation:
// ✅ Focus on length and breach detection, not arbitrary rules
async function validatePassword(password, userContext = {}) {
// 1. Length check
const lengthCheck = validatePasswordLength(password);
if (!lengthCheck.valid) return lengthCheck;
// 2. Check against breached password database
const isBreached = await checkPasswordBreach(password);
if (isBreached) {
return {
valid: false,
error: 'This password has been exposed in a data breach. Please choose a different password.'
};
}
// 3. Check for context-specific information (username, email)
if (userContext.username && password.toLowerCase().includes(userContext.username.toLowerCase())) {
return {
valid: false,
error: 'Password must not contain your username'
};
}
if (userContext.email) {
const emailLocal = userContext.email.split('@')[0];
if (password.toLowerCase().includes(emailLocal.toLowerCase())) {
return {
valid: false,
error: 'Password must not contain parts of your email address'
};
}
}
// 4. Dictionary word check (common passwords)
const isCommonPassword = await checkCommonPassword(password);
if (isCommonPassword) {
return {
valid: false,
error: 'This password is too common. Please choose a more unique password.'
};
}
return { valid: true };
}
Step 1.2: Compromised Password Detection Integration (30-60 minutes) {#step-12-compromised-password-detection-integration-30-60-minutes}
Why This Matters: 86% of breaches use stolen credentials (Verizon DBIR). Preventing reuse of passwords exposed in data breaches is one of the most effective security controls available.
Have I Been Pwned API Integration:
The Have I Been Pwned service maintains a database of over 11 billion breached passwords. The k-Anonymity model allows you to check passwords without revealing them to the API.
import crypto from 'crypto';
/**
* Check if password exists in breached password database
* Uses k-Anonymity model - only first 5 chars of SHA-1 hash sent to API
*/
async function checkPasswordBreach(password) {
// 1. Hash password with SHA-1
const sha1Hash = crypto.createHash('sha1').update(password).digest('hex').toUpperCase();
// 2. k-Anonymity: Send only first 5 characters
const hashPrefix = sha1Hash.substring(0, 5);
const hashSuffix = sha1Hash.substring(5);
// 3. Query HIBP API
const response = await fetch(`https://api.pwnedpasswords.com/range/${hashPrefix}`, {
headers: {
'Add-Padding': 'true' // Prevent response size analysis
}
});
if (!response.ok) {
// Fail open - don't block legitimate users if API unavailable
console.error('Breach API unavailable, allowing password');
return false;
}
const hashList = await response.text();
// 4. Check if suffix appears in response
const hashes = hashList.split('\n');
for (const line of hashes) {
const [suffix, count] = line.split(':');
if (suffix === hashSuffix) {
console.warn(`Password found in ${count} breaches`);
return true; // Password is breached
}
}
return false; // Password not found in breaches
}
Privacy-Preserving Design:
- k-Anonymity model - Only 5-character hash prefix sent to API
- Full password never transmitted - Verification happens client-side
- Response padding - Prevents timing attacks based on response size
- No logging - User passwords never recorded
Alternative: Self-Hosted Breach Database
For organizations requiring on-premise solutions or handling classified information:
// Download Pwned Passwords hash file (38GB+ as of 2025)
// https://haveibeenpwned.com/Passwords
import fs from 'fs';
import readline from 'readline';
async function checkPasswordBreachLocal(password) {
const sha1Hash = crypto.createHash('sha1').update(password).digest('hex').toUpperCase();
const hashPrefix = sha1Hash.substring(0, 5);
// Binary search pre-sorted hash file
const fileStream = fs.createReadStream('pwned-passwords-sha1-ordered.txt');
const rl = readline.createInterface({ input: fileStream });
for await (const line of rl) {
if (line.startsWith(hashPrefix)) {
if (line.startsWith(sha1Hash)) {
return true; // Breached
}
} else if (line > sha1Hash) {
break; // Hash not found
}
}
return false;
}
Step 1.3: Passphrase Support & Generation (20-30 minutes) {#step-13-passphrase-support-generation-20-30-minutes}
Why Passphrases: NIST recommends passphrases over complex passwords. The famous xkcd comic illustrates this perfectly: "correct-horse-battery-staple" (44 bits of entropy) is significantly stronger and more memorable than "Tr0ub4dor&3" (28 bits of entropy).
Tool Integration: Secure Password Generator
Use our Secure Password Generator to create:
- Cryptographically random passwords (8-128 characters)
- Customizable character sets (uppercase, lowercase, numbers, symbols)
- Passphrase generation (4-8 random words from Diceware dictionary)
- Diceware word lists for maximum entropy
- Pronunciation hints for memorability
- One-click copy to clipboard with auto-clear
- Bulk generation with CSV/JSON export for team password managers
Passphrase Generation Implementation:
// Diceware word list (7776 words = 6 dice rolls = 12.9 bits entropy per word)
const dicewareWords = [
'ability', 'able', 'about', 'above', 'accept', 'account', 'achieve',
// ... 7770 more words
];
function generatePassphrase(wordCount = 6, separator = '-') {
const words = [];
for (let i = 0; i < wordCount; i++) {
// Cryptographically secure random word selection
const randomIndex = crypto.randomInt(0, dicewareWords.length);
words.push(dicewareWords[randomIndex]);
}
const passphrase = words.join(separator);
// Calculate entropy
const entropyBits = Math.log2(Math.pow(dicewareWords.length, wordCount));
return {
passphrase,
entropy: entropyBits,
wordCount,
strength: entropyBits > 77 ? 'excellent' : entropyBits > 64 ? 'strong' : 'good'
};
}
// Example output:
// {
// passphrase: 'correct-horse-battery-staple-mountain-ocean',
// entropy: 77.5, // 6 words × 12.9 bits
// wordCount: 6,
// strength: 'excellent'
// }
Passphrase Strength Comparison:
- 4 words: 51.6 bits entropy (good for low-security applications)
- 6 words: 77.5 bits entropy (strong for most use cases)
- 8 words: 103.2 bits entropy (excellent for high-security environments)
Step 1.4: Password Strength Meter Implementation (20-30 minutes) {#step-14-password-strength-meter-implementation-20-30-minutes}
Goal: Provide real-time feedback to users as they create passwords, guiding them toward stronger choices.
Password Strength Calculation:
function calculatePasswordStrength(password) {
let score = 0;
const feedback = [];
// 1. Length scoring (most important factor)
if (password.length >= 8) score += 10;
if (password.length >= 12) score += 10;
if (password.length >= 16) score += 10;
if (password.length >= 20) score += 10;
// 2. Character diversity
const hasLowercase = /[a-z]/.test(password);
const hasUppercase = /[A-Z]/.test(password);
const hasNumbers = /[0-9]/.test(password);
const hasSpecial = /[^a-zA-Z0-9]/.test(password);
const charTypesCount = [hasLowercase, hasUppercase, hasNumbers, hasSpecial]
.filter(Boolean).length;
score += charTypesCount * 5;
// 3. Entropy calculation
let charsetSize = 0;
if (hasLowercase) charsetSize += 26;
if (hasUppercase) charsetSize += 26;
if (hasNumbers) charsetSize += 10;
if (hasSpecial) charsetSize += 32;
const entropy = Math.log2(Math.pow(charsetSize, password.length));
score += Math.min(entropy / 2, 30); // Cap at 30 points from entropy
// 4. Pattern detection (reduce score for patterns)
const commonPatterns = [
/^(.)\1+$/, // All same character (aaaaaa)
/^(.)(.)\1\2\1\2/, // Repeating pairs (ababab)
/^[0-9]+$/, // All numbers
/qwerty|asdf|zxcv/i, // Keyboard patterns
/password|admin|user/i, // Common words
/123|abc|111|000/i // Sequential patterns
];
for (const pattern of commonPatterns) {
if (pattern.test(password)) {
score -= 20;
feedback.push('Avoid common patterns and keyboard sequences');
}
}
// 5. Determine strength level
let strength;
if (score >= 70) strength = 'excellent';
else if (score >= 50) strength = 'strong';
else if (score >= 30) strength = 'good';
else if (score >= 15) strength = 'fair';
else strength = 'weak';
// 6. Generate feedback
if (password.length < 12) feedback.push('Use at least 12 characters for better security');
if (charTypesCount < 3) feedback.push('Include a mix of letters, numbers, and symbols');
if (entropy < 40) feedback.push('Consider using a passphrase (e.g., "correct-horse-battery-staple")');
return {
score: Math.max(0, Math.min(100, score)),
strength,
entropy: Math.round(entropy),
feedback,
estimatedCrackTime: calculateCrackTime(entropy)
};
}
function calculateCrackTime(entropy) {
const guessesPerSecond = 1e9; // 1 billion guesses/second (GPU)
const totalCombinations = Math.pow(2, entropy);
const secondsToCrack = totalCombinations / guessesPerSecond / 2; // Average case
if (secondsToCrack < 60) return 'Instant';
if (secondsToCrack < 3600) return `${Math.round(secondsToCrack / 60)} minutes`;
if (secondsToCrack < 86400) return `${Math.round(secondsToCrack / 3600)} hours`;
if (secondsToCrack < 31536000) return `${Math.round(secondsToCrack / 86400)} days`;
if (secondsToCrack < 31536000 * 100) return `${Math.round(secondsToCrack / 31536000)} years`;
return 'Centuries';
}
Visual Feedback Component (React Example):
function PasswordStrengthMeter({ password }: { password: string }) {
const strength = calculatePasswordStrength(password);
const colors = {
weak: 'bg-red-500',
fair: 'bg-orange-500',
good: 'bg-yellow-500',
strong: 'bg-green-500',
excellent: 'bg-green-600'
};
return (
<div className="space-y-2">
{/* Progress bar */}
<div className="h-2 w-full bg-gray-200 rounded-full overflow-hidden">
<div
className={`h-full transition-all ${colors[strength.strength]}`}
style={{ width: `${strength.score}%` }}
/>
</div>
{/* Strength label */}
<div className="flex justify-between items-center">
<span className="text-sm font-medium">
Strength: <span className="capitalize">{strength.strength}</span>
</span>
<span className="text-xs text-gray-600">
{strength.entropy} bits entropy
</span>
</div>
{/* Crack time estimate */}
<p className="text-xs text-gray-600">
Estimated crack time: <strong>{strength.estimatedCrackTime}</strong>
</p>
{/* Feedback messages */}
{strength.feedback.length > 0 && (
<ul className="text-xs text-gray-700 space-y-1">
{strength.feedback.map((msg, i) => (
<li key={i} className="flex items-start gap-2">
<span className="text-blue-500">ℹ</span>
<span>{msg}</span>
</li>
))}
</ul>
)}
</div>
);
}
Stage 1 Deliverables:
After completing this stage, you should have:
- NIST-compliant password validation functions
- Compromised password detection integration (HIBP API or self-hosted)
- Passphrase generation capabilities
- Real-time password strength meter with user feedback
- Documented password policy aligned with NIST SP 800-63B
Time Investment: 1-2 hours Next Step: Proceed to Stage 2 to implement secure password hashing with Argon2id.
Stage 2: Secure Password Hashing Implementation (2-3 hours) {#stage-2-secure-password-hashing-implementation-2-3-hours}
Password hashing transforms passwords into irreversible cryptographic hashes, ensuring that even if your database is compromised, attackers cannot retrieve plaintext passwords. The choice of hashing algorithm directly impacts your application's resistance to brute force attacks.
Modern Password Hashing Algorithms Comparison {#modern-password-hashing-algorithms-comparison}
2025 Algorithm Ranking:
| Algorithm | Security Level | Memory Hardness | Auth Time | Recommendation |
|---|---|---|---|---|
| Argon2id | Excellent | 128MB (configurable) | 220-280ms | First choice for new systems |
| bcrypt | Good | Fixed 4KB | 250-350ms | Solid fallback, legacy support |
| scrypt | Good | 128MB (configurable) | 180-300ms | Use when Argon2 unavailable |
| PBKDF2 | Fair | None (weakness) | 200-280ms | Compliance only (FIPS-140) |
Why Argon2id Wins:
- Winner of Password Hashing Competition (2015)
- Memory-hard algorithm (resistant to GPU and ASIC attacks)
- Configurable parameters (memory, time, parallelism)
- Hybrid mode combines data-dependent and data-independent approaches
- Protection against side-channel attacks
Step 2.1: Argon2id Configuration & Implementation (45-90 minutes) {#step-21-argon2id-configuration-implementation-45-90-minutes}
Goal: Implement Argon2id password hashing with production-ready configuration.
Installation:
# Node.js
npm install argon2
# Python
pip install argon2-cffi
# Go
go get golang.org/x/crypto/argon2
# PHP (built-in as of PHP 7.2)
# Use password_hash($password, PASSWORD_ARGON2ID)
Production Configuration (2025 Recommendations):
import argon2 from 'argon2';
const ARGON2_CONFIG = {
type: argon2.argon2id, // Hybrid mode (recommended)
memoryCost: 65536, // 64 MB (2^16 KB)
timeCost: 3, // 3 iterations
parallelism: 2, // 2 threads
hashLength: 32, // 256-bit hash output
saltLength: 16 // 128-bit salt
};
/**
* Hash password using Argon2id
*/
async function hashPassword(password) {
try {
const hash = await argon2.hash(password, ARGON2_CONFIG);
return hash;
// Example output:
// $argon2id$v=19$m=65536,t=3,p=2$randomsalt$hashedpassword
} catch (err) {
console.error('Password hashing failed:', err);
throw new Error('Failed to hash password');
}
}
/**
* Verify password against stored hash
*/
async function verifyPassword(password, hash) {
try {
return await argon2.verify(hash, password);
} catch (err) {
console.error('Password verification failed:', err);
return false;
}
}
Parameter Tuning Guide:
Benchmark Argon2 on your production hardware to determine optimal parameters. Target authentication time: 250-500ms.
/**
* Benchmark Argon2 on your production hardware
*/
async function benchmarkArgon2() {
const testPassword = 'TestPassword123!';
const configs = [
{ memoryCost: 32768, timeCost: 2, parallelism: 1 }, // Low
{ memoryCost: 65536, timeCost: 3, parallelism: 2 }, // Medium (recommended)
{ memoryCost: 131072, timeCost: 4, parallelism: 4 }, // High
];
for (const config of configs) {
const start = Date.now();
await argon2.hash(testPassword, { ...config, type: argon2.argon2id });
const duration = Date.now() - start;
console.log(`Memory: ${config.memoryCost}KB, Time: ${config.timeCost}, Parallelism: ${config.parallelism}`);
console.log(`Authentication time: ${duration}ms\n`);
}
}
// Run on production hardware to determine optimal parameters
// benchmarkArgon2();
Tool Integration: Hash Generator
Use our Hash Generator to:
- Generate Argon2, bcrypt, PBKDF2, and scrypt hashes
- Verify passwords against stored hashes
- Configure parameters (memory cost, time cost, parallelism)
- Benchmark performance on your hardware
- Compare hash outputs across algorithms
- Generate migration hashes (dual hashing during algorithm migration)
Step 2.2: Algorithm Migration Strategy (30-60 minutes) {#step-22-algorithm-migration-strategy-30-60-minutes}
Scenario: Migrating from MD5/SHA-1/bcrypt to Argon2id without forcing password resets.
Approach: Lazy Migration (Transparent Upgrade)
This strategy automatically upgrades users to Argon2id when they next log in, avoiding the user friction of forced password resets.
/**
* Unified password verification with automatic migration
*/
async function verifyPasswordWithMigration(password, storedHash, userId) {
let isValid = false;
let needsMigration = false;
// 1. Detect hash algorithm
if (storedHash.startsWith('$argon2id$')) {
// Already using Argon2id
isValid = await argon2.verify(storedHash, password);
} else if (storedHash.startsWith('$2b$') || storedHash.startsWith('$2a$')) {
// Legacy bcrypt
isValid = await bcrypt.compare(password, storedHash);
needsMigration = true;
} else if (storedHash.startsWith('pbkdf2_sha256$')) {
// Legacy PBKDF2
isValid = await verifyPasswordPBKDF2(password, storedHash);
needsMigration = true;
} else if (storedHash.length === 32 && /^[a-f0-9]+$/.test(storedHash)) {
// Legacy MD5 (INSECURE - migrate immediately)
const md5Hash = crypto.createHash('md5').update(password).digest('hex');
isValid = crypto.timingSafeEqual(Buffer.from(md5Hash), Buffer.from(storedHash));
needsMigration = true;
} else {
console.error('Unknown hash format:', storedHash.substring(0, 20));
return false;
}
// 2. If valid and needs migration, rehash with Argon2id
if (isValid && needsMigration) {
const newHash = await hashPassword(password);
await updateUserPasswordHash(userId, newHash);
console.log(`Migrated user ${userId} from legacy hash to Argon2id`);
}
return isValid;
}
/**
* Database update function
*/
async function updateUserPasswordHash(userId, newHash) {
await db.query(
'UPDATE users SET password_hash = $1, password_updated_at = NOW() WHERE id = $2',
[newHash, userId]
);
}
Migration Monitoring:
Track migration progress to understand how many users still need to log in for automatic migration:
/**
* Track migration progress
*/
async function getMigrationStats() {
const stats = await db.query(`
SELECT
CASE
WHEN password_hash LIKE '$argon2id$%' THEN 'argon2id'
WHEN password_hash LIKE '$2b$%' THEN 'bcrypt'
WHEN password_hash LIKE 'pbkdf2_%' THEN 'pbkdf2'
WHEN LENGTH(password_hash) = 32 THEN 'md5'
ELSE 'unknown'
END AS algorithm,
COUNT(*) AS count,
ROUND(COUNT(*) * 100.0 / SUM(COUNT(*)) OVER(), 2) AS percentage
FROM users
GROUP BY algorithm
ORDER BY count DESC
`);
return stats.rows;
}
// Example output:
// [
// { algorithm: 'argon2id', count: 12543, percentage: 62.15 }, // 62% migrated
// { algorithm: 'bcrypt', count: 7234, percentage: 35.84 }, // 36% pending
// { algorithm: 'md5', count: 412, percentage: 2.04 } // 2% legacy (URGENT)
// ]
Step 2.3: Secure Storage & Database Schema (20-30 minutes) {#step-23-secure-storage-database-schema-20-30-minutes}
Password Hash Storage Schema:
CREATE TABLE users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
email VARCHAR(255) UNIQUE NOT NULL,
password_hash TEXT NOT NULL, -- Store full hash with algorithm prefix
password_updated_at TIMESTAMPTZ DEFAULT NOW(),
password_history JSONB DEFAULT '[]'::jsonb, -- For password reuse prevention
account_locked BOOLEAN DEFAULT FALSE,
failed_login_attempts INT DEFAULT 0,
last_failed_login TIMESTAMPTZ,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX idx_users_email ON users(email);
CREATE INDEX idx_users_account_locked ON users(account_locked) WHERE account_locked = TRUE;
Password History (Prevent Reuse):
Implement password history tracking to prevent users from cycling through a small set of passwords:
const PASSWORD_HISTORY_COUNT = 5; // Remember last 5 passwords
/**
* Check if password was previously used
*/
async function isPasswordReused(userId, newPassword) {
const user = await db.query(
'SELECT password_hash, password_history FROM users WHERE id = $1',
[userId]
);
const currentHash = user.rows[0].password_hash;
const history = user.rows[0].password_history || [];
// Check current password
if (await verifyPasswordWithMigration(newPassword, currentHash, userId)) {
return true; // Same as current password
}
// Check password history
for (const oldHash of history) {
if (await argon2.verify(oldHash, newPassword)) {
return true; // Password was used before
}
}
return false; // Password not reused
}
/**
* Update password with history tracking
*/
async function updatePassword(userId, newPassword) {
// 1. Check for reuse
if (await isPasswordReused(userId, newPassword)) {
throw new Error('Cannot reuse previous passwords');
}
// 2. Hash new password
const newHash = await hashPassword(newPassword);
// 3. Get current hash for history
const user = await db.query('SELECT password_hash, password_history FROM users WHERE id = $1', [userId]);
const currentHash = user.rows[0].password_hash;
let history = user.rows[0].password_history || [];
// 4. Update history (keep last N passwords)
history.unshift(currentHash);
history = history.slice(0, PASSWORD_HISTORY_COUNT - 1);
// 5. Update database
await db.query(
`UPDATE users
SET password_hash = $1,
password_history = $2,
password_updated_at = NOW()
WHERE id = $3`,
[newHash, JSON.stringify(history), userId]
);
}
Stage 2 Deliverables:
After completing this stage, you should have:
- Argon2id password hashing implementation with production configuration
- Lazy migration strategy for seamless algorithm upgrades
- Password history tracking to prevent password reuse
- Secure database schema for password storage
- Migration monitoring dashboard
Time Investment: 2-3 hours Next Step: Proceed to Stage 3 to implement multi-factor authentication.
Stage 3: Multi-Factor Authentication (MFA) Implementation (3-4 hours) {#stage-3-multi-factor-authentication-mfa-implementation-3-4-hours}
Multi-factor authentication (MFA) is the single most effective security control available, preventing 99.9% of automated attacks according to Microsoft's security analysis. Modern MFA has evolved beyond SMS codes to include phishing-resistant methods like WebAuthn/FIDO2 hardware keys and platform authenticators (Face ID, Touch ID, Windows Hello).
MFA Method Security Hierarchy {#mfa-method-security-hierarchy}
Tier 1: Phishing-Resistant (Recommended for High-Risk)
- FIDO2/WebAuthn hardware keys (YubiKey, Titan Security Key)
- Platform authenticators (Face ID, Touch ID, Windows Hello)
- Passkeys (FIDO2 credentials synced via iCloud Keychain, Google Password Manager)
- PIV/CAC smart cards
Tier 2: TOTP & Push Notifications (Acceptable for Medium-Risk)
- TOTP authenticator apps (Google Authenticator, Authy, Microsoft Authenticator)
- Push notifications with number matching (reduces push fatigue attacks)
- Email-based codes (short-lived, 10-minute expiration)
Tier 3: SMS (Deprecated, Use Only as Last Resort)
- SMS one-time codes (vulnerable to SIM swapping, SS7 attacks)
- NIST no longer recommends SMS as MFA
- Acceptable only as fallback for account recovery
Step 3.1: WebAuthn/FIDO2 Registration (60-90 minutes) {#step-31-webauthn-fido2-registration-60-90-minutes}
Why WebAuthn:
- 93% login success rate vs. 63% for traditional passwords
- Phishing-resistant - Origin-bound credentials prevent credential reuse across domains
- Public key cryptography - No shared secrets stored on server
- Privacy-preserving - No cross-site tracking
- Standardized - W3C standard supported by all modern browsers
Server-Side Setup (Node.js with SimpleWebAuthn):
npm install @simplewebauthn/server @simplewebauthn/browser
import {
generateRegistrationOptions,
verifyRegistrationResponse,
generateAuthenticationOptions,
verifyAuthenticationResponse
} from '@simplewebauthn/server';
const RP_NAME = 'InventiveHQ';
const RP_ID = 'inventivehq.com'; // Domain
const ORIGIN = 'https://inventivehq.com';
/**
* Step 1: Generate registration challenge
*/
async function getWebAuthnRegistrationOptions(userId, username) {
const user = await db.query('SELECT * FROM users WHERE id = $1', [userId]);
const options = await generateRegistrationOptions({
rpName: RP_NAME,
rpID: RP_ID,
userID: userId,
userName: username,
userDisplayName: user.rows[0].email,
// Attestation (optional - verify authenticator)
attestationType: 'none', // 'direct' for enterprise
// Authenticator selection
authenticatorSelection: {
authenticatorAttachment: 'cross-platform', // External key (YubiKey)
// OR 'platform' for built-in (Touch ID, Windows Hello)
requireResidentKey: false,
residentKey: 'discouraged',
userVerification: 'preferred'
},
// Exclude already registered authenticators
excludeCredentials: await getUserAuthenticators(userId),
// Challenge timeout
timeout: 60000 // 60 seconds
});
// Store challenge for verification
await storeChallenge(userId, options.challenge);
return options;
}
/**
* Step 2: Verify registration response
*/
async function verifyWebAuthnRegistration(userId, credential) {
const challenge = await getStoredChallenge(userId);
const verification = await verifyRegistrationResponse({
response: credential,
expectedChallenge: challenge,
expectedOrigin: ORIGIN,
expectedRPID: RP_ID,
requireUserVerification: true
});
if (!verification.verified) {
throw new Error('WebAuthn registration verification failed');
}
// Store credential
const { credentialID, credentialPublicKey, counter } = verification.registrationInfo;
await db.query(
`INSERT INTO webauthn_credentials
(user_id, credential_id, public_key, counter, transports, created_at)
VALUES ($1, $2, $3, $4, $5, NOW())`,
[
userId,
Buffer.from(credentialID).toString('base64'),
Buffer.from(credentialPublicKey).toString('base64'),
counter,
JSON.stringify(credential.response.transports)
]
);
return { success: true };
}
Client-Side (Browser):
import { startRegistration, startAuthentication } from '@simplewebauthn/browser';
/**
* Register WebAuthn credential
*/
async function registerWebAuthnCredential() {
try {
// 1. Get registration options from server
const optionsResponse = await fetch('/api/auth/webauthn/register/options', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include'
});
const options = await optionsResponse.json();
// 2. Trigger browser WebAuthn API
const credential = await startRegistration(options);
// 3. Send credential to server for verification
const verifyResponse = await fetch('/api/auth/webauthn/register/verify', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ credential })
});
const result = await verifyResponse.json();
if (result.success) {
alert('Security key registered successfully!');
} else {
alert('Registration failed: ' + result.error);
}
} catch (error) {
console.error('WebAuthn registration error:', error);
alert('Failed to register security key. Please try again.');
}
}
Database Schema:
CREATE TABLE webauthn_credentials (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
credential_id TEXT UNIQUE NOT NULL, -- Base64-encoded credential ID
public_key TEXT NOT NULL, -- Base64-encoded public key
counter BIGINT NOT NULL DEFAULT 0, -- Signature counter (clone detection)
transports JSONB, -- ['usb', 'nfc', 'ble', 'internal']
friendly_name VARCHAR(100), -- User-assigned name ("YubiKey 5C", "Touch ID")
created_at TIMESTAMPTZ DEFAULT NOW(),
last_used_at TIMESTAMPTZ
);
CREATE INDEX idx_webauthn_user_id ON webauthn_credentials(user_id);
CREATE INDEX idx_webauthn_credential_id ON webauthn_credentials(credential_id);
Step 3.2: TOTP Authenticator App Setup (45-60 minutes) {#step-32-totp-authenticator-app-setup-45-60-minutes}
Why TOTP:
- Widely supported (Google Authenticator, Authy, Microsoft Authenticator)
- Works offline (time-based, no network required)
- Better than SMS (no SIM swapping vulnerability)
- Industry standard (RFC 6238)
TOTP Implementation:
npm install otpauth qrcode
import { TOTP } from 'otpauth';
import qrcode from 'qrcode';
/**
* Generate TOTP secret and QR code
*/
async function setupTOTP(userId, username) {
// 1. Generate random secret (160 bits / 32 base32 characters)
const secret = TOTP.Secret();
// 2. Create TOTP instance
const totp = new TOTP({
issuer: 'InventiveHQ',
label: username,
algorithm: 'SHA1', // Most compatible (SHA256 supported by newer apps)
digits: 6, // 6-digit codes
period: 30, // 30-second validity
secret: secret
});
// 3. Generate QR code
const otpauthURL = totp.toString(); // otpauth://totp/...
const qrCodeDataURL = await qrcode.toDataURL(otpauthURL);
// 4. Store secret (encrypted at rest)
await db.query(
`INSERT INTO totp_secrets (user_id, secret, created_at, verified)
VALUES ($1, $2, NOW(), FALSE)`,
[userId, secret.base32]
);
return {
secret: secret.base32,
qrCode: qrCodeDataURL,
manualEntry: secret.base32.match(/.{4}/g).join(' ') // Pretty format: ABCD EFGH IJKL
};
}
/**
* Verify TOTP code
*/
async function verifyTOTP(userId, code) {
const result = await db.query(
'SELECT secret FROM totp_secrets WHERE user_id = $1 AND verified = TRUE',
[userId]
);
if (result.rows.length === 0) {
return { valid: false, error: 'TOTP not configured' };
}
const secret = result.rows[0].secret;
const totp = new TOTP({
algorithm: 'SHA1',
digits: 6,
period: 30,
secret: secret
});
// Allow ±1 time window (90 seconds total) for clock drift
const delta = totp.validate({ token: code, window: 1 });
if (delta !== null) {
// Update last used timestamp
await db.query(
'UPDATE totp_secrets SET last_used_at = NOW() WHERE user_id = $1',
[userId]
);
return { valid: true };
}
return { valid: false, error: 'Invalid code' };
}
Step 3.3: Backup Codes Generation (20-30 minutes) {#step-33-backup-codes-generation-20-30-minutes}
Why Backup Codes: Users lose devices, hardware keys malfunction, and phones run out of battery. Backup codes provide a secure recovery method when primary MFA factors are unavailable.
Backup Code Implementation:
/**
* Generate backup codes
*/
async function generateBackupCodes(userId, count = 10) {
const codes = [];
for (let i = 0; i < count; i++) {
// Generate cryptographically secure 8-character code
const code = crypto.randomBytes(4).toString('hex').toUpperCase();
const formattedCode = code.match(/.{4}/g).join('-'); // Format: ABCD-1234
// Hash code for storage (like passwords)
const hash = await hashPassword(formattedCode);
codes.push({
code: formattedCode,
hash: hash
});
}
// Store hashed codes
await db.query(
`INSERT INTO backup_codes (user_id, code_hash, created_at)
VALUES ${codes.map((_, i) => `($1, $${i + 2}, NOW())`).join(', ')}`,
[userId, ...codes.map(c => c.hash)]
);
// Return plaintext codes to user (only shown once)
return codes.map(c => c.code);
}
/**
* Verify and consume backup code
*/
async function verifyBackupCode(userId, code) {
const result = await db.query(
'SELECT id, code_hash FROM backup_codes WHERE user_id = $1 AND used = FALSE',
[userId]
);
for (const row of result.rows) {
if (await verifyPassword(code, row.code_hash)) {
// Mark code as used
await db.query(
'UPDATE backup_codes SET used = TRUE, used_at = NOW() WHERE id = $1',
[row.id]
);
return { valid: true };
}
}
return { valid: false, error: 'Invalid or already used backup code' };
}
Database Schema:
CREATE TABLE backup_codes (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
code_hash TEXT NOT NULL,
used BOOLEAN DEFAULT FALSE,
used_at TIMESTAMPTZ,
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX idx_backup_codes_user_id ON backup_codes(user_id);
CREATE INDEX idx_backup_codes_unused ON backup_codes(user_id, used) WHERE used = FALSE;
Stage 3 Deliverables:
After completing this stage, you should have:
- WebAuthn/FIDO2 registration and authentication flow
- TOTP authenticator app enrollment with QR codes
- Backup code generation and verification
- Database schema for MFA credentials
- Multi-factor authentication enforcement logic
Time Investment: 3-4 hours Next Step: Proceed to Stage 4 to implement session and token management.
Stage 4: Session & Token Management (2-3 hours) {#stage-4-session-token-management-2-3-hours}
Session management determines how long users remain authenticated and how their authentication state is maintained across requests. Poor session management leads to session hijacking, token theft, and unauthorized access.
Modern Token Standards {#modern-token-standards}
JWT (JSON Web Tokens):
- Use Case: Stateless authentication for APIs, microservices, SPAs
- Advantages: Self-contained, no database lookups, scalable
- Disadvantages: Cannot revoke individual tokens (use short expiration)
Refresh Tokens:
- Use Case: Long-lived sessions without exposing long-lived JWTs
- Pattern: Short-lived access tokens (15 minutes) + long-lived refresh tokens (7-14 days)
- Security: Token rotation detects compromise
Step 4.1: JWT Access Token Implementation (45-60 minutes) {#step-41-jwt-access-token-implementation-45-60-minutes}
Goal: Implement secure JWT access tokens with appropriate expiration and claims.
JWT Configuration:
import jwt from 'jsonwebtoken';
import crypto from 'crypto';
// Generate strong JWT secret (256 bits minimum)
const JWT_SECRET = process.env.JWT_SECRET || crypto.randomBytes(32).toString('base64');
const JWT_ALGORITHM = 'HS256'; // Or RS256 for asymmetric keys
const JWT_EXPIRATION = '15m'; // 15 minutes
/**
* Generate JWT access token
*/
function generateAccessToken(user) {
const payload = {
sub: user.id, // Subject (user ID)
email: user.email,
roles: user.roles || [],
iat: Math.floor(Date.now() / 1000), // Issued at
exp: Math.floor(Date.now() / 1000) + (15 * 60) // Expires in 15 minutes
};
return jwt.sign(payload, JWT_SECRET, {
algorithm: JWT_ALGORITHM
});
}
/**
* Verify JWT access token
*/
function verifyAccessToken(token) {
try {
const decoded = jwt.verify(token, JWT_SECRET, {
algorithms: [JWT_ALGORITHM]
});
return {
valid: true,
payload: decoded
};
} catch (err) {
if (err.name === 'TokenExpiredError') {
return { valid: false, error: 'Token expired' };
} else if (err.name === 'JsonWebTokenError') {
return { valid: false, error: 'Invalid token' };
}
return { valid: false, error: 'Token verification failed' };
}
}
Tool Integration: JWT Decoder
Use our JWT Decoder to:
- Decode JWT header, payload, and signature
- Verify JWT signatures with secret or public key
- Validate expiration and not-before claims
- Inspect token issuer, audience, and subject
- Debug authentication issues in real-time
Step 4.2: Refresh Token Rotation (45-60 minutes) {#step-42-refresh-token-rotation-45-60-minutes}
Goal: Implement refresh token rotation to detect token theft and maintain long-lived sessions securely.
Refresh Token Rotation Pattern:
/**
* Generate refresh token
*/
async function generateRefreshToken(userId) {
// 1. Generate cryptographically secure token
const token = crypto.randomBytes(32).toString('base64url');
// 2. Hash token for database storage
const tokenHash = crypto.createHash('sha256').update(token).digest('hex');
// 3. Generate family ID (for rotation detection)
const familyId = crypto.randomUUID();
// 4. Store in database
await db.query(
`INSERT INTO refresh_tokens
(user_id, token_hash, family_id, expires_at, created_at)
VALUES ($1, $2, $3, NOW() + INTERVAL '7 days', NOW())`,
[userId, tokenHash, familyId]
);
return {
token: token,
familyId: familyId
};
}
/**
* Rotate refresh token
*/
async function rotateRefreshToken(oldToken) {
// 1. Hash incoming token
const tokenHash = crypto.createHash('sha256').update(oldToken).digest('hex');
// 2. Find token in database
const result = await db.query(
'SELECT * FROM refresh_tokens WHERE token_hash = $1',
[tokenHash]
);
if (result.rows.length === 0) {
throw new Error('Invalid refresh token');
}
const storedToken = result.rows[0];
// 3. Check if already used (token reuse = compromise detected)
if (storedToken.used) {
console.error(`Refresh token reuse detected for user ${storedToken.user_id}`);
// Revoke entire token family
await db.query(
'UPDATE refresh_tokens SET revoked = TRUE WHERE family_id = $1',
[storedToken.family_id]
);
throw new Error('Token reuse detected - all tokens revoked');
}
// 4. Check expiration
if (new Date(storedToken.expires_at) < new Date()) {
throw new Error('Refresh token expired');
}
// 5. Mark token as used
await db.query(
'UPDATE refresh_tokens SET used = TRUE, used_at = NOW() WHERE id = $1',
[storedToken.id]
);
// 6. Generate new tokens
const user = await getUser(storedToken.user_id);
const newAccessToken = generateAccessToken(user);
const newRefreshToken = await generateRefreshToken(storedToken.user_id);
return {
accessToken: newAccessToken,
refreshToken: newRefreshToken.token,
expiresIn: 15 * 60 // 15 minutes
};
}
Database Schema:
CREATE TABLE refresh_tokens (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
token_hash VARCHAR(64) UNIQUE NOT NULL, -- SHA-256 hash
family_id UUID NOT NULL, -- Token family (for rotation detection)
used BOOLEAN DEFAULT FALSE,
used_at TIMESTAMPTZ,
revoked BOOLEAN DEFAULT FALSE,
created_at TIMESTAMPTZ DEFAULT NOW(),
expires_at TIMESTAMPTZ NOT NULL,
user_agent TEXT,
ip_address INET
);
CREATE INDEX idx_refresh_tokens_user_id ON refresh_tokens(user_id);
CREATE INDEX idx_refresh_tokens_token_hash ON refresh_tokens(token_hash);
CREATE INDEX idx_refresh_tokens_family_id ON refresh_tokens(family_id);
CREATE INDEX idx_refresh_tokens_expires_at ON refresh_tokens(expires_at) WHERE used = FALSE AND revoked = FALSE;
Step 4.3: Secure Cookie Storage (30-45 minutes) {#step-43-secure-cookie-storage-30-45-minutes}
Goal: Store authentication tokens in secure, HttpOnly cookies to prevent XSS attacks.
Cookie Configuration:
const COOKIE_CONFIG = {
httpOnly: true, // Prevent XSS access
secure: true, // HTTPS only
sameSite: 'strict', // CSRF protection
maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days
path: '/',
domain: '.inventivehq.com' // Allow subdomains
};
/**
* Set authentication cookies
*/
function setAuthCookies(res, accessToken, refreshToken) {
// Access token (short-lived)
res.cookie('access_token', accessToken, {
...COOKIE_CONFIG,
maxAge: 15 * 60 * 1000 // 15 minutes
});
// Refresh token (long-lived)
res.cookie('refresh_token', refreshToken, {
...COOKIE_CONFIG,
maxAge: 7 * 24 * 60 * 60 * 1000 // 7 days
});
}
/**
* Clear authentication cookies (logout)
*/
function clearAuthCookies(res) {
res.clearCookie('access_token', COOKIE_CONFIG);
res.clearCookie('refresh_token', COOKIE_CONFIG);
}
Token Refresh Endpoint:
app.post('/api/auth/refresh', async (req, res) => {
const refreshToken = req.cookies.refresh_token;
if (!refreshToken) {
return res.status(401).json({ error: 'No refresh token provided' });
}
try {
const tokens = await rotateRefreshToken(refreshToken);
setAuthCookies(res, tokens.accessToken, tokens.refreshToken);
return res.json({
success: true,
expiresIn: tokens.expiresIn
});
} catch (error) {
console.error('Token refresh failed:', error);
clearAuthCookies(res);
return res.status(401).json({ error: error.message });
}
});
Stage 4 Deliverables:
After completing this stage, you should have:
- JWT access token generation and verification
- Refresh token rotation with compromise detection
- Secure cookie storage configuration
- Token refresh API endpoint
- Database schema for refresh tokens
Time Investment: 2-3 hours Next Step: Proceed to Stage 5 for OAuth 2.1 and OIDC integration, or Stage 6 for account recovery implementation.
Stage 5: Brute Force Protection & Monitoring (2-3 hours) {#stage-5-brute-force-protection-monitoring-2-3-hours}
Brute force attacks and credential stuffing campaigns account for 70% of password-related breaches (Verizon DBIR 2024). Modern attacks are distributed across thousands of IPs, making simple IP-based rate limiting ineffective. A layered defense strategy combining multiple countermeasures is essential.
Attack Landscape {#attack-landscape}
Common Attack Patterns:
- Credential stuffing: Testing leaked username/password pairs from other breaches
- Password spraying: Trying common passwords against many accounts (1 attempt per account)
- Distributed attacks: Spreading attempts across 2.8 million unique IPs (2025 campaign data)
- Slow and low attacks: Staying below rate limit thresholds
- Account enumeration: Testing if email addresses exist in system
Limitations of Rate Limiting Alone:
- Distributed attacks bypass IP-based limits
- Password spray attacks stay below per-account thresholds
- Proxy chains and VPNs rotate IPs continuously
- Legitimate users can be blocked by overly restrictive limits
Step 5.1: Adaptive Rate Limiting (45-60 minutes) {#step-51-adaptive-rate-limiting-45-60-minutes}
Goal: Implement multi-layered rate limiting at account, IP, and global levels.
Rate Limiting Implementation (Express + Redis):
import rateLimit from 'express-rate-limit';
import RedisStore from 'rate-limit-redis';
import Redis from 'redis';
const redisClient = Redis.createClient({
host: process.env.REDIS_HOST,
port: process.env.REDIS_PORT
});
/**
* Per-IP rate limiter
*/
const ipRateLimiter = rateLimit({
store: new RedisStore({
client: redisClient,
prefix: 'rl:ip:'
}),
windowMs: 60 * 60 * 1000, // 1 hour window
max: 20, // 20 attempts per hour per IP
standardHeaders: true,
legacyHeaders: false,
message: 'Too many login attempts from this IP. Please try again later.',
skipSuccessfulRequests: true // Only count failed attempts
});
/**
* Per-account rate limiter
*/
async function checkAccountRateLimit(email) {
const key = `rl:account:${email}`;
const windowMs = 15 * 60 * 1000; // 15 minutes
const maxAttempts = 5;
const attempts = await redisClient.incr(key);
if (attempts === 1) {
await redisClient.expire(key, Math.floor(windowMs / 1000));
}
if (attempts > maxAttempts) {
const ttl = await redisClient.ttl(key);
throw new Error(`Account temporarily locked. Try again in ${Math.ceil(ttl / 60)} minutes.`);
}
return { remaining: maxAttempts - attempts };
}
/**
* Global spike detector
*/
async function checkGlobalFailedLogins() {
const key = 'rl:global:failed_logins';
const windowMs = 5 * 60 * 1000; // 5 minute window
const threshold = 100; // 100 failed logins across all accounts
const count = await redisClient.incr(key);
if (count === 1) {
await redisClient.expire(key, Math.floor(windowMs / 1000));
}
if (count > threshold) {
console.error('Global brute force attack detected - failed login spike');
// Trigger alerting, enable CAPTCHA site-wide, etc.
return { alert: true, count: count };
}
return { alert: false, count: count };
}
Login Endpoint with Rate Limiting:
app.post('/api/auth/login', ipRateLimiter, async (req, res) => {
const { email, password } = req.body;
try {
// 1. Check account-specific rate limit
await checkAccountRateLimit(email);
// 2. Check global attack detection
const globalCheck = await checkGlobalFailedLogins();
// 3. Retrieve user
const user = await db.query('SELECT * FROM users WHERE email = $1', [email]);
if (user.rows.length === 0) {
// Don't reveal if account exists (prevent enumeration)
await sleep(Math.random() * 200 + 100); // Timing attack prevention
return res.status(401).json({ error: 'Invalid credentials' });
}
// 4. Verify password
const isValid = await verifyPasswordWithMigration(password, user.rows[0].password_hash, user.rows[0].id);
if (!isValid) {
// Record failed attempt
await recordFailedLogin(user.rows[0].id, req.ip);
return res.status(401).json({ error: 'Invalid credentials' });
}
// 5. Check if MFA required
if (user.rows[0].mfa_enabled) {
// Return MFA challenge (implementation in Stage 3)
return res.json({ mfaRequired: true, userId: user.rows[0].id });
}
// 6. Successful login
await recordSuccessfulLogin(user.rows[0].id, req.ip);
const accessToken = generateAccessToken(user.rows[0]);
const refreshToken = await generateRefreshToken(user.rows[0].id);
setAuthCookies(res, accessToken, refreshToken.token);
return res.json({ success: true });
} catch (error) {
console.error('Login error:', error);
return res.status(429).json({ error: error.message });
}
});
Step 5.2: Bot Detection & CAPTCHA Integration (30-45 minutes) {#step-52-bot-detection-captcha-integration-30-45-minutes}
Goal: Distinguish between legitimate users and automated bots.
CAPTCHA Trigger Logic:
/**
* Determine if CAPTCHA challenge required
*/
async function requiresCAPTCHA(email, ip) {
// 1. Check recent failed login count
const failedAttempts = await db.query(
`SELECT COUNT(*) FROM login_attempts
WHERE email = $1 AND success = FALSE AND created_at > NOW() - INTERVAL '1 hour'`,
[email]
);
if (parseInt(failedAttempts.rows[0].count) >= 3) {
return true; // Require CAPTCHA after 3 failed attempts
}
// 2. Check IP reputation
const ipReputation = await checkIPReputation(ip);
if (ipReputation.score < 50) {
return true; // Suspicious IP
}
// 3. Check for bot-like behavior
const requestPattern = await analyzeRequestPattern(ip);
if (requestPattern.isBot) {
return true; // Bot detected
}
return false;
}
Tool Integration: IP Risk Checker
Use our IP Risk Checker to:
- Query IP geolocation and ISP information
- Check IP blocklist status (Spamhaus, SURBL, Project Honey Pot)
- Detect VPN/proxy usage
- Review historical abuse reports
- Calculate fraud risk scores
Step 5.3: Security Monitoring & Alerting (45-60 minutes) {#step-53-security-monitoring-alerting-45-60-minutes}
Goal: Detect and respond to authentication attacks in real-time.
Login Attempt Tracking:
CREATE TABLE login_attempts (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID REFERENCES users(id) ON DELETE SET NULL,
email VARCHAR(255) NOT NULL,
ip_address INET NOT NULL,
user_agent TEXT,
success BOOLEAN NOT NULL,
failure_reason VARCHAR(100),
mfa_method VARCHAR(50),
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX idx_login_attempts_email ON login_attempts(email);
CREATE INDEX idx_login_attempts_ip ON login_attempts(ip_address);
CREATE INDEX idx_login_attempts_created_at ON login_attempts(created_at);
CREATE INDEX idx_login_attempts_failed ON login_attempts(email, success) WHERE success = FALSE;
Alert Triggers:
/**
* Monitor for suspicious authentication patterns
*/
async function monitorAuthenticationPatterns() {
// 1. Detect account enumeration
const enumerationDetection = await db.query(`
SELECT ip_address, COUNT(DISTINCT email) as unique_emails
FROM login_attempts
WHERE created_at > NOW() - INTERVAL '5 minutes'
AND success = FALSE
GROUP BY ip_address
HAVING COUNT(DISTINCT email) > 20
`);
if (enumerationDetection.rows.length > 0) {
alertSecurityTeam('Account enumeration detected', enumerationDetection.rows);
}
// 2. Detect credential stuffing
const credentialStuffing = await db.query(`
SELECT COUNT(*) as failed_logins
FROM login_attempts
WHERE created_at > NOW() - INTERVAL '5 minutes'
AND success = FALSE
`);
if (parseInt(credentialStuffing.rows[0].failed_logins) > 100) {
alertSecurityTeam('Credential stuffing attack detected', {
failedLogins: credentialStuffing.rows[0].failed_logins
});
}
// 3. Detect successful login from new location
const newLocationLogins = await db.query(`
SELECT u.id, u.email, la.ip_address, la.created_at
FROM login_attempts la
JOIN users u ON la.user_id = u.id
WHERE la.success = TRUE
AND la.created_at > NOW() - INTERVAL '1 hour'
AND NOT EXISTS (
SELECT 1 FROM login_attempts la2
WHERE la2.user_id = u.id
AND la2.ip_address = la.ip_address
AND la2.created_at < la.created_at - INTERVAL '7 days'
)
`);
for (const login of newLocationLogins.rows) {
alertUser(login.email, 'New login location detected', {
ip: login.ip_address,
timestamp: login.created_at
});
}
}
// Run every 5 minutes
setInterval(monitorAuthenticationPatterns, 5 * 60 * 1000);
Stage 5 Deliverables:
After completing this stage, you should have:
- Multi-layered rate limiting (per-IP, per-account, global)
- Bot detection and CAPTCHA integration
- Login attempt tracking and analytics
- Real-time security monitoring and alerting
- Automated response to authentication attacks
Time Investment: 2-3 hours
Conclusion {#conclusion}
Implementing secure authentication is not a one-time project—it's an ongoing commitment to protecting your users and your organization. This workflow has guided you through seven comprehensive stages covering password policies aligned with NIST SP 800-63B, modern password hashing with Argon2id, phishing-resistant multi-factor authentication, secure session management, and layered brute force protection.
Key Takeaways {#key-takeaways}
- Length over complexity - NIST guidelines prioritize password length over arbitrary composition rules
- Breach detection is essential - Block passwords found in data breaches using HIBP API
- MFA prevents 99.9% of attacks - Implement phishing-resistant WebAuthn/FIDO2 for high-security applications
- Argon2id is the gold standard - Memory-hard hashing algorithms resist GPU and ASIC attacks
- Layered defense - Combine rate limiting, bot detection, and behavioral analysis
- Monitor and alert - Real-time detection enables rapid response to attacks
- User experience matters - Security controls should enhance, not hinder, usability
Next Steps {#next-steps}
Immediate Actions:
- Audit your current password policy against NIST SP 800-63B guidelines
- Implement compromised password detection (Stage 1)
- Migrate to Argon2id password hashing (Stage 2)
- Enable multi-factor authentication for privileged accounts (Stage 3)
- Implement rate limiting and monitoring (Stage 5)
Long-Term Improvements:
- Deploy WebAuthn/FIDO2 for phishing-resistant authentication
- Implement refresh token rotation for enhanced session security
- Build security dashboards for real-time authentication monitoring
- Conduct quarterly security audits and penetration tests
- Train users on password managers and passphrase best practices
Related Tools & Resources {#related-tools-resources}
InventiveHQ Tools:
- Password Strength Checker - Evaluate password security
- Secure Password Generator - Generate strong passwords and passphrases
- Hash Generator - Test password hashing algorithms
- JWT Decoder - Debug JSON Web Tokens
- OAuth/OIDC Debugger - Test OAuth flows
- IP Risk Checker - Assess IP reputation
Standards & Documentation:
- NIST SP 800-63B: Digital Identity Guidelines (Authentication and Lifecycle Management)
- OWASP Authentication Cheat Sheet
- FIDO Alliance WebAuthn Specifications
- RFC 6238: TOTP Time-Based One-Time Password Algorithm
Further Reading:
- API Security Testing Workflow - Comprehensive API authentication testing
- Web Application Security Audit Workflow - Full security assessment methodology
- Email Security Hardening Workflow - SPF, DKIM, DMARC implementation
Authentication security is the foundation upon which all other security controls rest. By following this workflow and continuously improving your authentication systems, you'll significantly reduce your organization's attack surface and protect your users from the most common attack vectors.
Questions or need implementation assistance? Our security engineering team at InventiveHQ specializes in authentication architecture, security audits, and compliance implementation. Contact us for a free consultation.