Skip to main content
⚡ Calmops

Password Security: Bcrypt, Hashing Best Practices & Secure Reset Flows

Table of Contents

Password Security: Bcrypt, Hashing Best Practices & Secure Reset Flows

Passwords remain one of the most common authentication mechanisms in web applications. Yet they’re also one of the most frequently mishandled. A single security oversight—using weak hashing, improper salting, or a flawed reset flow—can compromise every user account in your system.

This guide covers the technical foundations and practical implementation of modern password security. We’ll explore bcrypt as the gold-standard hashing algorithm, delve into the mathematics and practices that make password storage secure, and detail a bulletproof password reset flow that protects against common attacks.


Table of Contents

  1. The Problem: Why Passwords Are Hard
  2. Password Hashing Fundamentals
  3. Bcrypt: The Gold Standard
  4. Password Hashing Best Practices
  5. Implementing Secure Password Storage
  6. Secure Password Reset Flows
  7. Common Mistakes & How to Avoid Them
  8. Additional Security Considerations
  9. Conclusion

The Problem: Why Passwords Are Hard

Why We Can’t Just Store Passwords

If you’ve been doing web development for a while, you know the cardinal rule: never store passwords in plaintext. But why not?

Consider this scenario: Your database is compromised. An attacker downloads all user records. If passwords are stored in plaintext, the attacker has immediate access to every user account—not just on your system, but potentially on other sites where users reused their password (a common mistake).

Now imagine passwords are “hashed”—converted into an irreversible fixed-length string using a mathematical function:

Password: "MySecurePassword123"
Hash: "2b$12$j9i0Xk8eFt6VzN1mQpL2zOz4HvJ4K9m5pQx8Wz9tY1aB2cD3eF4g5h6"

Even with the hash, the attacker cannot reverse-engineer the original password (assuming the hash is strong). This shifts the security problem: instead of needing to prevent database breaches (nearly impossible), you’re focusing on making it computationally infeasible for attackers to crack the hash.

The Difference Between Hashing and Encryption

Developers often confuse these two:

Aspect Hashing Encryption
Reversible? No (one-way) Yes (two-way)
Use Case Passwords, checksums Data protection
Key? No key needed Requires a key
Speed Variable (intentionally slow for passwords) Fast

For passwords, you want hashing, not encryption. Encryption would allow you to decrypt passwords, which defeats the purpose.


Password Hashing Fundamentals

How Password Hashing Works

When a user creates or changes their password, the server:

  1. Takes the password string (e.g., “MyPassword123”)
  2. Passes it through a hashing algorithm
  3. Stores only the resulting hash in the database
  4. Discards the original password

During login:

  1. User enters their password
  2. Server hashes the entered password
  3. Compares the hash to the stored hash
  4. If they match, access is granted

The critical insight: the password itself is never stored or transmitted after the initial entry. Only its hash is retained.

Why Not MD5 or SHA?

You might ask: “Why not use simple hash functions like MD5 or SHA-1?” Three reasons:

1. Speed is a problem for passwords

MD5 is designed for speed—it can hash billions of passwords per second. This is great for checksums, terrible for passwords. If an attacker has a database hash and wants to crack it, speed works against you.

Time to crack common password with MD5:
"password123" at 10 billion hashes/second = < 0.1 milliseconds

2. Rainbow tables

Attackers pre-compute hashes of common passwords and store them in lookup tables (“rainbow tables”). With MD5, checking if a hash appears in a rainbow table is trivial.

Rainbow Table Example:
password123 → 482c811da5d5b4bc6d497ffa98491e38
letmein    → 0cee3ef8dbed4326b8b740f385723a41
password   → 5f4dcc3b5aa765d61d8327deb882cf99

3. No salting

Early hash functions didn’t include a salt—a random string unique to each password. Without salt, identical passwords hash to identical values, revealing password reuse patterns:

User1 password hash: a1b2c3d4e5f6... (hash of "mypassword")
User2 password hash: a1b2c3d4e5f6... (same hash = same password)
Attacker: "These users have the same password!"

Modern password hashing addresses all three issues.

What Makes a Good Password Hash Function?

A password hash function should be:

  • Deterministic: Same password always produces the same hash (required for comparison)
  • Fast to verify: Computing one hash should take microseconds
  • Slow to attack: Computing millions of hashes should take minutes or hours
  • Resistant to collisions: Two different passwords shouldn’t produce the same hash
  • Salted: Include a unique random component
  • Adaptive: Allow increasing complexity as computing power increases

Bcrypt meets all these criteria.


Bcrypt: The Gold Standard

What Is Bcrypt?

Bcrypt is a password hashing algorithm designed by Niels Provos and David Mazières in 1999. It’s based on the Blowfish cipher and has become the de facto standard for password hashing in modern applications.

The name derives from the blowfish crypt algorithm. Despite being over 25 years old, it remains secure and relevant—a testament to its design.

How Bcrypt Works

Bcrypt combines three critical elements:

1. Salting

Password: "MyPassword123"
Random Salt: "$2b$12$j9i0Xk8eFt6VzN1mQpL2zO" (16 bytes, base64 encoded)

The salt is randomly generated and unique for each password. Even if two users have identical passwords, their hashes will differ because each has a different salt.

2. Work Factor (Cost Parameter)

Cost Parameter: 12 (meaning 2^12 = 4,096 iterations)

Bcrypt includes a configurable “work factor”—the number of internal iterations. This intentionally slows down the hashing process. On modern hardware:

  • Cost 10 → ~10 milliseconds per hash
  • Cost 12 → ~50 milliseconds per hash
  • Cost 14 → ~300 milliseconds per hash

Higher cost = slower for both legitimate users and attackers. The work factor is embedded in the hash, so you can increase it over time without rehashing all passwords.

3. Blowfish Cipher

The underlying algorithm applies the Blowfish cipher 64 times in a specific pattern, further protecting against brute-force attacks.

The Bcrypt Hash Format

A bcrypt hash looks like this:

$2b$12$j9i0Xk8eFt6VzN1mQpL2zOz4HvJ4K9m5pQx8Wz9tY1aB2cD3eF4g5h6
 ↑  ↑   ↑                        ↑
 |  |   |                        └─ Hash (31 characters)
 |  |   └─ Work factor (12)
 |  └─ Salt (22 characters)
 └─ Algorithm identifier ($2b$ = bcrypt with fixes)

Breaking it down:

  • $2b$ - Algorithm version (bcrypt with security fixes)
  • 12 - Work factor (cost parameter)
  • j9i0Xk8eFt6VzN1mQpL2zO - Salt (22 characters, base64 encoded)
  • z4HvJ4K9m5pQx8Wz9tY1aB2cD3eF4g5h6 - Hash (31 characters, base64 encoded)

The entire hash is 60 characters. The salt and work factor are stored with the hash, which is essential—you need them to verify passwords later.

Advantages of Bcrypt

Slow by design: Even with hardware acceleration, checking millions of hashes takes days. For legitimate users (hashing one password per login), the delay is imperceptible.

Embedded metadata: The salt and work factor are stored in the hash itself. This means you can safely store the entire hash string and still be able to verify passwords.

Work factor flexibility: As computers get faster, you can increase the work factor. Existing passwords remain verifiable with their original cost parameter.

Battle-tested: Used in production systems worldwide for over 25 years with no practical breaks discovered.

No patent restrictions: Free to use and implement.

Bcrypt Limitations

Despite its strengths, bcrypt has considerations:

Maximum password length: Bcrypt truncates passwords longer than 72 bytes. For very long passphrases, this could be a concern (though rare in practice).

// Both of these hash to the same value in bcrypt:
"MyPassword" + "A".repeat(1000)
"MyPassword" + "A".repeat(100)
// All 72+ characters are treated identically

Speed overhead: For systems processing millions of logins per second, bcrypt’s slowness could become a bottleneck (though this is rare at scale, as you’d use multiple servers).

Blowfish algorithm: Blowfish has some theoretical weaknesses with very long plaintexts (though irrelevant for passwords). For absolute paranoia, newer alternatives exist.

Bcrypt Alternatives

Scrypt: Designed specifically to resist hardware attacks like GPU/ASIC cracking. More aggressive resource requirements. Good for systems requiring maximum security, though often overkill.

Argon2: Modern password hashing algorithm winner of the Password Hashing Competition (2015). Memory-hard like scrypt but better parameters. Recommended for new systems if available in your language.

PBKDF2: Older standard, still secure but slower than bcrypt. Less recommended unless required for compatibility.

For most applications, bcrypt remains the best choice. It’s simple, proven, and available in every language.


Password Hashing Best Practices

1. Always Salt Passwords

Never hash a password without a salt.

// ❌ WRONG - No salt
const hash = sha256(password);

// ✅ RIGHT - Bcrypt handles salting automatically
const hash = await bcrypt.hash(password, 10);

Without salt, identical passwords produce identical hashes, leaking information about which users have the same password. Additionally, rainbow tables become effective.

Bcrypt generates a unique salt automatically for each password—you don’t need to manage it separately.

2. Use Appropriate Cost Factor

The cost factor is a balance between security and performance:

// Cost too low - fast to hash, fast to crack
const hash = await bcrypt.hash(password, 4);  // 4ms to hash

// Cost too high - slow to hash, slow to crack
const hash = await bcrypt.hash(password, 16); // 2 seconds to hash

// Recommended sweet spot
const hash = await bcrypt.hash(password, 12); // ~50ms to hash

Guidance:

  • Cost 10 (≈10ms) - Acceptable but leaning weak
  • Cost 12 (≈50ms) - Recommended standard
  • Cost 14 (≈300ms) - Paranoid/strong security
  • Cost 16+ (≈2+ seconds) - Only for systems where password hashing isn’t on critical path

The cost should be set so that hashing one password takes 50-100 milliseconds on your typical server hardware. This ensures:

  • Legitimate users experience no noticeable delay during login
  • Attackers cannot feasibly crack hashes through brute force

Monitor login latency and adjust if needed. Your infrastructure may handle higher costs without user impact.

3. Never Hash Hashes

// ❌ WRONG - Hashing twice
const hash1 = await bcrypt.hash(password, 12);
const hash2 = await bcrypt.hash(hash1, 12);

// ✅ RIGHT - Hash once
const hash = await bcrypt.hash(password, 12);

Hashing multiple times provides no security benefit and only creates confusion during verification.

4. Compare Hashes Securely

When verifying passwords, use a timing-safe comparison:

// ❌ WRONG - Vulnerable to timing attacks
if (storedHash === inputHash) {
  // Grant access
}

// ✅ RIGHT - Bcrypt handles this
const isValid = await bcrypt.compare(password, storedHash);
if (isValid) {
  // Grant access
}

Why? Ordinary string comparison returns false as soon as the first character doesn’t match. If an attacker is measuring response times, they can determine correct characters:

Correct hash: $2b$12$j9i0Xk8eFt6VzN1mQpL2zOz4HvJ4K9...
Guess 1:      $2a$12$j9i0Xk8eFt6VzN1mQpL2zOz4HvJ4K9... (fast - wrong first char after cost)
Guess 2:      $2b$12$j9i0Xk8eFt6VzN1mQpL2zOz4HvJ4K9... (slow - correct first part)

Attacker notices guess 2 took longer and knows "$2b$" is correct.

Bcrypt’s compare() function always takes the same time regardless of where the hashes differ, preventing this leak.

5. Validate Input Before Hashing

// ✅ Validate password before hashing
function validatePassword(password) {
  if (!password || password.length < 8) {
    throw new Error('Password must be at least 8 characters');
  }
  if (password.length > 128) {
    throw new Error('Password must be less than 128 characters');
  }
  return true;
}

app.post('/register', async (req, res) => {
  const { password } = req.body;
  
  try {
    validatePassword(password);
    const hash = await bcrypt.hash(password, 12);
    // Store hash in database
  } catch (error) {
    res.status(400).json({ error: error.message });
  }
});

Validation serves multiple purposes:

  • Prevents empty/null passwords - Ensures users actually set a password
  • Enforces minimum length - Weaker passwords are more vulnerable to cracking
  • Prevents abuse - Extremely long inputs could cause performance issues
  • User guidance - Helps users understand password requirements

6. Never Log Passwords or Hashes

// ❌ WRONG - Logging sensitive data
console.log(`User login: ${username}, password: ${password}`);
console.log(`Hash: ${hash}`);

// ✅ RIGHT - Log only necessary information
console.log(`User login attempt: ${username}`);
// Log successful auth separately, without credentials
console.log(`Authentication successful for user: ${username}`);

Even hashes shouldn’t be logged. If logs are compromised, attackers have valuable material for cracking.

7. Use HTTPS for All Password Transmission

// ❌ HTTP - Password transmitted in plaintext over the network
// Browser sends: password=MyPassword123

// ✅ HTTPS - Password encrypted in transit
// Browser sends encrypted payload

No matter how secure your password storage is, it’s useless if passwords are intercepted in transit. All password-related endpoints must use HTTPS.

8. Implement Rate Limiting on Login

// ✅ Rate limiting prevents brute force attacks
const loginAttempts = new Map(); // In production, use Redis

app.post('/login', (req, res) => {
  const { username } = req.body;
  const attempts = loginAttempts.get(username) || [];
  
  // Remove attempts older than 15 minutes
  const recentAttempts = attempts.filter(t => Date.now() - t < 15 * 60 * 1000);
  
  if (recentAttempts.length >= 5) {
    return res.status(429).json({ error: 'Too many login attempts. Try again later.' });
  }
  
  // Process login...
  recentAttempts.push(Date.now());
  loginAttempts.set(username, recentAttempts);
});

Rate limiting makes brute-force attacks impractical. After N failed attempts, temporarily lock the account or require additional verification.


Implementing Secure Password Storage

Complete Registration Example (Node.js)

const express = require('express');
const bcrypt = require('bcrypt');
const validator = require('validator');

const app = express();
app.use(express.json());

// Password validation rules
const PASSWORD_MIN_LENGTH = 8;
const PASSWORD_MAX_LENGTH = 128;
const BCRYPT_COST = 12;

function validatePassword(password) {
  if (!password) {
    throw new Error('Password is required');
  }
  
  if (password.length < PASSWORD_MIN_LENGTH) {
    throw new Error(`Password must be at least ${PASSWORD_MIN_LENGTH} characters`);
  }
  
  if (password.length > PASSWORD_MAX_LENGTH) {
    throw new Error(`Password must be less than ${PASSWORD_MAX_LENGTH} characters`);
  }
  
  // Optional: Require complexity
  if (!/[A-Z]/.test(password)) {
    throw new Error('Password must contain at least one uppercase letter');
  }
  
  if (!/[a-z]/.test(password)) {
    throw new Error('Password must contain at least one lowercase letter');
  }
  
  if (!/[0-9]/.test(password)) {
    throw new Error('Password must contain at least one number');
  }
}

// Registration endpoint
app.post('/register', async (req, res) => {
  try {
    const { email, password } = req.body;
    
    // Validate email
    if (!email || !validator.isEmail(email)) {
      return res.status(400).json({ error: 'Valid email required' });
    }
    
    // Validate password
    validatePassword(password);
    
    // Check if user already exists
    const existingUser = await User.findOne({ email });
    if (existingUser) {
      return res.status(409).json({ error: 'User already exists' });
    }
    
    // Hash password with bcrypt
    const passwordHash = await bcrypt.hash(password, BCRYPT_COST);
    
    // Create user in database
    const user = await User.create({
      email,
      passwordHash,
      createdAt: new Date()
    });
    
    // Return success (never return password or hash)
    res.status(201).json({
      message: 'User registered successfully',
      userId: user.id,
      email: user.email
    });
    
  } catch (error) {
    console.error('Registration error:', error.message);
    res.status(500).json({ error: 'Registration failed' });
  }
});

// Login endpoint
app.post('/login', async (req, res) => {
  try {
    const { email, password } = req.body;
    
    // Find user
    const user = await User.findOne({ email });
    if (!user) {
      // Don't reveal if user exists
      return res.status(401).json({ error: 'Invalid email or password' });
    }
    
    // Compare password to hash
    const isPasswordValid = await bcrypt.compare(password, user.passwordHash);
    
    if (!isPasswordValid) {
      return res.status(401).json({ error: 'Invalid email or password' });
    }
    
    // Password is correct - create session or JWT
    const token = generateSessionToken(user.id);
    
    res.json({
      message: 'Login successful',
      token,
      user: {
        id: user.id,
        email: user.email
      }
    });
    
  } catch (error) {
    console.error('Login error:', error.message);
    res.status(500).json({ error: 'Login failed' });
  }
});

// Change password endpoint
app.post('/change-password', async (req, res) => {
  try {
    const userId = req.user.id; // From auth middleware
    const { currentPassword, newPassword } = req.body;
    
    // Fetch user
    const user = await User.findById(userId);
    if (!user) {
      return res.status(404).json({ error: 'User not found' });
    }
    
    // Verify current password
    const isValid = await bcrypt.compare(currentPassword, user.passwordHash);
    if (!isValid) {
      return res.status(401).json({ error: 'Current password is incorrect' });
    }
    
    // Validate new password
    validatePassword(newPassword);
    
    // Prevent reusing same password
    const isSamePassword = await bcrypt.compare(newPassword, user.passwordHash);
    if (isSamePassword) {
      return res.status(400).json({ error: 'New password cannot be the same as current password' });
    }
    
    // Hash and update
    const newHash = await bcrypt.hash(newPassword, BCRYPT_COST);
    await User.findByIdAndUpdate(userId, { passwordHash: newHash });
    
    res.json({ message: 'Password changed successfully' });
    
  } catch (error) {
    console.error('Password change error:', error.message);
    res.status(500).json({ error: 'Password change failed' });
  }
});

Python/FastAPI Implementation

from fastapi import FastAPI, HTTPException
from pydantic import BaseModel, EmailStr, validator
from passlib.context import CryptContext
import re

app = FastAPI()

# Configure password hashing
pwd_context = CryptContext(
  schemes=["bcrypt"],
  deprecated="auto",
  bcrypt__rounds=12  # Cost factor
)

class RegistrationRequest(BaseModel):
  email: EmailStr
  password: str
  
  @validator('password')
  def validate_password(cls, v):
    if len(v) < 8:
      raise ValueError('Password must be at least 8 characters')
    if len(v) > 128:
      raise ValueError('Password must be less than 128 characters')
    if not re.search(r'[A-Z]', v):
      raise ValueError('Password must contain uppercase letter')
    if not re.search(r'[a-z]', v):
      raise ValueError('Password must contain lowercase letter')
    if not re.search(r'[0-9]', v):
      raise ValueError('Password must contain number')
    return v

@app.post("/register")
async def register(data: RegistrationRequest):
  try:
    # Check if user exists
    existing_user = await User.find_one({"email": data.email})
    if existing_user:
      raise HTTPException(status_code=409, detail="User already exists")
    
    # Hash password using bcrypt
    password_hash = pwd_context.hash(data.password)
    
    # Create user
    user = await User.create({
      "email": data.email,
      "password_hash": password_hash
    })
    
    return {
      "message": "User registered successfully",
      "user_id": str(user.id),
      "email": user.email
    }
    
  except Exception as e:
    raise HTTPException(status_code=500, detail="Registration failed")

@app.post("/login")
async def login(data: LoginRequest):
  try:
    user = await User.find_one({"email": data.email})
    
    if not user:
      raise HTTPException(status_code=401, detail="Invalid email or password")
    
    # Verify password using bcrypt
    if not pwd_context.verify(data.password, user.password_hash):
      raise HTTPException(status_code=401, detail="Invalid email or password")
    
    # Generate session/JWT token
    token = generate_token(user.id)
    
    return {
      "message": "Login successful",
      "token": token,
      "user": {
        "id": str(user.id),
        "email": user.email
      }
    }
    
  except HTTPException:
    raise
  except Exception as e:
    raise HTTPException(status_code=500, detail="Login failed")

Secure Password Reset Flows

Password reset is deceptively complex. A poorly designed reset flow can be more dangerous than password storage itself, giving attackers a backdoor to accounts.

The Challenges

1. Identity Verification Without Password

During reset, the user can’t authenticate with their password (that’s what they forgot). You need another way to verify they own the account.

2. Time Sensitivity

The reset mechanism shouldn’t be permanent. If a user clicks “reset” but doesn’t complete the flow, the reset shouldn’t remain valid indefinitely.

3. Preventing Account Takeover

A malicious actor shouldn’t be able to reset another user’s password just by knowing their email address.

4. Preventing Enumeration

An attacker shouldn’t be able to determine which emails are registered in your system by observing differences in response times or messages.

The most secure approach uses a time-limited, cryptographically secure token:

User requests reset
           ↓
System generates secure random token
           ↓
System stores token (hashed) with user ID and expiration time
           ↓
System sends email with reset link containing token
           ↓
User clicks link, enters new password
           ↓
System verifies token exists, hasn't expired, matches user
           ↓
System updates password, invalidates token
           ↓
System logs the user in or asks them to log in

Step-by-Step Implementation

Step 1: Generate and Store Reset Token

const crypto = require('crypto');

app.post('/forgot-password', async (req, res) => {
  try {
    const { email } = req.body;
    
    // Find user (don't reveal if exists)
    const user = await User.findOne({ email });
    
    if (user) {
      // Generate secure random token (32 bytes = 256 bits)
      const resetToken = crypto.randomBytes(32).toString('hex');
      
      // Hash the token before storing (defense in depth)
      // If database is compromised, attackers don't have plain tokens
      const hashedToken = crypto
        .createHash('sha256')
        .update(resetToken)
        .digest('hex');
      
      // Store hashed token with expiration (15 minutes)
      const resetTokenExpiry = Date.now() + 15 * 60 * 1000;
      
      await User.findByIdAndUpdate(user.id, {
        passwordResetToken: hashedToken,
        passwordResetExpiry: resetTokenExpiry
      });
      
      // Send email with reset link
      // The plain token goes in the link, not the hash
      const resetLink = `${process.env.FRONTEND_URL}/reset-password?token=${resetToken}&email=${email}`;
      
      await sendEmail(email, 'Password Reset', `
        Click here to reset your password: ${resetLink}
        This link expires in 15 minutes.
        If you didn't request this, ignore this email.
      `);
    }
    
    // Always return success (don't reveal if user exists)
    res.json({
      message: 'If the email exists, a reset link has been sent'
    });
    
  } catch (error) {
    console.error('Forgot password error:', error);
    res.status(500).json({ error: 'Request failed' });
  }
});

Step 2: Verify Token and Reset Password

app.post('/reset-password', async (req, res) => {
  try {
    const { token, email, newPassword } = req.body;
    
    if (!token || !email || !newPassword) {
      return res.status(400).json({ error: 'Missing required fields' });
    }
    
    // Validate new password
    validatePassword(newPassword);
    
    // Hash the token to compare with stored hash
    const hashedToken = crypto
      .createHash('sha256')
      .update(token)
      .digest('hex');
    
    // Find user and verify token
    const user = await User.findOne({
      email,
      passwordResetToken: hashedToken,
      passwordResetExpiry: { $gt: Date.now() } // Token not expired
    });
    
    if (!user) {
      return res.status(400).json({
        error: 'Invalid or expired reset link'
      });
    }
    
    // Don't allow reusing the password that was forgotten
    // (Optional but good practice)
    const isSamePassword = await bcrypt.compare(newPassword, user.passwordHash);
    if (isSamePassword) {
      return res.status(400).json({
        error: 'New password cannot be the same as your previous password'
      });
    }
    
    // Hash new password
    const newHash = await bcrypt.hash(newPassword, BCRYPT_COST);
    
    // Update password and clear reset token
    await User.findByIdAndUpdate(user.id, {
      passwordHash: newHash,
      passwordResetToken: null,
      passwordResetExpiry: null,
      passwordChangedAt: new Date()
    });
    
    // Optional: Invalidate all existing sessions
    // This forces the user to log back in (more secure)
    await Session.deleteMany({ userId: user.id });
    
    // Optional: Send confirmation email
    await sendEmail(email, 'Password Reset Successful', `
      Your password has been reset.
      If you didn't request this, contact support immediately.
    `);
    
    res.json({
      message: 'Password reset successfully. Please log in with your new password.'
    });
    
  } catch (error) {
    console.error('Reset password error:', error);
    res.status(500).json({ error: 'Reset failed' });
  }
});

Architecture Diagram: Password Reset Flow

┌─────────────────────────────────────────────────────┐
│               User Requests Reset                   │
│  POST /forgot-password { email: "[email protected]" }│
└──────────────────────┬──────────────────────────────┘
                       │
        ┌──────────────▼──────────────┐
        │  Generate Secure Token      │
        │  (32 random bytes)          │
        └──────────────┬──────────────┘
                       │
        ┌──────────────▼──────────────┐
        │  Hash Token for Storage     │
        │  Store with 15min expiry    │
        └──────────────┬──────────────┘
                       │
        ┌──────────────▼──────────────┐
        │  Send Email with Token      │
        │  Never send hashed version  │
        └──────────────┬──────────────┘
                       │
        ┌──────────────▼──────────────┐
        │  User Clicks Email Link     │
        │  GET /reset?token=...       │
        └──────────────┬──────────────┘
                       │
        ┌──────────────▼──────────────┐
        │  User Enters New Password   │
        │  POST /reset-password       │
        └──────────────┬──────────────┘
                       │
     ┌─────────────────▼─────────────────┐
     │ Hash Submitted Token               │
     │ Look up: hashed token + not expired│
     └─────────────────┬─────────────────┘
                       │
     ┌─────────────────▼─────────────────┐
     │ Validate New Password              │
     │ Hash with Bcrypt                   │
     │ Update DB, clear reset token       │
     │ Invalidate old sessions (optional) │
     └─────────────────┬─────────────────┘
                       │
        ┌──────────────▼──────────────┐
        │  Send Confirmation Email    │
        │  Redirect to Login          │
        └──────────────────────────────┘

Security Considerations for Reset Flows

Use Cryptographically Secure Random

// ❌ WRONG - Not cryptographically secure
const token = Math.random().toString(36).substring(7);

// ✅ RIGHT - Cryptographically secure
const token = crypto.randomBytes(32).toString('hex');

Use crypto.randomBytes() or equivalent, never Math.random().

Hash the Token in Storage

// Store hashed token, send plain token
const plainToken = crypto.randomBytes(32).toString('hex');
const hashedToken = crypto.createHash('sha256').update(plainToken).digest('hex');

// Email contains plain token (in reset link)
sendEmail(email, resetLink + '?token=' + plainToken);

// Database stores hashed token
User.update({ passwordResetToken: hashedToken });

This defense-in-depth approach means even if the database is breached, attackers can’t use the tokens directly.

Set Appropriate Expiration

// Too short - frustrates users
const expiry = Date.now() + 5 * 60 * 1000; // 5 minutes

// Recommended
const expiry = Date.now() + 15 * 60 * 1000; // 15 minutes

// Too long - increases attack window
const expiry = Date.now() + 24 * 60 * 60 * 1000; // 24 hours

15 minutes is a good balance. Users have time to check their email without leaving the door open too long.

Invalidate Token After Use

// ✅ Always clear the token after password reset
await User.findByIdAndUpdate(userId, {
  passwordResetToken: null,
  passwordResetExpiry: null
});

Once the token is used, it should be invalidated. This prevents replay attacks.

Invalidate Previous Sessions

// Optional but recommended: Force user to log back in
// This ensures old sessions don't stay active after password reset
await Session.deleteMany({ userId: user.id });

After a password reset, you might invalidate all existing sessions. This forces the user to log back in with their new password, which is more secure (though less convenient).

Don’t Reveal If Email Exists

// ❌ WRONG - Reveals registered emails
if (!user) {
  res.status(404).json({ error: 'User not found' });
}

// ✅ RIGHT - Same response regardless
if (user) {
  // Send reset email
}

// Always respond the same way
res.json({
  message: 'If the email exists, a reset link has been sent'
});

This prevents account enumeration attacks where an attacker determines which emails are registered.

Send Confirmation Emails

// After password reset, confirm the action
await sendEmail(email, 'Password Reset Successful', `
  Your password was reset on ${new Date().toISOString()}.
  
  If you didn't request this, your account may be compromised.
  Please contact support: [email protected]
`);

Users can detect unauthorized reset attempts and respond quickly.


Common Mistakes & How to Avoid Them

Mistake 1: Plaintext Passwords in Logs

// ❌ WRONG
app.post('/login', (req, res) => {
  const { username, password } = req.body;
  console.log(`Login attempt: ${username}, ${password}`); // PASSWORD IN LOG!
  // ...
});

// ✅ RIGHT
app.post('/login', (req, res) => {
  const { username, password } = req.body;
  console.log(`Login attempt: ${username}`);
  // Process password securely...
});

Mistake 2: Using SHA-256 for Passwords

// ❌ WRONG - Too fast for password hashing
const hash = crypto.createHash('sha256').update(password).digest('hex');

// ✅ RIGHT - Designed for passwords
const hash = await bcrypt.hash(password, 12);

Mistake 3: Hashing on the Client Side Only

// ❌ WRONG - Client-side hashing only
// User's browser: hash = sha256(password)
// Send hash to server (hash becomes the password)
// Attacker intercepts hash, uses it to log in

// ✅ RIGHT - Server-side hashing
// User's browser: Send password over HTTPS
// Server: Hash with bcrypt, store hash

Server-side hashing is essential. Client-side hashing can be part of defense-in-depth, but never the only layer.

Mistake 4: Reusing Reset Tokens

// ❌ WRONG - Token valid indefinitely
await User.update({ passwordResetToken: token });
// No expiry set

// ✅ RIGHT - Token expires
await User.update({
  passwordResetToken: hashedToken,
  passwordResetExpiry: Date.now() + 15 * 60 * 1000
});

Mistake 5: Storing Reset Token in Plaintext

// ❌ WRONG - If DB is compromised, tokens are exposed
User.update({ passwordResetToken: plainToken });

// ✅ RIGHT - Store hashed version
const hashedToken = crypto.createHash('sha256').update(plainToken).digest('hex');
User.update({ passwordResetToken: hashedToken });

Mistake 6: Weak Cost Factor

// ❌ WRONG - Too fast
await bcrypt.hash(password, 4);

// ✅ RIGHT - Appropriate cost
await bcrypt.hash(password, 12);

Mistake 7: No Rate Limiting on Login

// ❌ WRONG - Attacker can try unlimited passwords
app.post('/login', async (req, res) => {
  // No rate limiting
  // Attacker tries 1000 passwords per second
});

// ✅ RIGHT - Rate limiting prevents brute force
app.post('/login', async (req, res) => {
  if (hasExceededLoginAttempts(username)) {
    return res.status(429).json({ error: 'Too many attempts' });
  }
  // Process login...
});

Mistake 8: Sending Passwords in Emails

// ❌ WRONG - Never send passwords via email
sendEmail(user.email, `Your password is: ${newPassword}`);

// ✅ RIGHT - Send reset token only
const resetToken = generateSecureToken();
sendEmail(user.email, `Click here to reset: ${resetLink}?token=${resetToken}`);

Emails are notoriously insecure. Passwords should never be sent, even during password reset.


Additional Security Considerations

Multi-Factor Authentication (MFA)

While not strictly password security, MFA adds critical protection:

// Add MFA to password-based auth
app.post('/login', async (req, res) => {
  // Verify password...
  
  if (user.mfaEnabled) {
    // Send MFA code
    const mfaCode = generateSecureCode();
    await sendMfaCode(user.email, mfaCode);
    
    req.session.pendingMfa = true;
    req.session.pendingUserId = user.id;
    
    return res.json({ message: 'MFA code sent' });
  }
  
  // Create authenticated session
});

Password Requirements

Enforce reasonable requirements without being excessive:

const PASSWORD_RULES = {
  minLength: 8,      // Too short: 6, Too long: 20+
  maxLength: 128,    // Accommodate long passphrases
  requireUppercase: true,
  requireLowercase: true,
  requireNumber: true,
  requireSpecial: false // Often unnecessary and frustrating
};

Password Expiration

Modern guidance recommends against forced password expiration:

// ❌ NOT RECOMMENDED - Outdated guidance
// Force password reset every 90 days

// ✅ MODERN APPROACH
// Reset only when:
// - User requests it
// - Password is compromised
// - High-security operation

Forced expiration leads to weaker passwords (users reuse patterns). Reset when needed instead.

Secure Password Storage in Database

// Ensure database backups are encrypted
// Ensure database connections use SSL/TLS
// Ensure database access is restricted
// Ensure passwords are never written to logs

// Good practice: Separate password hash column
const User = {
  id: 'uuid',
  email: 'string',
  passwordHash: 'string', // Separate from other fields
  createdAt: 'timestamp'
};

Monitoring and Alerting

// Alert on suspicious password activity
app.post('/login', async (req, res) => {
  // ... authentication ...
  
  if (user.lastLoginLocation !== getLocation(req)) {
    // New location detected
    await sendEmail(user.email, `
      Your account was accessed from a new location.
      Location: ${getLocation(req)}
      Time: ${new Date()}
      
      If this wasn't you, change your password immediately.
    `);
  }
});

Conclusion

Password security is foundational to application security. The practices outlined here—using bcrypt, implementing proper hashing with appropriate cost factors, and designing secure reset flows—protect your users and your business.

Key Takeaways

For Password Storage:

  • Use bcrypt with a cost factor of 12
  • Always salt passwords (bcrypt does this automatically)
  • Never log passwords or hashes
  • Validate passwords before hashing
  • Use HTTPS for all password transmission
  • Implement rate limiting on login

For Reset Flows:

  • Use cryptographically secure random tokens
  • Store hashed versions of tokens
  • Set 15-minute expiration times
  • Invalidate tokens after use
  • Don’t reveal which emails are registered
  • Send confirmation emails for successful resets

For Defense-in-Depth:

  • Enforce reasonable password requirements (minimum 8 characters, character variety)
  • Implement multi-factor authentication
  • Monitor for suspicious activity
  • Keep dependencies updated
  • Conduct security audits regularly
  • Follow OWASP guidance

Password security is not a “one-time” implementation but an ongoing responsibility. Stay informed about emerging threats, keep your dependencies patched, and regularly review your authentication implementation against current best practices.


Further Reading & Resources

Comments