Mastering PHP Password Hashing: Essential Security Techniques Every Developer Must Know

Hire a PHP developer for your project — click here.

by admin
php_password_hashing_algorithms_explained

Password hashing algorithms explained: Why PHP developers must get this right

I remember the moment it clicked for me. I was debugging a legacy system at 2 AM, coffee going cold beside the keyboard, and I found it: plain-text passwords in the database. Just sitting there. My stomach dropped. That's when I realized that understanding password hashing wasn't some optional advanced topic—it was foundational. It was the difference between a secure application and a liability.

If you're building anything with PHP that involves user authentication, you need to understand password hashing. Not the mathematical nitty-gritty necessarily, but the why, the how, and more importantly, the wrong ways that still haunt the internet.

Let me walk you through this the way I wish someone had explained it to me.

What password hashing really is

A hash function takes any input—a password, a file, a novel—and transforms it into a fixed-length string of characters through a mathematical operation. The magic part: you can't reverse it. Feed the function the same input, and you get the same output every time. Change a single character in the input, and the entire output becomes unrecognizable. It's a one-way trip.

But here's the thing: not all hash functions are created equal. Your typical general-purpose hash functions like MD5 or SHA-1? They're fast. Blazingly fast. That's terrible for passwords.

Why? Because if someone steals your database—and statistically, someone will try—an attacker can run through billions of password guesses per second, hashing each one with MD5 and checking if it matches a stolen hash. It becomes a brute-force lottery, and unfortunately, they often win.

Password hashing algorithms solve this by being intentionally slow. They consume memory. They demand computation time. They make brute-force attacks feel like trying to drain an ocean with a teaspoon.

The algorithms PHP gives you

PHP provides several options for password hashing, and the good news is that the platform makes the secure choice the default.

PASSWORD_DEFAULT and bcrypt are synonymous in modern PHP. Bcrypt uses the CRYPT_BLOWFISH algorithm and has been the industry standard for years. It works beautifully because it incorporates a "cost factor"—a parameter you can tune to make hashing slower as computers get faster. Today, a work factor of 13-14 is recommended, which means hashing takes roughly 250 to 1000 milliseconds on 2025-era servers.

Argon2 exists in two flavors: Argon2i and Argon2id. This is the newer contestant, designed by cryptographers specifically to resist GPU and ASIC attacks. Argon2id is the stronger version. If you're starting a new project today and your server supports it, this is the algorithm I'd reach for. It's genuinely harder to crack, though bcrypt remains solid and widely supported.

PBKDF2 is another option if you need FIPS-140 compliance. It requires a work factor of 600,000 or more with HMAC-SHA-256 as the internal hash function. It's not my first choice for new projects, but it exists in the toolbox.

Let's be clear: MD5, SHA-1, and plain SHA-256 for password storage? Those are archaeological artifacts now. They're fast, which is precisely why they're wrong. I see tutorials still recommending them, and it makes me want to scream into the void.

The PHP way: password_hash() and password_verify()

PHP gives you two functions that handle this correctly. Use them. Don't invent your own system.

<?php
  $plaintext_password = "Password@123";
  
  $hash = password_hash($plaintext_password, PASSWORD_DEFAULT);
  
  echo "Generated hash: " . $hash;
?>

When you call password_hash(), something elegant happens behind the scenes. The function automatically generates a salt—a random value unique to this password—and incorporates it into the hash. You don't manually create salts. You don't hardcode anything. The algorithm identifier gets baked into the hash output itself, so if PHP ever changes the default algorithm in the future, the system still knows how to verify old hashes correctly.

The second parameter is your algorithm choice. Use PASSWORD_DEFAULT for most cases. If you want Argon2, pass PASSWORD_ARGON2ID.

Later, when a user logs in and provides their password, you verify it like this:

<?php
  $stored_hash = get_pwd_from_db($username);
  $attempted_password = $_POST['password'];
  
  if (password_verify($attempted_password, $stored_hash)) {
    echo "Password matches.";
  } else {
    echo "Password incorrect.";
  }
?>

The password_verify() function reads the algorithm and cost parameters from the stored hash and applies the same hashing operation to the attempted password. If both hashes match, the user is authenticated.

This matters: never use === or == for hash comparison in cryptographic contexts. Those operators short-circuit, leaking timing information that attackers can exploit. PHP's password_verify() uses constant-time comparison internally. Trust it.

Database storage: The practical details

You need to allocate space. Hash outputs vary in length depending on the algorithm you choose. Argon2id produces longer hashes than bcrypt. Be safe: allocate at least 255 characters in your database column for password hashes. This gives you room to upgrade algorithms in the future without scrambling.

ALTER TABLE users MODIFY COLUMN password_hash VARCHAR(255);

It's a small thing, but I've seen developers get burned by undersizing this field.

The salt: Automatic, but essential

Salts exist for one reason: to prevent rainbow tables. A rainbow table is a pre-computed database of hashes. Someone generates millions of hashes for common passwords and stores them. If two users on different sites both use the password "password123," without salts, the hashes would be identical. With a unique salt per password, the hashes look completely different even though the input password is the same.

See also
Mastering the Art of Hiring: How Savvy Companies Find Top PHP Developers Online

Here's the relief: password_hash() generates and includes the salt automatically. You don't manage it. You don't store it separately. It's embedded in the hash string itself, and password_verify() extracts it automatically when verifying.

This is one area where PHP absolutely nails the usability-meets-security balance.

The pepper: Defense in depth

There's something deeper you should know. Since 2017, NIST recommends mixing in a pepper—a secret value stored separately from the database. Unlike the salt, which is unique per password, the pepper is identical for all users and lives in a configuration file, not in the database.

Here's why this matters: if an attacker breaches your database but not your server's filesystem, they still can't crack the hashes. They'd have to guess the pepper too. With a randomly generated 128-bit pepper, this becomes computationally impossible, even with known passwords.

The implementation uses hash_hmac():

<?php
  $pepper = getConfigVariable("pepper");
  $password = $_POST['password'];
  
  $peppered_password = hash_hmac("sha256", $password, $pepper);
  
  $hash = password_hash($peppered_password, PASSWORD_ARGON2ID);
  
  // Store $hash in the database
?>

And during login:

<?php
  $pepper = getConfigVariable("pepper");
  $attempted_password = $_POST['password'];
  $stored_hash = get_pwd_from_db($username);
  
  $peppered_password = hash_hmac("sha256", $attempted_password, $pepper);
  
  if (password_verify($peppered_password, $stored_hash)) {
    echo "Password matches.";
  }
?>

A pepper isn't mandatory, but it's a relatively easy win. I think of it as insurance. The downside is minimal; the upside is substantial.

Cost factors and tuning

Different algorithms accept different parameters. Bcrypt takes a "cost" parameter; Argon2 takes time and memory parameters. These exist because you want hashing to be slow—slow enough to deter brute-force, but not so slow that legitimate login attempts frustrate users.

For bcrypt, a cost of 10 is the older standard, but 13-14 is recommended for 2025. Each increment roughly doubles the hashing time. On a modern server, cost 14 might take a second to hash a password. That's acceptable for registration or login—users wait a moment. But that same second becomes a nightmare for an attacker trying billions of combinations.

You can set the cost when calling password_hash():

<?php
  $options = [
    'cost' => 13,
  ];
  
  $hash = password_hash($password, PASSWORD_BCRYPT, $options);
?>

Monitor your actual hashing times in production. If they start creeping down—if your servers got upgraded and passwords are hashing faster than you expected—consider bumping the cost factor.

Migration and future-proofing

What happens if you want to upgrade from bcrypt to Argon2? Or increase bcrypt's cost factor?

PHP gives you password_needs_rehash(), which checks whether a stored hash uses the current algorithm and cost parameters. You can run this check on every login:

<?php
  if (password_verify($password, $hash)) {
    if (password_needs_rehash($hash, PASSWORD_ARGON2ID, ['cost' => 13])) {
      $new_hash = password_hash($password, PASSWORD_ARGON2ID, ['cost' => 13]);
      update_user_password($username, $new_hash);
    }
    
    // User is authenticated
  }
?>

This way, old passwords gradually migrate to the new algorithm as users log in. You're not forced to rehash everyone's passwords all at once.

The mistakes that still happen

I've seen them all. Developers storing passwords in application logs before hashing. Developers using timestamps or sequential numbers as salts, thinking randomness doesn't matter that much. Custom hashing functions that layer MD5 on top of SHA-256, believing redundancy equals security.

It doesn't.

The most dangerous mistake? Trusting outdated tutorials. The internet is full of articles from 2012 and 2015 still recommending SHA-1 or crypt() with manual salting. I've found production systems running on these approaches.

Here's my rule: if you're not using password_hash() and password_verify(), you're almost certainly doing it wrong. Don't invent. Don't optimize. Don't believe you're the special developer who understands cryptography better than the PHP core team.

Timing attacks and constant-time comparison

One last thing that deserves attention. Never compare hashes using standard string equality:

// WRONG
if ($provided_hash == $stored_hash) { ... }

// WRONG
if ($provided_hash === $stored_hash) { ... }

These operators short-circuit. They return false the moment they find a mismatch. An attacker can measure the time it takes for the comparison to fail. If a hash fails on the first character versus the tenth character, that timing difference—microseconds as it may be—leaks information.

This is why password_verify() is essential. It uses constant-time comparison internally, always examining the entire hash regardless of mismatches.

The philosophy behind all this

I think about password hashing differently now. It's not about locking users out of their accounts if they forget. It's about responsibility. Your users trust you with their credentials. They use the same password on multiple sites—I know they do, even though security experts beg them not to. When your database leaks, their other accounts become vulnerable.

Every millisecond you add to the hashing cost, every layer of defense like a pepper, every thoughtful parameter you tune—that's respect for the trust people place in your application.

PHP makes this remarkably simple. The framework gives you the tools. No excuses exist anymore for storing passwords insecurely.

Use password_hash() and password_verify(). Allocate 255 characters for the hash. Consider a pepper. Monitor cost factors. That's it. That's the foundation of secure password handling in PHP.

The hard part isn't understanding the cryptography. The hard part is remembering to care when you're tired, when deadlines loom, when shipping features feels more urgent than security details.

But that moment when a user thanks you for taking their account security seriously—or more commonly, the moment when you successfully defend against a breach attempt because you did things right—that's when it becomes worth it.
перейти в рейтинг

Related offers