26 min read
When building any application that requires user authentication, one of the most critical security decisions you'll make is how to store user passwords. Get this wrong, and you could expose your users to catastrophic security breaches. Get it right, and you'll have laid a solid foundation for your application's security.
In this comprehensive guide, we'll explore the evolution of password storage techniques, from the naive approaches that should never be used to the sophisticated methods employed by modern frameworks like Django. Whether you're a beginner just starting out or looking to deepen your understanding of password security, this article will take you through everything you need to know.
The Fundamental Problem: Why Password Storage is Tricky
Before diving into solutions, let's understand why storing passwords securely is such a challenge. The core issue is that passwords need to be verified (when users log in) but should never be retrievable by anyoneโnot even system administrators or developers.
This creates a unique requirement: we need a way to verify that a user has entered the correct password without actually storing the password itself. This is where the concept of one-way functions becomes crucial.
The Wrong Way: Plain Text Storage
Let's start with what you should absolutely never do: storing passwords in plain text.
CREATE TABLE users (
id INT PRIMARY KEY,
username VARCHAR(50),
password VARCHAR(100) -- NEVER DO THIS!
);
INSERT INTO users VALUES (1, 'john_doe', 'mypassword123');
This approach is a security disaster waiting to happen. If your database is compromised, every user's password is immediately exposed. Unfortunately, this still happens more often than you'd think, even in 2025.
Understanding Hashing: The First Line of Defense
What is Hashing?
Hashing is a mathematical process that takes an input (your password) and produces a fixed-length string of characters (the hash). The key properties of a good cryptographic hash function are:
- One-way function: It's computationally infeasible to reverse the process
- Deterministic: The same input always produces the same output
- Fixed output length: Regardless of input size, the output is always the same length
- Avalanche effect: A tiny change in input produces a dramatically different output
Let's see how this works with a simple example using SHA-256:
Input: "password123"
SHA-256 Hash: "ef92b778bafe771e89245b89ecbc08a44a4e166c06659911881f383d4473e94f"
Input: "password124" (notice the tiny change)
SHA-256 Hash: "5994471abb01112afcc18159f6cc74b4f511b99806da59b3caf5a9c173cacfc5"
Notice how completely different the hashes are, despite only changing one character.
๐ Live Password Hashing Demo
SHA-256 Hash Output:
Type a password above to see its hash...
How Hashing Works for Password Storage
Instead of storing the actual password, we store its hash:
CREATE TABLE users (
id INT PRIMARY KEY,
username VARCHAR(50),
password_hash VARCHAR(64) -- Store the hash, not the password
);
-- When user registers with password "mypassword123"
INSERT INTO users VALUES (1, 'john_doe', 'ef92b778bafe771e89245b89ecbc08a44a4e166c06659911881f383d4473e94f');
When the user tries to log in:
- Take the password they entered
- Hash it using the same algorithm
- Compare the resulting hash with the stored hash
- If they match, the password is correct
This approach means that even if your database is compromised, attackers don't have the actual passwordsโthey only have the hashes.
The Problem with Simple Hashing: Enter Rainbow Tables
While hashing seems like the perfect solution, it has a significant vulnerability: rainbow tables.
What are Rainbow Tables?
Rainbow tables are precomputed tables of common passwords and their corresponding hashes. Since hash functions are deterministic (the same input always produces the same output), attackers can create massive databases mapping common passwords to their hashes.
For example, a rainbow table might contain:
password123 โ ef92b778bafe771e89245b89ecbc08a44a4e166c06659911881f383d4473e94f
123456 โ 8d969eef6ecad3c29a3a629280e686cf0c3f5d5a86aff3ca12020c923adc6c92
qwerty โ 65e84be33532fb784c48129675f9eff3a682b27168c0ea744b2cf58ee02337c5
If an attacker gets your database and sees the hash ef92b778bafe771e89245b89ecbc08a44a4e166c06659911881f383d4473e94f
, they can quickly look it up in their rainbow table and discover that the original password was "password123".
The Scale of the Problem
Modern rainbow tables can contain billions of password-hash combinations for common algorithms like MD5, SHA-1, and even SHA-256. These tables can crack a significant percentage of passwords in seconds, making simple hashing inadequate for security.
๐ Rainbow Table Attack Simulator
๐ Precomputed Rainbow Table (Sample)
๐ฏ Rainbow Table Lookup
Click "Search Rainbow Table" to simulate an instant lookup attack
๐ Brute Force Attack
Click "Start Brute Force" to simulate trying every combination
0.001s
Rainbow Table Lookup
2.3 hours
Brute Force Attack
โ ๏ธ Educational Demo: This simulates how rainbow tables work. Real rainbow tables contain millions of password-hash pairs and can crack common passwords in seconds.
The Solution: Adding Salt to Your Hashes
What is a Salt?
A salt is a random value that's added to your password before hashing. This simple addition completely defeats rainbow tables and significantly enhances security.
Here's how it works:
Password: "password123"
Salt: "8f2k9d7n"
Combined: "password1238f2k9d7n"
Hash: sha256("password1238f2k9d7n") = "a1b2c3d4e5f6..."
Why Salts are Effective
- Unique hashes: Even if two users have the same password, their hashes will be different because they have different salts
- Rainbow table defense: Attackers would need to create rainbow tables for every possible salt, which is computationally infeasible
- No additional complexity for users: The salt is generated automatically and stored alongside the hash
Salt Implementation
Here's how you might store salted hashes:
CREATE TABLE users (
id INT PRIMARY KEY,
username VARCHAR(50),
password_hash VARCHAR(64),
salt VARCHAR(32)
);
-- User registers with password "mypassword123"
-- Generate random salt: "8f2k9d7n"
-- Hash "mypassword1238f2k9d7n"
INSERT INTO users VALUES (1, 'john_doe', 'a1b2c3d4e5f6...', '8f2k9d7n');
For login verification:
- Retrieve the user's salt from the database
- Append the salt to the entered password
- Hash the combined string
- Compare with the stored hash
๐ง Salt Addition Demonstration
+
โ Without Salt (Vulnerable)
1. Input:
password123
SHA-256 Hash:
Loading...
โ
With Salt (Secure)
1. Combine:
password123a7b9k2m8
SHA-256 Hash:
Loading...
๐ฏ Try Different Scenarios
๐จ Why Salts Matter: Database Breach Simulation
Without salts, identical passwords create identical hashes, revealing patterns:
Salt Best Practices
- Use cryptographically secure random number generators to create salts
- Make salts at least 16 bytes long (128 bits)
- Use a unique salt for every password, never reuse salts
- Store the salt alongside the hashโit's not secret information
Modern Approach: Multiple Rounds of Hashing
While salting solves the rainbow table problem, modern hardware can still compute hashes very quickly. A powerful computer can calculate millions or billions of hashes per second, making brute force attacks feasible.
The Concept of Key Stretching
The solution is key stretchingโdeliberately making the hashing process slower by performing multiple rounds of hashing. Instead of hashing once, we hash thousands or tens of thousands of times.
Round 1: hash(password + salt)
Round 2: hash(result_of_round_1)
Round 3: hash(result_of_round_2)
...
Round 10000: hash(result_of_round_9999)
This process:
- Makes each password verification take longer (maybe 100-500 milliseconds)
- Is barely noticeable to legitimate users
- Makes brute force attacks exponentially more expensive for attackers
Adaptive Hashing Algorithms
Modern password hashing uses specialized algorithms designed for this purpose:
- PBKDF2 (Password-Based Key Derivation Function 2)
- bcrypt
- scrypt
- Argon2 (currently recommended as the best choice)
These algorithms are designed to be computationally expensive and have tunable parameters to control the computational cost.
๐ Key Stretching Visualization
Hashing Progress
Ready to start
Click "Start Key Stretching Process" to see the rounds of hashing in action
๐ค Legitimate User
0.25s
Single login attempt
Barely noticeable delay
Barely noticeable delay
๐จ Attacker
2.9 days
1 million password attempts
Computationally expensive
Computationally expensive
๐ก How it works: Each round takes the output of the previous round and hashes it again.
This makes the process deliberately slow, increasing security against brute force attacks.
Visualization of Brute Force Attack with Salting
โฑ๏ธ Brute Force Attack Timer
Security Method Comparison
Security Method
Single Attempt
1K Attempts
1M Attempts
1B Attempts
๐ฏ Attack Time Visualization
๐ Home Computer
Modern CPU
~100,000 hashes/sec
~100,000 hashes/sec
-
๐ป Server Farm
Multiple GPUs
~10,000,000 hashes/sec
~10,000,000 hashes/sec
-
๐ญ Specialized Hardware
ASIC miners
~1,000,000,000 hashes/sec
~1,000,000,000 hashes/sec
-
โ๏ธ Cloud Botnet
Distributed attack
~100,000,000,000 hashes/sec
~100,000,000,000 hashes/sec
-
๐ก Hardware assumptions: Times calculated based on realistic attack scenarios.
Modern GPUs can perform billions of simple hash operations per second, but key stretching significantly slows this down.
Django's Password Hashing: A Real-World Example
Let's examine how Django, one of the most popular web frameworks, implements secure password hashing.
Django's Default Configuration
Django uses PBKDF2 with SHA-256 by default, but it's designed to be flexible. Here's what a Django password hash looks like:
pbkdf2_sha256$320000$randomsalt$hashvalue
Breaking this down:
pbkdf2_sha256
: The algorithm used320000
: The number of iterations (rounds)randomsalt
: The randomly generated salthashvalue
: The actual hash result
How Django Handles Password Hashing
When you create a user in Django:
from django.contrib.auth.models import User
from django.contrib.auth.hashers import make_password
# Creating a user
user = User.objects.create_user(
username='john_doe',
password='mypassword123' # Django automatically hashes this
)
# The actual stored value looks like:
# pbkdf2_sha256$320000$8f2k9d7nxmvp$a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6q7r8s9t0
Django's Password Verification Process
When a user logs in, Django:
- Retrieves the stored password hash
- Extracts the algorithm, iteration count, and salt
- Takes the entered password and applies the same process
- Compares the results
from django.contrib.auth.hashers import check_password
# During login
is_valid = check_password('mypassword123', stored_hash)
# Returns True if password matches, False otherwise
Django's Security Features
Django includes several advanced security features:
- Algorithm agility: Easy to upgrade to new algorithms
- Automatic iteration upgrades: Increases iterations over time as hardware improves
- Multiple algorithm support: Can verify old passwords while using new algorithms for new passwords
- Timing attack protection: Uses constant-time comparison functions
Customizing Django's Password Hashing
You can configure Django to use different algorithms or adjust parameters:
# settings.py
PASSWORD_HASHERS = [
'django.contrib.auth.hashers.Argon2PasswordHasher',
'django.contrib.auth.hashers.PBKDF2PasswordHasher',
'django.contrib.auth.hashers.PBKDF2SHA1PasswordHasher',
'django.contrib.auth.hashers.BCryptSHA256PasswordHasher',
]
The first hasher in the list is used for new passwords, while the others are used for verification of existing passwords.
๐ Django Password Format Breakdown
Django Password Hash:
Click "Generate Django Hash" to see the result
๐ Django Hashing Process
๐ง Django Supported Algorithms
๐ก๏ธ Security Features:
- Algorithm Agility: Easy to upgrade to new algorithms without breaking existing passwords
- Automatic Iteration Upgrades: Django can automatically increase iterations for better security
- Salt Generation: Unique cryptographically secure salt for each password
- Constant-Time Comparison: Prevents timing attacks during verification
# Django password hashing example
from django.contrib.auth.hashers import make_password, check_password
# Creating a hash
password_hash = make_password('mypassword123')
print(password_hash)
# Verifying a password
is_valid = check_password('mypassword123', password_hash)
print(is_valid) # True
Best Practices for Secure Password Storage
1. Never Roll Your Own Crypto
Use established libraries and frameworks that implement these techniques correctly. Popular options include:
- Django (Python)
- Laravel (PHP)
- Spring Security (Java)
- bcrypt libraries (available in most languages)
2. Keep Your Hashing Up to Date
Security requirements evolve over time. What's secure today might not be secure in five years. Regularly review and update your password hashing strategy.
3. Monitor and Adjust Iteration Counts
As hardware becomes more powerful, increase the number of iterations in your key stretching. A good rule of thumb is to aim for 100-500 milliseconds of computation time.
4. Implement Additional Security Measures
Password hashing is just one part of a comprehensive security strategy:
- Enforce strong password policies
- Implement rate limiting for login attempts
- Use multi-factor authentication where possible
- Monitor for suspicious login patterns
- Regularly audit your security practices
5. Prepare for Breaches
Assume that your database will eventually be compromised and plan accordingly:
- Use the strongest password hashing available
- Have an incident response plan
- Know how to quickly notify users and force password resets
- Consider implementing breach detection systems
The Future of Password Security
The landscape of password security continues to evolve. Emerging trends include:
- Passwordless authentication using biometrics or hardware tokens
- Zero-knowledge proof systems that can verify passwords without storing any password-related information
- Quantum-resistant algorithms to prepare for the advent of quantum computing
However, passwords aren't going away anytime soon, making secure storage techniques more important than ever.
Conclusion
Storing passwords securely is a complex topic that has evolved significantly over the years. From the early days of plain text storage to modern adaptive hashing algorithms, each advancement has been driven by the need to stay ahead of increasingly sophisticated attacks.
The key takeaways are:
- Never store passwords in plain textโthis is inexcusable in modern applications
- Simple hashing is not enoughโrainbow tables make it vulnerable
- Always use saltsโthey're essential for preventing rainbow table attacks
- Implement key stretchingโmake your hashing computationally expensive
- Use established librariesโdon't implement these techniques yourself
- Stay currentโsecurity requirements evolve over time
By following these principles and using frameworks like Django that implement these techniques correctly, you can ensure that your users' passwords remain secure even in the face of database breaches. Remember, security is not a one-time implementation but an ongoing commitment to protecting your users' data.
The techniques we've discussedโfrom basic hashing to salted, multi-round hashing as implemented in modern frameworksโrepresent the current best practices in password security. By understanding these concepts and implementing them correctly, you're taking a crucial step toward building secure, trustworthy applications.
Subscribe to my newsletter.
Become a subscriber receive the latest updates in your inbox.
Member discussion