AppSec Basics

Secure Password Storage: Beyond Just Hashing

"We hash passwords" isn't actually a complete answer. Which algorithm, how it's salted, and how comparisons happen all matter just as much as the fact that hashing happens at all.

F Fora AI Team June 25, 2026 8 min read

Every engineer knows not to store passwords in plaintext. Fewer stop to check which hashing approach their stack is actually using, and that gap is where a surprising number of real-world breaches turn from "annoying" into "catastrophic."

Not all hashing is equal

MD5 and SHA-1 are cryptographic hash functions, and it's tempting to assume "hashed" means "safe." It doesn't, for password storage specifically. These algorithms were designed to be fast — which is exactly the wrong property for a password hash. An attacker with a stolen database of MD5-hashed passwords can test billions of guesses per second on off-the-shelf GPU hardware, because MD5 was never designed to resist that.

Purpose-built password hashing algorithms — Argon2, bcrypt, and scrypt — are deliberately slow and memory-intensive. That's a feature: it makes brute-forcing computationally expensive even at scale. Django's default password hasher uses PBKDF2 with a high iteration count for exactly this reason, and can be configured to use Argon2 instead if you install argon2-cffi.

Salting isn't optional

A salt is random data added to each password before hashing, unique per user. Without it, two users with the same password produce identical hashes, and an attacker can precompute a lookup table (a "rainbow table") of common password hashes once and reuse it against every breached database they encounter. Django salts automatically; if you're ever tempted to write custom authentication logic, this is one of the easiest things to get subtly wrong.

The comparison itself matters too

Even with a strong hash, comparing values incorrectly can reintroduce risk. A plain == comparison between two strings in Python returns as soon as it finds a mismatched character, which means the operation takes very slightly longer for strings that match further before diverging. In a security-critical comparison, that timing difference can theoretically be measured and exploited to guess a secret one character at a time. Django's check_password() uses a constant-time comparison specifically to close this off — it's one more reason not to write custom password-checking logic by hand.

What this means practically

For almost every Django project, the right amount of custom password-security code is zero. Use django.contrib.auth, let AUTH_PASSWORD_VALIDATORS enforce minimum strength, and let authenticate() and check_password() handle hashing and comparison. The moments this becomes a real design decision are when you're migrating away from a legacy hashing scheme, or integrating with an external identity provider — and in both cases, it's worth treating as a deliberate architecture review rather than an incidental implementation detail.

← Back to all posts