Password Hashing

JA JASWANT Updated 19 Oct 2026
6 min read ·Lesson 117 of 143
In this post

Why Passwords Must Be Hashed In simple terms: A website must never store users' passwords as plain text. If the database is ever stolen — through an attack, a leaked backup, or a careless employee — e

Why Passwords Must Be Hashed

In simple terms: A website must never store users' passwords as plain text. If the database is ever stolen — through an attack, a leaked backup, or a careless employee — every password would be exposed, and because many people reuse passwords, attackers could log into their email and bank accounts too. Instead, the site stores a hash: a scrambled fingerprint of the password from which the password cannot be recovered. At login, the entered password is hashed the same way and compared.

Technically: Password hashing uses deliberately slow, salted one-way functions designed for passwords — bcrypt, Argon2id, scrypt. A unique random salt per password ensures that identical passwords produce different hashes and defeats precomputed "rainbow tables". The work factor (cost) makes each guess expensive, slowing down brute-force attacks on stolen hashes. PHP's password_hash(), password_verify(), and password_needs_rehash() implement all of this correctly.

What Not to Use

MethodWhy It Is Unsafe
Plain textEvery password is immediately exposed
base64_encode(), encryptionReversible — whoever has the key (or no key, for base64) gets the passwords
md5(), sha1(), hash('sha256', ...)Extremely fast: modern GPUs test billions of guesses per second; unsalted versions are also vulnerable to rainbow tables
Home-made schemes (md5($salt . $password))Still fast, often subtly flawed

password_hash() and password_verify()

<?php
$password = 'correct horse battery staple';

$hash = password_hash($password, PASSWORD_DEFAULT);
$secondHash = password_hash($password, PASSWORD_DEFAULT);

echo strlen($hash) . " characters, starts with " . substr($hash, 0, 7) . "\n";
var_dump($hash !== $secondHash);

var_dump(password_verify('correct horse battery staple', $hash));
var_dump(password_verify('Correct horse battery staple', $hash));

print_r(password_get_info($hash));

Output (PHP 8.4+; the hash itself is different every time)

60 characters, starts with $2y$12$
bool(true)
bool(true)
bool(false)
Array
(
    [algo] => 2y
    [algoName] => bcrypt
    [options] => Array
        (
            [cost] => 12
        )

)

How It Works

  1. password_hash() generates a random salt, hashes the password with the selected algorithm, and returns a string containing everything needed for verification: the algorithm ($2y$ = bcrypt), the cost (12), the salt, and the hash.
  2. Hashing the same password twice gives different results, because each hash has its own salt.
  3. password_verify() reads the algorithm, cost, and salt from the stored hash, hashes the entered password the same way, and compares the results in constant time. It returns true only for the exact password (passwords are case-sensitive).
  4. password_get_info() describes a stored hash.

PASSWORD_DEFAULT currently means bcrypt; PHP 8.4 raised its default cost from 10 to 12. Because the default may change in future PHP versions, store hashes in a column that can hold at least 255 characters (VARCHAR(255)).

Choosing the Algorithm

ConstantAlgorithmNotes
PASSWORD_DEFAULTbcrypt (today)Recommended; follows PHP's best choice over time
PASSWORD_BCRYPTbcryptUses only the first 72 bytes of the password
PASSWORD_ARGON2IDArgon2idMemory-hard; recommended by OWASP; available when PHP is built with Argon2 or libsodium support
$hash = password_hash($password, PASSWORD_ARGON2ID);
// $argon2id$v=19$m=65536,t=4,p=1$...

The cost should make one hash take very roughly 100–500 ms on your server: slow enough to hinder attackers, fast enough for users. Test on your hardware before raising it.


Upgrading Hashes Over Time: password_needs_rehash()

When the algorithm or cost changes, existing hashes should be upgraded. This can only happen at login, when the plain password is available:

<?php
$password = 'correct horse battery staple';

$oldHash = password_hash($password, PASSWORD_BCRYPT, ['cost' => 10]);   // created years ago

if (password_verify($password, $oldHash) && password_needs_rehash($oldHash, PASSWORD_DEFAULT, ['cost' => 12])) {
    $newHash = password_hash($password, PASSWORD_DEFAULT, ['cost' => 12]);
    echo "Upgraded: " . substr($oldHash, 0, 7) . " → " . substr($newHash, 0, 7) . "\n";
}

Output

Upgraded: $2y$10$ → $2y$12$

After a successful login, the application checks whether the stored hash still matches the current settings and, if not, stores a fresh hash. Over time, all active accounts migrate automatically.


Practical Example: Registration and Login

<?php
declare(strict_types=1);

final class AuthService
{
    private array $users = [];   // email => password hash (a database table in real code)

    public function register(string $email, string $password): string
    {
        $email = strtolower(trim($email));

        if (filter_var($email, FILTER_VALIDATE_EMAIL) === false) {
            return 'Please enter a valid email address.';
        }
        if (mb_strlen($password) < 8) {
            return 'Use at least 8 characters for your password.';
        }
        if (isset($this->users[$email])) {
            return 'This email is already registered.';
        }

        $this->users[$email] = password_hash($password, PASSWORD_DEFAULT);
        return 'Account created.';
    }

    public function login(string $email, string $password): bool
    {
        $email = strtolower(trim($email));
        $hash = $this->users[$email] ?? null;

        if ($hash === null) {
            password_verify($password, self::dummyHash());   // keep timing similar
            return false;
        }

        if (!password_verify($password, $hash)) {
            return false;
        }

        if (password_needs_rehash($hash, PASSWORD_DEFAULT)) {
            $this->users[$email] = password_hash($password, PASSWORD_DEFAULT);
        }

        return true;
    }

    private static function dummyHash(): string
    {
        static $hash = null;
        return $hash ??= password_hash('dummy-password-for-timing', PASSWORD_DEFAULT);
    }
}

$auth = new AuthService();

echo $auth->register('Asha@Example.com', 'short') . "\n";
echo $auth->register('Asha@Example.com', 'correct horse battery staple') . "\n";

foreach ([['asha@example.com', 'correct horse battery staple'], ['asha@example.com', 'wrong'], ['nobody@example.com', 'whatever']] as [$email, $password]) {
    echo $auth->login($email, $password) ? "Login OK for $email\n" : "Invalid email or password.\n";
}

Output

Use at least 8 characters for your password.
Account created.
Login OK for asha@example.com
Invalid email or password.
Invalid email or password.

Step-by-Step Explanation

  1. Registration normalizes the email, enforces a minimum length, and stores only the hash.
  2. Login looks up the hash and verifies the password; on success, it upgrades outdated hashes.
  3. For unknown emails, a dummy hash (created once with the same settings as real hashes) is verified anyway, so the response time is similar to a wrong password for a real account. This makes it harder to discover which emails are registered by measuring response times.
  4. Both "unknown email" and "wrong password" return the same message, for the same reason.

In a real application, users are stored in the database (with a UNIQUE email index), the session ID is regenerated after login (see Session Security), and failed attempts are rate-limited.


Password Policies

Modern guidance (such as NIST SP 800-63B) recommends:

DoAvoid
Require a minimum length (at least 8 characters; longer is better)Forced composition rules ("one symbol, one digit")
Allow long passwords and passphrases (at least 64 characters)Silently truncating passwords
Allow all characters, including spaces and UnicodePeriodic forced password changes without a reason
Check new passwords against lists of breached passwordsPassword hints or security questions
Rate-limit login attempts and offer multi-factor authenticationTelling users which part of the login was wrong

Breached-password checks can be done with services such as Have I Been Pwned using its k-anonymity API, which never receives the full password or its full hash.


Common Mistakes

Using Fast Hash Functions

Effect: Stolen hashes are cracked quickly.

Fix: Use password_hash().

Comparing Hashes Yourself

Effect: password_hash($input) === $storedHash is always false (different salts), and manual comparisons can leak timing information.

Fix: Always use password_verify().

Too-Short Database Columns

Effect: Hashes are truncated and logins fail after algorithm changes.

Fix: Use VARCHAR(255).

Revealing Whether an Account Exists

Effect: Attackers can enumerate registered emails.

Fix: Use identical messages and similar timing for all login failures.

Logging or Emailing Passwords

Effect: Plain-text passwords end up in logs and inboxes.

Fix: Never log passwords; send password-reset links, never passwords.


Best Practices

  • Hash passwords with password_hash($password, PASSWORD_DEFAULT) (or Argon2id) and verify with password_verify().
  • Store hashes in VARCHAR(255) columns and upgrade them with password_needs_rehash() at login.
  • Enforce sensible length-based policies and check against breached passwords.
  • Use identical error messages, rate limiting, and multi-factor authentication for logins.
  • Never store, log, or transmit passwords in plain text.

Official reference: https://www.php.net/manual/en/faq.passwords.php

0 Comments

Reviewed before they appear

No comments yet.

PHP
PHP Home PHP Switch PHP Match Alternative Syntax PHP Loops While Loop Do While Loop For Loop Foreach Loop Nested Loops Break Statement PHP Home PHP Intro PHP Install PHP Built-in Web Server PHP Syntax PHP Tags PHP and HTML PHP Comments PHP Multiline Comments PHP Variables Variables Scope PHP Casting Logical Operators Null Coalescing Operator Spaceship Operator Operator Precedence Continue Statement Modify Strings PHP If...Else...Elseif PHP If Modify Strings PHP If Operators PHP Functions Concatenate Strings PHP If...Else Slicing Strings User-defined Functions Slicing Strings PHP Shorthand if Escape Characters Default Argument Values PHP Nested if Pass by Reference String Interpolation Named Arguments PHP File Upload Variadic Functions Return Values Search in Strings Multibyte Strings Type Declarations in Functions PHP Numbers PHP Math PHP Floats PHP Random Numbers PHP Constants PHP Properties PHP Methods PHP Constructor Constructor Property Promotion PHP Cookies PHP Destructor PHP Access Modifiers Readonly Properties PHP Sessions PHP Inheritance Method Overriding PHP Filters The final Keyword CSRF Protection PHP Filters Advanced Session Security Secure Cookies PHP Magic Constants File Upload Security PHP Callback Functions PHP Operators Filesystem Security PHP JSON Arithmetic Operators Error Reporting in Production Hiding PHP Assignment Operators PHP Configuration (php.ini) Comparison Operators PHP Command Line (CLI) PHP Exceptions Increment / Decrement Operators Migrating to PHP 8 PHP Form Validation PHP Form Required PHP Form URL/E-mail Error Handling Form Input Sanitizing Escaping Output (htmlspecialchars) Custom Error Handler Checkbox, Radio and Select Inputs Sticky Forms PHP Form Complete PHP Date and Time Date Formatting Error Logging HTTP Headers and Redirects Output Buffering Sending Email PHP cURL Consuming REST APIs PHP Generators AJAX PHP AJAX Database AJAX XML AJAX Live Search AJAX Poll AJAX with JSON Security Introduction Password Hashing SQL Injection Prevention XSS Prevention Garbage Collection Project: Simple Blog PHP What is OOP PHP Classes/Objects The $this Keyword Access Array Items Update Array Items Add Array Items Remove Array Items Sorting Arrays Multidimensional Arrays Array Destructuring Array Spread Operator Search in Arrays Merge Arrays PHP Abstract Classes PHP Interfaces Polymorphism PHP Traits PHP Static Methods PHP Static Properties Late Static Binding Magic Methods Object Cloning
Ask about this post
AI Ask about this post

Ask questions about Password Hashing and get answers drawn from it.

Signed-in readers only.