TL;DR
Django never stores passwords in plaintext. Every password goes through a hashing algorithm before touching the database. The default is PBKDF2 with SHA256 and 1.8 million iterations slow enough to make brute-force attacks impractical, fast enough to be imperceptible for legitimate logins.
Each password gets its own random salt, making rainbow tables useless. When you upgrade your hashing algorithm (say, from PBKDF2 to Argon2), Django transparently re-hashes passwords as users log in. No forced password resets. No migration scripts.
The whole system lives in
django.contrib.auth.hasherswith two key functions:make_password()andcheck_password(). Understanding them is worth five minutes of your time.
You've probably wondered at some point: when a user signs up and types p@ssw0rd123, what actually
gets saved in the database? Surely Django isn't storing that plaintext string, right?
It isn't. And the way it handles passwords is genuinely clever worth understanding even if you never look at the implementation directly.
What Actually Gets Stored
When you call something like User.objects.create_user() or make_password(), Django doesn't store
your password as-is. Instead, it runs the password through a hashing algorithm and stores something
that looks like this:
pbkdf2_sha256$1800000$salt_value$hashed_result
That's four parts separated by $:
- The algorithm used (
pbkdf2_sha256) - The iteration count (
1800000) - The random salt (
salt_value) - The resulting hash (
hashed_result)
This format is important because it tells Django exactly how to verify the password later. The stored hash is self-describing.
Why Not Just Use SHA256?
A common beginner instinct is to just run the password through SHA256 or MD5 and call it a day. That's a mistake, and here's why.
SHA256 is a fast hash. It's designed to be fast. That's great for file checksums, but terrible for passwords because a modern GPU can compute billions of SHA256 hashes per second. If an attacker gets your database, they can brute-force weak passwords in minutes.
What you want is a slow hash. One that takes deliberate effort to compute, making brute-force attacks impractical. That's exactly what PBKDF2 is designed for.
PBKDF2: The Default Choice
Django defaults to PBKDF2 (Password-Based Key Derivation Function 2) with SHA256. It's not a single hash but a function that repeatedly hashes the password thousands of times.
The key insight is the iteration count. By default, Django uses 1800000 iterations. That means to
verify a single password, the system has to compute SHA256 1.8 million times. For a legitimate
login, that's a few hundred milliseconds imperceptible to a user. For an attacker trying billions of
combinations, it's painfully slow.
Salting
Every password gets its own random salt. This means even if two users have the same password, their stored hashes will look completely different. Rainbow tables (precomputed lookup tables for common passwords) become useless because the attacker would need a separate table for every possible salt value.
The Password Hashers Setting
In your settings.py, there's a setting called PASSWORD_HASHERS:
PASSWORD_HASHERS = [
"django.contrib.auth.hashers.PBKDF2PasswordHasher",
"django.contrib.auth.hashers.PBKDF2SHA1PasswordHasher",
"django.contrib.auth.hashers.Argon2PasswordHasher",
"django.contrib.auth.hashers.BCryptSHA256PasswordHasher",
]
The first entry is what Django uses to hash new passwords. The entire list is what Django uses to verify existing passwords during login.
Why a list? Because when you upgrade your hashing algorithm, you don't want to force every user to reset their password. Django handles this elegantly:
- User logs in with their old password.
- Django identifies the old algorithm from the stored hash.
- It re-hashes the password using the new default algorithm.
- The database gets updated automatically.
The next time that user logs in, their password is already using the newer, stronger algorithm. It's a seamless migration that happens in the background.
Argon2 and Bcrypt
While PBKDF2 is solid, there are two other serious contenders:
Argon2 won the Password Hashing Competition in 2015 and is considered the state of the art. It's memory-hard, meaning it requires a significant amount of RAM to compute, making GPU-based attacks much harder. If you're starting a new project, Argon2 is worth considering:
PASSWORD_HASHERS = [
"django.contrib.auth.hashers.Argon2PasswordHasher",
# ... other hashers for backwards compatibility
]
You'll need to install the argon2-cffi package:
pip install argon2-cffi
Bcrypt is another battle-tested option. It's been around longer than Argon2 and has a strong track record. The main difference is that Argon2 is more resistant to GPU attacks due to its memory-hardness.
The make_password and check_password Functions
Django provides two utility functions in django.contrib.auth.hashers:
from django.contrib.auth.hashers import make_password, check_password
# Hashing a password
hashed =("mysecretpassword")
# Returns: "pbkdf2_sha256$1800000$randomsalt$longhashstring"
# Verifying a password
is_valid =("mysecretpassword", hashed)
# Returns: True
The check_password function does something subtle but critical: it uses constant-time
comparison. This prevents timing attacks where an attacker could deduce the password by measuring
how long the comparison takes. Every comparison takes the same amount of time regardless of whether
the password is correct or not.
Unusable Passwords
What if you want to create a user account that can never log in? Maybe it's an account provisioned through an external identity provider. Django handles this:
from django.contrib.auth.hashers import make_password
# Creating an unusable password
hashed =(None)
# Returns: "!" - a special marker
When make_password receives None, it returns the string "!". The check_password function
recognizes this marker and always returns False. No password will ever match.
Putting It All Together
Here's the complete lifecycle of a password in Django:
- User submits a password during registration or password change.
- Django calls
make_password()on the raw password. - A random salt is generated.
- The password is hashed using the default algorithm (PBKDF2 by default).
- The formatted hash string is saved to the database.
- On login, the user submits their password.
- Django calls
check_password()with the raw password and stored hash. - The hash is parsed to extract the algorithm, iterations, and salt.
- The raw password is hashed using those same parameters.
- The result is compared using constant-time comparison.
- If it matches and the algorithm was outdated, Django re-hashes with the current default.
Pro tip: If you're building a new Django project today, consider switching to Argon2 as your default hasher. It's a one-line change in
settings.pyplus a pip install, and it provides better protection against modern hardware-based attacks.
Final Thoughts
Django's password storage is one of those things that works silently in the background. You rarely think about it, but it's doing important work.
The key takeaways:
- Django never stores plaintext passwords. Everything goes through a hashing algorithm.
- PBKDF2 with 1.8 million iterations is the default, and it's a solid choice for most applications.
- Salts are generated automatically, making rainbow tables useless.
- Algorithm upgrades happen transparently when you change the
PASSWORD_HASHERSsetting. - Argon2 is available if you want state-of-the-art protection, especially against GPU attacks.
It's one of those things you set up once, understand once, and then never worry about again because Django has already thought about it for you.