Security

Password Hashing & Salting Explained (How It Actually Works)

How password hashing and salting actually work — why fast hashes and plain encryption fail, and how bcrypt and Argon2 keep stored passwords safe.

HandyUtils January 6, 2026 8 min read

Password hashing and salting work by running each password through a deliberately slow, one-way function together with a unique random salt, so the value you store can't be reversed or matched against a precomputed table. It sounds simple—just hash and store, right? Unfortunately, many developers get it wrong, leading to catastrophic breaches. Here's how it actually works, and 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

Frequently Asked Questions

How does password hashing work?

Hashing runs a password through a one-way function that always produces the same fixed-length output for the same input, but can't be reversed to recover the original. When a user signs up, you store the hash instead of the password. When they log in, you hash what they typed and compare it to the stored hash—if they match, the password is correct. Because the function only goes one way, a stolen database of hashes doesn't hand attackers the passwords directly. The catch: you must use a slow, password-specific hash (bcrypt, Argon2, or scrypt), not a fast general-purpose one.

What is salting a password?

A salt is a unique, random value added to each password before it's hashed. Without it, two users with the same password get the same hash, and attackers can crack many accounts at once using precomputed "rainbow tables." With a per-user salt, identical passwords produce completely different hashes, so those precomputed tables become useless and each password has to be attacked individually. The salt isn't secret—it's stored alongside the hash and is needed to verify the password later.

Is SHA-256 good for hashing passwords?

No. SHA-256 is a great general-purpose hash, but it's the wrong tool for passwords because it's fast—modern hardware can compute billions of SHA-256 hashes per second, which is exactly what an attacker wants when brute-forcing a stolen database. Password storage needs a deliberately slow, tunable algorithm like bcrypt or Argon2 that makes each guess expensive. Use SHA-256 for integrity checks and signatures, not for storing passwords.

bcrypt vs Argon2 — which should I use?

For a brand-new system, prefer Argon2id: it won the 2015 Password Hashing Competition and is memory-hard, which resists GPU and ASIC cracking better than bcrypt. That said, bcrypt is an excellent, battle-tested choice that's been in production since 1999 and is available in virtually every language—if your framework already ships it and it's tuned to a sensible work factor, there's no urgent reason to switch. Both are far better than any fast hash. The short version: choose Argon2id if you can, bcrypt if that's what you have.

Why not just encrypt passwords instead of hashing them?

Encryption is reversible—anyone with the key can turn the stored value back into the original password, which makes the key a single point of catastrophic failure and creates insider-threat and key-management risks. Hashing is one-way: even you can't recover the original, and you don't need to. Users prove their identity by re-hashing what they type, and if they forget a password they reset it rather than "recovering" it. For password storage, that irreversibility is the whole point.

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
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.

Security
What Is HMAC? How Hash-Based Message Authentication Works

HMAC explained for developers: how it works, HMAC vs MAC, HMAC-SHA256 vs SHA-1, and how to verify signatures in your API.