Password Security Best Practices: Hashing, Storage, and Policies

Share

TL;DR

The #1 password security best practice is using bcrypt or argon2id for password hashing instead of SHA-256 or MD5. Require minimum 8 characters without complexity rules. Check against breached password databases. Implement rate limiting and account lockout. Offer passwordless options and 2FA.

"The strength of your password storage determines whether a data breach becomes a minor incident or a catastrophic compromise of every user account."

Best Practice 1: Use the Right Hashing Algorithm 3 min

Only use password-specific hashing algorithms:

AlgorithmStatusUse Case
argon2idBest choiceNew applications
bcryptExcellentWidely supported
scryptGoodAlternative to bcrypt
PBKDF2AcceptableCompliance requirements
SHA-256NEVERNot for passwords
MD5NEVERNot for passwords
Password hashing with bcrypt
import bcrypt from 'bcrypt';

const SALT_ROUNDS = 12;  // Adjust based on server capacity

async function hashPassword(password) {
  return bcrypt.hash(password, SALT_ROUNDS);
}

async function verifyPassword(password, hash) {
  return bcrypt.compare(password, hash);
}

// Usage
const hash = await hashPassword('userPassword123');
// Stored: $2b$12$LQv3c1yqBWVHxkd0LHAkCOYz6TtxMQJqhN8/X4.G...
Password hashing with argon2
import argon2 from 'argon2';

async function hashPassword(password) {
  return argon2.hash(password, {
    type: argon2.argon2id,  // Recommended variant
    memoryCost: 65536,      // 64 MB
    timeCost: 3,            // 3 iterations
    parallelism: 4,         // 4 threads
  });
}

async function verifyPassword(password, hash) {
  return argon2.verify(hash, password);
}

Best Practice 2: Modern Password Policies 2 min

NIST guidelines recommend simpler, more effective policies:

  • Minimum 8 characters (12+ recommended)
  • Maximum 64+ characters (do not limit)
  • Allow all characters including spaces
  • No complexity requirements (uppercase, symbols)
  • No periodic password rotation
  • Check against breached password lists
  • Show password strength meter
Password validation
import { z } from 'zod';

const passwordSchema = z.string()
  .min(8, 'Password must be at least 8 characters')
  .max(128, 'Password too long')
  .refine(
    (password) => !isBreachedPassword(password),
    'This password has appeared in a data breach'
  );

// Check against Have I Been Pwned
async function isBreachedPassword(password) {
  const hash = crypto
    .createHash('sha1')
    .update(password)
    .digest('hex')
    .toUpperCase();

  const prefix = hash.slice(0, 5);
  const suffix = hash.slice(5);

  const response = await fetch(
    `https://api.pwnedpasswords.com/range/${prefix}`
  );
  const text = await response.text();

  return text.includes(suffix);
}

Best Practice 3: Secure Password Reset 5 min

Password reset is a common attack vector:

Secure password reset flow
import crypto from 'crypto';

async function requestPasswordReset(email) {
  const user = await findUserByEmail(email);

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

  // Generate secure token
  const token = crypto.randomBytes(32).toString('hex');
  const hashedToken = crypto
    .createHash('sha256')
    .update(token)
    .digest('hex');

  // Store hashed token with expiry
  await db.passwordReset.create({
    userId: user.id,
    tokenHash: hashedToken,
    expiresAt: new Date(Date.now() + 60 * 60 * 1000), // 1 hour
  });

  // Send email with plain token
  await sendEmail(email, {
    subject: 'Password Reset',
    link: `https://app.example.com/reset?token=${token}`,
  });

  return { message: 'If the email exists, a reset link was sent' };
}

async function resetPassword(token, newPassword) {
  const hashedToken = crypto
    .createHash('sha256')
    .update(token)
    .digest('hex');

  const reset = await db.passwordReset.findFirst({
    where: {
      tokenHash: hashedToken,
      expiresAt: { gt: new Date() },
      used: false,
    },
  });

  if (!reset) {
    throw new Error('Invalid or expired reset link');
  }

  // Hash and save new password
  const hash = await hashPassword(newPassword);
  await db.user.update({
    where: { id: reset.userId },
    data: { passwordHash: hash },
  });

  // Mark token as used and invalidate sessions
  await db.passwordReset.update({
    where: { id: reset.id },
    data: { used: true },
  });

  await invalidateAllSessions(reset.userId);
}

Best Practice 4: Rate Limiting Login Attempts 3 min

Protect against brute force attacks:

Login rate limiting
import rateLimit from 'express-rate-limit';

// Global rate limit by IP
const loginLimiter = rateLimit({
  windowMs: 15 * 60 * 1000,  // 15 minutes
  max: 5,                     // 5 attempts per window
  message: 'Too many login attempts, please try again later',
  standardHeaders: true,
  legacyHeaders: false,
});

// Per-account lockout
const LOCKOUT_THRESHOLD = 5;
const LOCKOUT_DURATION = 15 * 60 * 1000; // 15 minutes

async function attemptLogin(email, password, ip) {
  const user = await findUserByEmail(email);

  if (!user) {
    // Prevent timing attacks
    await bcrypt.hash(password, 12);
    throw new AuthError('Invalid credentials');
  }

  // Check if account is locked
  if (user.lockedUntil && user.lockedUntil > new Date()) {
    throw new AuthError('Account temporarily locked');
  }

  const valid = await verifyPassword(password, user.passwordHash);

  if (!valid) {
    // Increment failed attempts
    const attempts = user.failedAttempts + 1;

    await db.user.update({
      where: { id: user.id },
      data: {
        failedAttempts: attempts,
        lockedUntil: attempts >= LOCKOUT_THRESHOLD
          ? new Date(Date.now() + LOCKOUT_DURATION)
          : null,
      },
    });

    throw new AuthError('Invalid credentials');
  }

  // Reset failed attempts on success
  await db.user.update({
    where: { id: user.id },
    data: { failedAttempts: 0, lockedUntil: null },
  });

  return createSession(user);
}

Best Practice 5: Offer Stronger Authentication 10 min

Passwords alone are not enough:

  • Offer two-factor authentication (TOTP, WebAuthn)
  • Support passwordless login (magic links, passkeys)
  • Encourage password managers
  • Implement step-up authentication for sensitive actions
TOTP 2FA implementation
import speakeasy from 'speakeasy';
import QRCode from 'qrcode';

// Generate 2FA secret
function generate2FASecret(email) {
  const secret = speakeasy.generateSecret({
    name: `YourApp (${email})`,
    issuer: 'YourApp',
  });

  return {
    secret: secret.base32,
    qrCodeUrl: secret.otpauth_url,
  };
}

// Verify 2FA token
function verify2FAToken(secret, token) {
  return speakeasy.totp.verify({
    secret: secret,
    encoding: 'base32',
    token: token,
    window: 1,  // Allow 1 step before/after
  });
}

// Login with 2FA
async function loginWith2FA(email, password, totpToken) {
  const user = await authenticatePassword(email, password);

  if (user.twoFactorEnabled) {
    if (!totpToken) {
      return { requiresTwoFactor: true };
    }

    if (!verify2FAToken(user.twoFactorSecret, totpToken)) {
      throw new AuthError('Invalid 2FA code');
    }
  }

  return createSession(user);
}

Official Resources: For comprehensive password security guidance, see OWASP Password Storage Cheat Sheet and OWASP Authentication Cheat Sheet.

Should I require password changes every 90 days?

No. NIST no longer recommends periodic password rotation. It leads to weaker passwords (Password1, Password2...). Only require changes when there is evidence of compromise.

What about complexity requirements?

Studies show complexity requirements (uppercase, number, symbol) do not improve security and frustrate users. Focus on length and breach checking instead.

How many bcrypt rounds should I use?

Use the highest value that keeps login under 250ms on your server. Start with 10-12 and benchmark. Increase over time as hardware improves.

Check Your Password Security

Scan for weak password hashing and policy issues.

Start Free Scan
Best Practices

Password Security Best Practices: Hashing, Storage, and Policies