Securityintermediate

Password Security & Management — Hashing, Salting, and Secure Flows

Learn how to properly hash, salt, and manage passwords. Covers bcrypt, argon2, scrypt comparison, rainbow table prevention, pepper secrets, password reset flows, and frontend validation.

15 min read·Published Apr 21, 2026
securitypasswordshashingauthentication

Why Password Security Matters

Passwords remain the most common authentication mechanism. When a database breach occurs, the attacker gets the stored password representations. If passwords are stored in plain text, every account is immediately compromised. If they are hashed with a weak algorithm, the attacker can crack them within hours. Proper password security ensures that even after a breach, the passwords remain computationally infeasible to recover.

 Breach Scenario:

 Plain text storage:   attacker gets passwords instantly
 MD5/SHA-256 hashing:  attacker cracks most in hours (GPU brute force)
 bcrypt (cost 12):     attacker cracks ~25,000 guesses/sec per GPU
 argon2id (tuned):     attacker cracks ~1,000 guesses/sec per GPU

 Time to crack "P@ssw0rd123" (common password):
   MD5:       < 1 second
   SHA-256:   < 1 second
   bcrypt:    ~3 seconds
   argon2id:  ~30 seconds

 Time to crack "kX9#mP2$vL7!nQ4" (random 15-char):
   MD5:       centuries
   SHA-256:   centuries
   bcrypt:    centuries
   argon2id:  centuries (even more so)

The goal is not to make cracking impossible — it is to make it so slow that cracking even one password takes an impractical amount of time and resources.

Password Hashing: The Core Defense

Hashing is a one-way function. You can convert a password to a hash, but you cannot convert the hash back to a password. When a user logs in, you hash their input and compare it to the stored hash.

 Registration:
   password "hello123" --> hash function --> "$2b$12$LJ3m4y..." --> store in DB

 Login:
   input "hello123" --> hash function --> "$2b$12$LJ3m4y..."
   Compare with stored hash --> match --> authenticated

 Attacker:
   stolen hash "$2b$12$LJ3m4y..." --> cannot reverse to "hello123"
   Must try every possible password through the hash function (brute force)

Why General-Purpose Hashes Fail

SHA-256 and MD5 were designed for integrity checking — they need to be fast. A modern GPU can compute billions of SHA-256 hashes per second. Password hashing algorithms are deliberately slow to make brute-force attacks impractical.

+------------------+------------------------+----------------------------------+
| Algorithm        | Hashes/sec (GPU)       | Time to Try 10 Billion Passwords |
+------------------+------------------------+----------------------------------+
| MD5              | ~10,000,000,000        | ~1 second                        |
| SHA-256          | ~3,000,000,000         | ~3 seconds                       |
| bcrypt (cost 12) | ~25,000                | ~4.6 days                        |
| scrypt (default) | ~10,000                | ~11.6 days                       |
| argon2id (tuned) | ~1,000                 | ~116 days                        |
+------------------+------------------------+----------------------------------+

bcrypt

bcrypt has been the industry standard since 1999. It includes a built-in salt, a configurable cost factor, and has been battle-tested for decades.

How bcrypt Works

 Input: password + cost factor (e.g., 12)

 1. Generate random 16-byte salt
 2. Derive key using Blowfish cipher, iterated 2^cost times
 3. Output: $2b$12$<22-char salt><31-char hash>

 Example output:
 $2b$12$LJ3m4ys3Lg5Rfz7mJQhOae6EYjqMHx.dpUJqMMkYjnL1MoZxVkS8u
  |  |  |                      |
  |  |  salt (22 chars)        hash (31 chars)
  |  cost factor (12 = 2^12 = 4096 iterations)
  algorithm identifier

bcrypt Implementation

const bcrypt = require('bcrypt');

// Configuration
const BCRYPT_COST = 12; // 2^12 = 4,096 iterations
// Cost 10 = ~100ms, Cost 12 = ~300ms, Cost 14 = ~1s
// Choose based on your server capacity

// Hash a password
async function hashPassword(plaintext) {
  return bcrypt.hash(plaintext, BCRYPT_COST);
}

// Verify a password
async function verifyPassword(plaintext, storedHash) {
  return bcrypt.compare(plaintext, storedHash);
}

// Complete registration flow
async function registerUser(email, password) {
  // 1. Validate password strength
  const strengthErrors = validatePasswordStrength(password);
  if (strengthErrors.length > 0) {
    throw new Error(strengthErrors.join(', '));
  }

  // 2. Check for existing user
  const existing = await db.users.findByEmail(email);
  if (existing) {
    // Don't reveal whether email exists
    // Still hash to prevent timing attacks
    await bcrypt.hash(password, BCRYPT_COST);
    throw new Error('Registration failed');
  }

  // 3. Hash and store
  const passwordHash = await hashPassword(password);
  const user = await db.users.create({
    email,
    passwordHash,
    createdAt: new Date(),
  });

  return user;
}

// Complete login flow
async function loginUser(email, password) {
  const user = await db.users.findByEmail(email);

  if (!user) {
    // Hash anyway to prevent timing attacks
    // (attacker cannot distinguish "user not found" from "wrong password"
    //  by measuring response time)
    await bcrypt.hash(password, BCRYPT_COST);
    throw new Error('Invalid credentials');
  }

  const valid = await verifyPassword(password, user.passwordHash);
  if (!valid) {
    throw new Error('Invalid credentials');
  }

  return user;
}

bcrypt limitation: Maximum input length is 72 bytes. Passwords longer than 72 bytes are truncated. This rarely matters in practice (72 bytes is very long for a password), but be aware.

Argon2

Argon2 won the Password Hashing Competition (PHC) in 2015. It offers three variants:

+------------------+-----------------------------------------------------+
| Variant          | Designed For                                        |
+------------------+-----------------------------------------------------+
| Argon2d          | Resistance to GPU attacks (data-dependent memory)   |
| Argon2i          | Resistance to side-channel attacks (data-independent)|
| Argon2id         | Hybrid — recommended for password hashing           |
+------------------+-----------------------------------------------------+

Argon2id is recommended for password hashing because it combines GPU resistance and side-channel resistance.

Argon2 Parameters

+------------------+------------------+-----------------------------------+
| Parameter        | What It Controls | Recommended Value                 |
+------------------+------------------+-----------------------------------+
| memoryCost       | Memory usage     | 65536 (64 MB) minimum             |
| timeCost         | Iterations       | 3 minimum                         |
| parallelism      | CPU threads      | 1-4 (depends on server)           |
| hashLength       | Output size      | 32 bytes                          |
+------------------+------------------+-----------------------------------+

Argon2 Implementation

const argon2 = require('argon2');

// Configuration — tune these based on your server
const ARGON2_CONFIG = {
  type: argon2.argon2id,
  memoryCost: 65536,    // 64 MB
  timeCost: 3,          // 3 iterations
  parallelism: 4,       // 4 threads
  hashLength: 32,       // 32-byte output
};

async function hashPassword(plaintext) {
  return argon2.hash(plaintext, ARGON2_CONFIG);
}

async function verifyPassword(plaintext, storedHash) {
  try {
    return await argon2.verify(storedHash, plaintext);
  } catch {
    return false;
  }
}

// Check if hash needs rehashing (e.g., after increasing cost)
async function needsRehash(storedHash) {
  return argon2.needsRehash(storedHash, ARGON2_CONFIG);
}

// Login with automatic rehashing
async function loginWithRehash(email, password) {
  const user = await db.users.findByEmail(email);
  if (!user) {
    await hashPassword(password); // Timing attack prevention
    throw new Error('Invalid credentials');
  }

  const valid = await verifyPassword(password, user.passwordHash);
  if (!valid) throw new Error('Invalid credentials');

  // Rehash if config has changed (e.g., increased cost)
  if (await needsRehash(user.passwordHash)) {
    const newHash = await hashPassword(password);
    await db.users.update(user.id, { passwordHash: newHash });
  }

  return user;
}

scrypt

scrypt is a memory-hard password hashing function. It was designed to be resistant to hardware brute-force attacks by requiring large amounts of memory.

const crypto = require('crypto');
const { promisify } = require('util');
const scryptAsync = promisify(crypto.scrypt);

const SCRYPT_CONFIG = {
  N: 16384,       // CPU/memory cost (power of 2)
  r: 8,           // Block size
  p: 1,           // Parallelization
  keyLength: 64,  // Output length in bytes
};

async function hashPassword(plaintext) {
  const salt = crypto.randomBytes(32);
  const derivedKey = await scryptAsync(plaintext, salt, SCRYPT_CONFIG.keyLength, {
    N: SCRYPT_CONFIG.N,
    r: SCRYPT_CONFIG.r,
    p: SCRYPT_CONFIG.p,
  });

  // Store salt and hash together
  return `${salt.toString('hex')}:${derivedKey.toString('hex')}`;
}

async function verifyPassword(plaintext, storedHash) {
  const [saltHex, hashHex] = storedHash.split(':');
  const salt = Buffer.from(saltHex, 'hex');
  const storedKey = Buffer.from(hashHex, 'hex');

  const derivedKey = await scryptAsync(plaintext, salt, SCRYPT_CONFIG.keyLength, {
    N: SCRYPT_CONFIG.N,
    r: SCRYPT_CONFIG.r,
    p: SCRYPT_CONFIG.p,
  });

  // Constant-time comparison
  return crypto.timingSafeEqual(derivedKey, storedKey);
}

Algorithm Comparison

+------------------+----------+----------+----------+----------+
| Feature          | bcrypt   | scrypt   | argon2id | PBKDF2   |
+------------------+----------+----------+----------+----------+
| Memory-hard      | No       | Yes      | Yes      | No       |
| GPU resistant    | Moderate | High     | Highest  | Low      |
| Side-channel     | Moderate | Low      | High     | Moderate |
|   resistant      |          |          |          |          |
| Max input length | 72 bytes | Unlimited| Unlimited| Unlimited|
| Built-in salt    | Yes      | No       | Yes      | No       |
| Maturity         | 1999     | 2009     | 2015     | 2000     |
| Recommendation   | Good     | Good     | Best     | Avoid    |
+------------------+----------+----------+----------+----------+

Recommendation priority:

  1. Argon2id — Best overall, use if available
  2. bcrypt — Battle-tested, excellent fallback
  3. scrypt — Good if Argon2 unavailable
  4. PBKDF2 — Avoid for new projects (not memory-hard)

Salting: Preventing Rainbow Tables

A salt is a random value added to each password before hashing. Without salts, identical passwords produce identical hashes — attackers can use precomputed lookup tables (rainbow tables) to find passwords instantly.

 Without salt:
   hash("password123") = "abc123..." (same for ALL users with this password)
   Attacker precomputes hashes for common passwords --> instant lookup

 With salt:
   user1: salt="randomA" --> hash("randomA" + "password123") = "xyz789..."
   user2: salt="randomB" --> hash("randomB" + "password123") = "def456..."
   Same password, different hashes. Rainbow table useless.
// Manual salting (for educational purposes — bcrypt/argon2 handle this automatically)
const crypto = require('crypto');

function hashWithSalt(password) {
  const salt = crypto.randomBytes(32).toString('hex'); // 32 bytes = 256 bits
  const hash = crypto
    .createHash('sha256')
    .update(salt + password)
    .digest('hex');

  return { salt, hash };
}

function verifyWithSalt(password, storedSalt, storedHash) {
  const hash = crypto
    .createHash('sha256')
    .update(storedSalt + password)
    .digest('hex');

  return crypto.timingSafeEqual(
    Buffer.from(hash, 'hex'),
    Buffer.from(storedHash, 'hex')
  );
}

// NOTE: This is for illustration only.
// In production, use bcrypt or argon2 which handle salting internally.

Salt requirements:

  • Unique per password — Never reuse salts
  • Random — Use a cryptographic random number generator
  • Sufficient length — At least 16 bytes (128 bits), 32 bytes preferred
  • Stored with hash — The salt is not secret; it is stored alongside the hash

Pepper: Server-Side Secret

A pepper is a secret value added to the password before hashing, but unlike a salt, the pepper is NOT stored in the database. It is stored in environment variables or a key management service.

 Salt: stored in database alongside hash (unique per password)
 Pepper: stored in environment variable (same for all passwords)

 hash(pepper + salt + password) = stored_hash

 If database is breached:
   Attacker has: stored_hash, salt
   Attacker missing: pepper
   Brute force is insufficient — they don't know the pepper
const bcrypt = require('bcrypt');
const crypto = require('crypto');

const PEPPER = process.env.PASSWORD_PEPPER; // 32+ byte hex string

async function hashWithPepper(password) {
  // HMAC the password with the pepper first
  const peppered = crypto
    .createHmac('sha256', PEPPER)
    .update(password)
    .digest('hex');

  // Then bcrypt the peppered value
  return bcrypt.hash(peppered, 12);
}

async function verifyWithPepper(password, storedHash) {
  const peppered = crypto
    .createHmac('sha256', PEPPER)
    .update(password)
    .digest('hex');

  return bcrypt.compare(peppered, storedHash);
}

Pepper rotation: If your pepper is compromised, you need to re-hash all passwords. Plan for this by storing a pepper version alongside each hash, so you can gradually migrate users during login.

async function loginWithPepperRotation(email, password) {
  const user = await db.users.findByEmail(email);
  if (!user) throw new Error('Invalid credentials');

  const currentPepper = process.env.PASSWORD_PEPPER_V2;
  const oldPepper = process.env.PASSWORD_PEPPER_V1;

  // Try current pepper first
  let valid = await verifyWithPepper(password, user.passwordHash, currentPepper);

  // Fall back to old pepper
  if (!valid && user.pepperVersion === 1) {
    valid = await verifyWithPepper(password, user.passwordHash, oldPepper);

    if (valid) {
      // Rehash with new pepper
      const newHash = await hashWithPepper(password, currentPepper);
      await db.users.update(user.id, {
        passwordHash: newHash,
        pepperVersion: 2,
      });
    }
  }

  if (!valid) throw new Error('Invalid credentials');
  return user;
}

Password Reset Flows

Password reset is a critical flow. A poorly implemented reset can be a bigger vulnerability than the passwords themselves.

Secure Reset Flow

 User                        Server                     Email Service
 ----                        ------                     -------------
    |                           |                            |
    |-- POST /forgot-password ->|                            |
    |   { email: "[email protected]" }   |                            |
    |                           |-- Generate token           |
    |                           |-- Store hash of token      |
    |                           |   with expiry (1 hour)     |
    |                           |                            |
    |                           |-- Send reset email ------->|
    |                           |   with token in URL        |
    |                           |                            |
    |<-- "Check your email" ----|                            |
    |   (same response whether                               |
    |    email exists or not)                                 |
    |                                                        |
    |<-- Email with reset link ------------------------------|
    |   https://app.com/reset?token=abc123                   |
    |                                                        |
    |-- POST /reset-password -->|                            |
    |   { token, newPassword }  |                            |
    |                           |-- Verify token (hash match)|
    |                           |-- Check not expired        |
    |                           |-- Hash new password        |
    |                           |-- Update DB                |
    |                           |-- Invalidate token         |
    |                           |-- Invalidate all sessions  |
    |                           |                            |
    |<-- "Password updated" ----|                            |

Implementation

const crypto = require('crypto');
const bcrypt = require('bcrypt');

// Step 1: Request password reset
app.post('/api/forgot-password', async (req, res) => {
  const { email } = req.body;

  // Always return success to prevent user enumeration
  res.json({ message: 'If the email exists, a reset link was sent.' });

  const user = await db.users.findByEmail(email);
  if (!user) return; // Silently return

  // Generate cryptographic token
  const resetToken = crypto.randomBytes(32).toString('hex');

  // Store HASH of token (not the token itself)
  const tokenHash = crypto.createHash('sha256').update(resetToken).digest('hex');

  await db.resetTokens.create({
    userId: user.id,
    tokenHash,
    expiresAt: new Date(Date.now() + 60 * 60 * 1000), // 1 hour
    used: false,
  });

  // Invalidate any previous reset tokens for this user
  await db.resetTokens.invalidateOld(user.id, tokenHash);

  // Send email with the plain token (NOT the hash)
  await sendEmail({
    to: email,
    subject: 'Password Reset',
    text: `Reset your password: https://app.com/reset?token=${resetToken}`,
  });
});

// Step 2: Reset password with token
app.post('/api/reset-password', async (req, res) => {
  const { token, newPassword } = req.body;

  // Validate new password strength
  const errors = validatePasswordStrength(newPassword);
  if (errors.length > 0) {
    return res.status(400).json({ error: errors.join(', ') });
  }

  // Hash the submitted token to find the stored record
  const tokenHash = crypto.createHash('sha256').update(token).digest('hex');

  const resetRecord = await db.resetTokens.findByHash(tokenHash);

  if (!resetRecord || resetRecord.used || resetRecord.expiresAt < new Date()) {
    return res.status(400).json({ error: 'Invalid or expired reset token' });
  }

  // Hash new password and update
  const passwordHash = await bcrypt.hash(newPassword, 12);

  await db.users.update(resetRecord.userId, { passwordHash });

  // Mark token as used
  await db.resetTokens.markUsed(tokenHash);

  // Invalidate all existing sessions for this user
  await db.sessions.deleteByUserId(resetRecord.userId);

  res.json({ message: 'Password updated successfully' });
});

Key security requirements for password resets:

  • Token is random — Use crypto.randomBytes(32), not UUIDs or timestamps
  • Store hash of token — If the database is breached, attacker cannot use stored tokens
  • Expire quickly — 1 hour maximum
  • Single use — Token is invalidated after use
  • Invalidate sessions — After password change, all existing sessions are destroyed
  • Same response — Do not reveal whether the email exists

Frontend Password Validation

Client-side validation provides instant user feedback but is not a security measure. The server must re-validate all constraints because the client can be bypassed.

// Password strength validator
function validatePasswordStrength(password) {
  const errors = [];

  if (password.length < 12) {
    errors.push('Must be at least 12 characters');
  }

  if (password.length > 128) {
    errors.push('Must be 128 characters or fewer');
  }

  if (!/[a-z]/.test(password)) {
    errors.push('Must include a lowercase letter');
  }

  if (!/[A-Z]/.test(password)) {
    errors.push('Must include an uppercase letter');
  }

  if (!/[0-9]/.test(password)) {
    errors.push('Must include a number');
  }

  if (!/[^a-zA-Z0-9]/.test(password)) {
    errors.push('Must include a special character');
  }

  // Check against common passwords
  const commonPasswords = [
    'password123', 'qwerty12345', 'letmein1234',
    'admin12345', 'welcome1234', 'monkey12345',
  ];
  if (commonPasswords.some(common => password.toLowerCase().includes(common))) {
    errors.push('Password is too common');
  }

  return errors;
}

Password Strength Meter (React)

import { useState, useMemo } from 'react';

type StrengthLevel = 'weak' | 'fair' | 'good' | 'strong';

function calculateStrength(password: string): { level: StrengthLevel; score: number } {
  let score = 0;

  if (password.length >= 8) score += 1;
  if (password.length >= 12) score += 1;
  if (password.length >= 16) score += 1;
  if (/[a-z]/.test(password) && /[A-Z]/.test(password)) score += 1;
  if (/[0-9]/.test(password)) score += 1;
  if (/[^a-zA-Z0-9]/.test(password)) score += 1;
  if (password.length >= 20) score += 1;

  // Unique character ratio
  const uniqueRatio = new Set(password).size / password.length;
  if (uniqueRatio > 0.7) score += 1;

  const level: StrengthLevel =
    score <= 2 ? 'weak' :
    score <= 4 ? 'fair' :
    score <= 6 ? 'good' :
    'strong';

  return { level, score: Math.min(score, 8) };
}

const STRENGTH_COLORS: Record<StrengthLevel, string> = {
  weak: '#ef4444',
  fair: '#f59e0b',
  good: '#3b82f6',
  strong: '#22c55e',
};

export function PasswordInput() {
  const [password, setPassword] = useState('');

  const strength = useMemo(() => calculateStrength(password), [password]);

  return (
    <div>
      <label htmlFor="password">Password</label>
      <input
        id="password"
        type="password"
        value={password}
        onChange={(e) => setPassword(e.target.value)}
        autoComplete="new-password"
      />

      {password.length > 0 && (
        <div>
          <div
            style={{
              height: '4px',
              width: `${(strength.score / 8) * 100}%`,
              backgroundColor: STRENGTH_COLORS[strength.level],
              transition: 'width 0.3s, background-color 0.3s',
              borderRadius: '2px',
            }}
          />
          <span style={{ color: STRENGTH_COLORS[strength.level] }}>
            {strength.level.charAt(0).toUpperCase() + strength.level.slice(1)}
          </span>
        </div>
      )}
    </div>
  );
}

Password Security Checklist

+------+----------------------------------------------+----------+
| #    | Check                                        | Status   |
+------+----------------------------------------------+----------+
| 1    | Passwords hashed with argon2id or bcrypt      | [ ]      |
| 2    | Unique salt per password (auto with bcrypt)    | [ ]      |
| 3    | Pepper applied (stored outside database)       | [ ]      |
| 4    | Minimum length 12 characters                   | [ ]      |
| 5    | Maximum length 128+ characters                 | [ ]      |
| 6    | No password truncation (or document if bcrypt)  | [ ]      |
| 7    | Common password check                          | [ ]      |
| 8    | Reset tokens are random, hashed, expiring       | [ ]      |
| 9    | Same error for bad email and bad password        | [ ]      |
| 10   | Timing attack prevention on login                | [ ]      |
| 11   | Sessions invalidated after password change       | [ ]      |
| 12   | Password strength meter on frontend              | [ ]      |
| 13   | Server validates password strength too            | [ ]      |
| 14   | Rate limiting on login and reset endpoints       | [ ]      |
| 15   | Rehashing strategy for algorithm upgrades        | [ ]      |
+------+----------------------------------------------+----------+

Summary

Password security is a multi-layered discipline. The hashing algorithm is the core, but the complete system includes salting, peppering, strength validation, secure reset flows, and operational practices.

Key takeaways:

  1. Use Argon2id as first choice, bcrypt as fallback. Never use MD5, SHA-256, or PBKDF2 for passwords.
  2. Salts prevent rainbow table attacks. bcrypt and Argon2 handle salting automatically.
  3. Pepper adds a layer that survives database breaches. Store it in environment variables.
  4. Password reset tokens must be cryptographically random, hashed before storage, single-use, and short-lived.
  5. Prevent user enumeration — identical responses for "user not found" and "wrong password."
  6. Frontend validation provides UX feedback. Server validation provides security.
  7. Rehash on login when upgrading algorithms or increasing cost factors.
  8. Constant-time comparison prevents timing attacks when verifying hashes manually.

Found this helpful?

Support devsofus — help us keep creating free dev guides.

Related Articles