Security

Password Security: Hashing, Salting, and Best Practices

How to properly store passwords: why hashing alone isn't enough, what salting does, and modern password security recommendations.

HandyUtils January 6, 2026 6 min read

Password security seems simple—just hash and store, right? Unfortunately, many developers get it wrong, leading to catastrophic breaches. Here's how to do it properly.

Why You Should Never Store Plain Passwords

When a database is breached (and breaches happen to everyone), plain-text passwords mean:

  • Attackers get immediate access to user accounts
  • Users who reuse passwords are compromised everywhere
  • Your company faces legal liability and reputation damage
-- NEVER DO THIS
INSERT INTO users (email, password) VALUES ('user@example.com', 'MyPassword123');

Even encrypted passwords are risky—if attackers get the encryption key, all passwords are exposed at once.

Why Encryption Isn't the Answer

"Why not encrypt passwords instead of hashing?"

Encryption is reversible—with the key, you can recover the original password. This creates risks:

  • Key management complexity
  • Single point of failure (compromised key = all passwords)
  • Insider threats (anyone with key access)
  • You don't need to recover passwords—users can reset them

Hashing is one-way. Even you can't recover the original password, which is exactly what we want.

Hashing Passwords: The Basics

A hash function converts a password to a fixed-length string:

hash("MyPassword123") → "a1b2c3d4e5f6..."

To verify: hash the entered password and compare with stored hash.

def verify_password(entered, stored_hash):
    return hash(entered) == stored_hash

But simple hashing has a fatal flaw...

The Rainbow Table Problem

A rainbow table is a precomputed database of password → hash mappings:

password     → 5f4dcc3b5aa765d61d8327deb882cf99
123456       → e10adc3949ba59abbe56e057f20f883e
qwerty       → d8578edf8458ce06fbc5bb76a58c5ca4
...millions more...

If you hash passwords with MD5 or SHA:

  • Attackers download a rainbow table
  • Look up each hash in your breached database
  • Instantly recover common passwords

Salting Explained

A salt is random data added to each password before hashing:

salt = generate_random_bytes(16)  # Different for each user
hash = sha256(salt + password)
store(username, salt, hash)

Now the same password produces different hashes:

User Password Salt Hash
Alice password123 a3f8... 9e2d1f...
Bob password123 7b2c... 5a8c3e...

Rainbow tables become useless—attackers would need a table for every possible salt.

Salt Requirements

  • Unique per user: Never reuse salts
  • Random: Use cryptographic random generator
  • Long enough: At least 16 bytes (128 bits)
  • Stored with hash: You need it to verify

Modern Password Hashing: bcrypt, Argon2, scrypt

Standard hash functions (MD5, SHA) are designed to be fast. For passwords, that's a problem—attackers can try billions of guesses per second.

Password hashing algorithms are intentionally slow:

bcrypt

The veteran choice, battle-tested since 1999:

import bcrypt

# Hash a password
hashed = bcrypt.hashpw(password.encode(), bcrypt.gensalt())
# b'$2b$12$LQv3c1yqBWVHxkd0LHAkCOYz6TtxMQJqhN8/A.1JYTfsO/zL.xVZ.'

# Verify
if bcrypt.checkpw(password.encode(), hashed):
    print("Valid!")

The output contains the salt and cost factor—all in one string.

Work factor: The 12 in $2b$12$... is the cost factor. Each increment doubles the computation time. Adjust based on your server capacity.

Argon2

Winner of the Password Hashing Competition (2015). Resists GPU attacks better than bcrypt:

from argon2 import PasswordHasher

ph = PasswordHasher()
hash = ph.hash("password123")
# $argon2id$v=19$m=65536,t=3,p=4$c29tZXNhbHQ$...

try:
    ph.verify(hash, "password123")
    print("Valid!")
except:
    print("Invalid!")

Argon2 has three variants:

  • Argon2d: Fast, but vulnerable to side-channel attacks
  • Argon2i: Resistant to side-channel attacks
  • Argon2id: Hybrid, recommended for passwords

scrypt

Memory-hard function, making parallel attacks expensive:

const crypto = require('crypto');

const hash = crypto.scryptSync(password, salt, 64);

Which Should You Choose?

Algorithm Recommendation
Argon2id Best choice for new systems
bcrypt Excellent, well-understood
scrypt Good, but Argon2 is preferred
SHA-256 ❌ Not for passwords
MD5 ❌ Never

Common Password Mistakes

1. Using fast hashes

# WRONG
password_hash = hashlib.sha256(password.encode()).hexdigest()

# RIGHT
password_hash = bcrypt.hashpw(password.encode(), bcrypt.gensalt())

2. Not using salt

# WRONG
hash = sha256(password)

# RIGHT (bcrypt includes salt automatically)
hash = bcrypt.hashpw(password.encode(), bcrypt.gensalt())

3. Reusing salts

# WRONG
global_salt = "mysalt"
hash = sha256(global_salt + password)

# RIGHT
salt = generate_unique_random_salt()
hash = sha256(salt + password)

4. Low work factor

# WRONG - too fast
bcrypt.hashpw(password, bcrypt.gensalt(rounds=4))

# RIGHT - adjust based on your needs
bcrypt.hashpw(password, bcrypt.gensalt(rounds=12))

5. Truncating passwords

Some systems silently truncate long passwords:

# bcrypt truncates at 72 bytes
# Pre-hash long passwords:
if len(password) > 72:
    password = sha256(password).digest()
hashed = bcrypt.hashpw(password, bcrypt.gensalt())

Password Strength Factors

Help users create strong passwords:

Length over complexity

correcthorsebatterystaple is stronger than P@ssw0rd!

Check against breached passwords

Use the Have I Been Pwned API to reject compromised passwords.

Allow long passwords

Support at least 64 characters (ideally unlimited).

Don't require arbitrary rules

Requirements like "must contain uppercase, number, symbol" lead to predictable patterns like Password1!

Implementing Secure Authentication

Complete example (Python/Flask)

from argon2 import PasswordHasher
from argon2.exceptions import VerifyMismatchError

ph = PasswordHasher()

def create_user(username, password):
    # Validate password strength first
    if len(password) < 12:
        raise ValueError("Password too short")
    
    # Check against breached passwords (HIBP API)
    if is_password_breached(password):
        raise ValueError("Password found in data breach")
    
    # Hash and store
    hashed = ph.hash(password)
    db.execute(
        "INSERT INTO users (username, password_hash) VALUES (?, ?)",
        [username, hashed]
    )

def verify_user(username, password):
    user = db.get_user(username)
    if not user:
        # Prevent timing attacks - still do hash comparison
        ph.hash(password)  # Dummy hash
        return False
    
    try:
        ph.verify(user.password_hash, password)
        
        # Check if rehash needed (algorithm updated)
        if ph.check_needs_rehash(user.password_hash):
            new_hash = ph.hash(password)
            db.update_password_hash(username, new_hash)
        
        return True
    except VerifyMismatchError:
        return False

Summary

Password security requires:

  1. Never store plain text - Always hash
  2. Use password-specific algorithms - bcrypt, Argon2, or scrypt
  3. Unique salt per password - Built into modern algorithms
  4. Appropriate work factor - Balance security and performance
  5. Length over complexity - Encourage passphrases
  6. Check breached passwords - Reject known-compromised passwords
Do Don't
Use Argon2id or bcrypt Use MD5, SHA-256
Unique salt per user Reuse salts
Allow long passwords Truncate at 8 characters
Encourage passphrases Require P@ssw0rd! patterns
Check breach databases Accept any password

Want to generate strong passwords? Try our Password Generator and Password Strength Checker!

Related Topics
password hashing salting bcrypt argon2 security authentication
Share this article

Continue Reading

Security
JWT Tokens Decoded: Structure, Security, and Best Practices

Understanding JSON Web Tokens: the three parts of a JWT, how verification works, and security considerations for token-based authentication.

Security
What is HMAC? Message Authentication for Developers

Understanding HMAC (Hash-based Message Authentication Code): how it works, why it's more secure than plain hashes, and implementing it in your APIs.

Security
Understanding Hash Functions: MD5, SHA-1, SHA-256 Explained

A developer's guide to cryptographic hash functions: what they are, how they work, and choosing the right hash for your use case.