Educational Content: This article is written for educational purposes to help developers and cybersecurity students understand software concepts. Always follow ethical guidelines and applicable laws.
A few years ago I was going through an old side project — something I'd built when I was just getting started with PHP. I opened the users table in phpMyAdmin and stared at the password column for a solid ten seconds before it hit me. Plain MD5. Every single password, sitting there as a 32-character hex string, completely crackable in seconds with tools that have been freely available for over a decade.
I'm sharing that because I think a lot of developers have a version of that moment. You learn the basics, you build something, you move on, and somewhere in that process the security details either didn't register or felt like something you'd come back to later. This post is the thing I wish I'd read back then — a clear picture of how password cracking actually works, what makes certain approaches dangerous, and exactly what to do instead.
The Reality of Stolen Password Databases
Before getting into the mechanics, it helps to understand the landscape. Data breaches happen constantly. Large services, small forums, old applications with forgotten admin panels — databases get exposed regularly and the contents end up circulating in private channels and eventually in public dumps.
These dumps contain hundreds of millions of username and password hash combinations. Attackers run them through cracking tools offline — on their own hardware, at their own pace, with no lockouts and no rate limiting — and convert as many hashes back to plaintext as their hardware and time allow. The results get compiled into "credential databases" that are then used for credential stuffing attacks against other services.
This means your users' passwords aren't just at risk from someone attacking your application directly. They're at risk from every other service those users have ever registered with. If someone uses the same password on your site and on a site that got breached three years ago — and that breach used weak hashing — their password is already in a database somewhere.
This context matters because it shapes how you think about your responsibility as a developer. You can't control what other sites do. You can control whether your hashing is strong enough that even if your database gets exposed, the passwords inside it remain protected.
How Password Cracking Actually Works
There are a few main approaches, and understanding them explains why certain hashing algorithms are completely inadequate.
Dictionary attacks — The attacker takes a list of common passwords — there are wordlists with hundreds of millions of entries compiled from real breach data — hashes each one using the same algorithm the target database used, and compares the results to the stolen hashes. Any match is a cracked password. Common passwords like password123, qwerty, iloveyou, and variations of them fall almost instantly.
Rule-based attacks — Tools like Hashcat apply transformation rules to wordlist entries automatically. If the wordlist has "password", the rule engine generates "Password", "p@ssword", "password1", "PASSWORD", "p4ssw0rd", and thousands of other variations without the attacker needing to list them manually. This catches the vast majority of passwords that users think are clever substitutions.
Rainbow table attacks — A rainbow table is a precomputed lookup table mapping hashes back to their plaintext. For unsalted MD5, rainbow tables covering every password up to a certain length have existed for years. An attacker doesn't need to compute anything — they just look up the hash in the table. Lookup is nearly instantaneous.
Brute force — Trying every possible combination systematically. Against weak algorithms like MD5 on modern GPU hardware, this is practical for shorter passwords. A single consumer GPU can compute billions of MD5 hashes per second. An 8-character password using letters and numbers has around 218 trillion combinations — which sounds like a lot until you realize that at three billion hashes per second, it falls in under a day.
Why MD5 and SHA1 Are the Wrong Choice
MD5 and SHA1 were designed to be fast. That's the problem. They were built for checksums and data integrity verification — use cases where you want to hash large files quickly. Fast is exactly the wrong property for a password hashing function.
When an algorithm is fast, an attacker with a GPU can compute billions of hashes per second. Every optimization you can make — better hardware, parallel processing, distributed cracking rigs — directly translates into more passwords cracked per hour.
Here's how stark the difference is in practical terms:
- MD5: roughly 60 billion hashes per second on a high-end GPU
- SHA256: roughly 20 billion hashes per second on the same hardware
- bcrypt (cost factor 12): roughly 10,000 hashes per second on the same hardware
- Argon2id (modern recommended): even slower by design, highly configurable
That difference in speed between MD5 and bcrypt isn't a rounding error. It's six million times slower. An attack that cracks an MD5 database in an hour would take nearly 700 years against the same database hashed with bcrypt at a reasonable cost factor.
The slowness is the feature. Password hashing functions are intentionally designed to be computationally expensive so that cracking at scale becomes impractical.
Salting — Why It Matters Even With Strong Algorithms
A salt is a random value added to each password before hashing. Even if two users have the same password, their hashes will be completely different because each one was combined with a unique random salt before hashing.
Salting defeats rainbow table attacks entirely — precomputed tables become useless because they'd need to account for every possible salt. It also means an attacker can't crack multiple accounts simultaneously — each hash has to be attacked individually with its own salt factored in.
The good news is that modern password hashing functions like bcrypt and Argon2 handle salting automatically. You don't generate or store the salt separately — it's embedded in the resulting hash string.
Implementing Password Hashing Correctly in PHP
PHP has had excellent built-in password hashing since PHP 5.5. There's genuinely no reason to use anything else for web applications.
<?php
// Hashing a password when user registers or changes password
$password = $_POST['password'];
$hash = password_hash($password, PASSWORD_BCRYPT, ['cost' => 12]);
// Store $hash in your database
// It looks something like: $2y$12$someRandomSaltEmbeddedHereAndThenTheActualHash
<?php
// Verifying a password at login
$password = $_POST['password'];
$hash = $row['password']; // retrieved from database
if (password_verify($password, $hash)) {
// Password is correct
// Start session, log user in
} else {
// Password is wrong
// Increment failed attempt counter
}
That's genuinely it. password_hash() generates a unique salt automatically, applies it, runs bcrypt at the specified cost factor, and returns a single string containing everything needed to verify the password later. password_verify() extracts the salt and cost factor from the stored hash and does the comparison correctly.
The cost factor (12 in the example above) controls how expensive the hashing is. Higher values are slower and more resistant to cracking but also take longer at login. A cost factor of 12 produces a hash in roughly 300-400 milliseconds on typical server hardware — imperceptible to a user logging in, but significant when an attacker is trying to compute millions of them per second.
PHP also provides a function to check whether a hash needs to be upgraded:
<?php
// After successful login, check if the hash needs upgrading
if (password_needs_rehash($hash, PASSWORD_BCRYPT, ['cost' => 12])) {
$newHash = password_hash($password, PASSWORD_BCRYPT, ['cost' => 12]);
// Update the stored hash in your database
$stmt = $db->prepare("UPDATE users SET password = ? WHERE id = ?");
$stmt->execute([$newHash, $user_id]);
}
This is useful when you increase the cost factor over time as hardware gets faster. On the next login after you raise the cost, the hash gets silently upgraded without requiring users to reset their passwords.
For New Projects — Consider Argon2
bcrypt is solid and has been the recommendation for years. For new projects in 2026, Argon2id is worth considering as the default. It won a password hashing competition specifically designed to evaluate and select the most robust modern algorithm, and it has advantages over bcrypt — particularly resistance to GPU-based attacks because it's configurable in memory usage, not just CPU time.
<?php
// PHP 7.2+ supports Argon2
$hash = password_hash($password, PASSWORD_ARGON2ID, [
'memory_cost' => 65536, // 64 MB
'time_cost' => 4, // 4 iterations
'threads' => 3
]);
// Verification works identically
if (password_verify($password, $hash)) {
// Correct password
}
The memory cost parameter is what makes Argon2 particularly resistant to GPU cracking. GPUs are powerful because they have thousands of cores running in parallel. High memory requirements mean fewer parallel operations fit in GPU memory simultaneously — reducing the attacker's throughput advantage significantly.
What to Do If You Have Weakly Hashed Passwords
If you've inherited a codebase or discovered old data that was hashed with MD5 or SHA1, here's a practical migration path that doesn't require forcing everyone to reset their password immediately.
<?php
function legacyLogin($username, $password, $db) {
$stmt = $db->prepare("SELECT id, password, hash_type FROM users WHERE username = ?");
$stmt->execute([$username]);
$user = $stmt->fetch();
if (!$user) return false;
$passwordCorrect = false;
if ($user['hash_type'] === 'md5') {
// Old style - verify against MD5 hash
if (hash_equals($user['password'], md5($password))) {
$passwordCorrect = true;
}
} else {
// New style - use proper verification
$passwordCorrect = password_verify($password, $user['password']);
}
if ($passwordCorrect) {
if ($user['hash_type'] === 'md5') {
// Upgrade to bcrypt on successful login
$newHash = password_hash($password, PASSWORD_BCRYPT, ['cost' => 12]);
$db->prepare(
"UPDATE users SET password = ?, hash_type = 'bcrypt' WHERE id = ?"
)->execute([$newHash, $user['id']]);
}
return $user['id'];
}
return false;
}
This approach silently migrates users from the old hashing to bcrypt the next time they log in successfully. Over time, as users log in, the proportion of weakly hashed accounts shrinks. After a reasonable period — three to six months — you can force a password reset for any accounts that still have the old hash type, since those users haven't logged in recently anyway.
Passwords Are Only One Piece
Even the strongest password hashing doesn't fully protect against every scenario. A well-hashed password database takes the catastrophic outcome off the table — attackers get a database of hashes that will take effectively forever to crack. But password security as a whole also involves things like:
Not allowing weak passwords — Check submitted passwords against lists of known common passwords. The Have I Been Pwned API offers a k-anonymity-based lookup that lets you check whether a password appears in known breach data without sending the full password to any external server.
Rate limiting login attempts — We covered this in the API security post, but it applies equally to login forms. Automated credential stuffing attacks try thousands of username/password combinations in rapid succession.
Two-factor authentication — Even a cracked password is useless if the attacker also needs a TOTP code from an authenticator app. For applications handling anything sensitive, 2FA is worth implementing.
Breach notification — Consider monitoring HaveIBeenPwned's notification service for your domain so you know when your users' email addresses appear in new breaches — which may mean their passwords for your service are at risk if they reuse passwords.
Final Thought
Password security is one of those areas where the correct approach is well established, the tools to implement it are built into the language, and there's genuinely no good reason to use the old approaches anymore. The gap between MD5 and bcrypt isn't a matter of debate — it's a six-million-fold difference in cracking resistance on modern hardware.
If you have production code using MD5 or SHA1 for passwords, migrating it is probably the highest-value security improvement you can make today. Start with the silent migration pattern above — it requires minimal changes to your login flow and starts protecting users immediately without disrupting anyone's experience.
Your users are trusting you with their credentials. A few lines of code difference is all it takes to honor that trust properly.
— Skand K.