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.hashers with two key functions: make_password() and check_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 $:

  1. The algorithm used (pbkdf2_sha256)
  2. The iteration count (1800000)
  3. The random salt (salt_value)
  4. 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:

  1. User logs in with their old password.
  2. Django identifies the old algorithm from the stored hash.
  3. It re-hashes the password using the new default algorithm.
  4. 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 = make_password("mysecretpassword")
# Returns: "pbkdf2_sha256$1800000$randomsalt$longhashstring"

# Verifying a password
is_valid = check_password("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 = make_password(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:

  1. User submits a password during registration or password change.
  2. Django calls make_password() on the raw password.
  3. A random salt is generated.
  4. The password is hashed using the default algorithm (PBKDF2 by default).
  5. The formatted hash string is saved to the database.
  6. On login, the user submits their password.
  7. Django calls check_password() with the raw password and stored hash.
  8. The hash is parsed to extract the algorithm, iterations, and salt.
  9. The raw password is hashed using those same parameters.
  10. The result is compared using constant-time comparison.
  11. 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.py plus 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_HASHERS setting.
  • 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.