PHP password_hash and password_verify Tutorial: Secure Registration and Login with PDO and MySQL

Almost every PHP project needs a login page: a capstone system, an inventory app, a school portal, an admin panel. And almost every beginner tutorial from ten years ago still saves passwords with md5(). That is the one part of a login system you really cannot get wrong, because if your database ever leaks, weak password hashes turn into real passwords within minutes.

The good news is that PHP already has everything you need built in. In this PHP password_hash and password_verify tutorial you will learn how the two functions work, why md5() and sha1() are the wrong tool, how to pick a cost and upgrade old hashes with password_needs_rehash(), and finally how to build a small but secure registration and login system with PDO, MySQL and sessions. Every script was run on PHP 8.3 with MariaDB 11.8, and the output shown is copied from the terminal.

Requirements

  • PHP 7.4 or newer (PHP 8.x recommended) with the PDO MySQL extension. XAMPP, Laragon and WAMP all include it.
  • MySQL or MariaDB.
  • Basic PHP: forms, $_POST, if statements and sessions.

The complete code is in the zip file at the bottom of this page.

Hashing Is Not Encryption

Encryption can be reversed if you have the key. A password hash is one-way: you can turn a password into a hash, but you cannot turn the hash back into the password. To check a login, you hash what the user typed and compare. That way your database never stores the real password, so even you, the developer, cannot read it.

A good password hash also has two more properties: it uses a random salt so two users with the same password get different hashes, and it is deliberately slow so attackers cannot try billions of guesses per second. PHP's password_hash() does both for you.

Step 1: password_hash() and password_verify() Basics

Save this as 01_hash_basics.php and run it with php 01_hash_basics.php:

  1. <?php
  2. // 01_hash_basics.php - password_hash() and password_verify() basics
  3. $password = 'MySecret#2026';
  4.  
  5. $hash1 = password_hash($password, PASSWORD_DEFAULT);
  6. $hash2 = password_hash($password, PASSWORD_DEFAULT);
  7.  
  8. echo "Hash 1: $hash1\n";
  9. echo "Hash 2: $hash2\n";
  10. echo "Length: " . strlen($hash1) . " characters\n";
  11. echo "Same hash both times? " . ($hash1 === $hash2 ? 'yes' : 'no') . "\n\n";
  12.  
  13. // password_verify() reads the algorithm, cost and salt from the hash itself
  14. var_dump(password_verify('MySecret#2026', $hash1)); // correct password
  15. var_dump(password_verify('MySecret#2026', $hash2)); // also correct
  16. var_dump(password_verify('mysecret#2026', $hash1)); // wrong case = wrong password
  17.  
  18. echo "\n";
  19. print_r(password_get_info($hash1));

Output (your hashes will be different, which is the point):

Hash 1: $2y$10$PZGKoE3iv5WDFY.1kDtcL..jswXFSL5d19mdfJ8GTrqM1xup6oTF.
Hash 2: $2y$10$vK6GsYmsKSDQwwU3sjRAcObmde9HfM75CpbGPoVGwk2UiuB.qiOHK
Length: 60 characters
Same hash both times? no

bool(true)
bool(true)
bool(false)

Array
(
    [algo] => 2y
    [algoName] => bcrypt
    [options] => Array
        (
            [cost] => 10
        )

)

What the output tells us:

  • The same password produced two different hashes. That is the random salt at work, and it is stored inside the hash itself.
  • The hash starts with $2y$10$. 2y means bcrypt and 10 is the cost. The next 22 characters are the salt and the rest is the actual hash.
  • password_verify() does not need the salt separately. It reads the algorithm, cost and salt from the stored hash, so you only save one column.
  • Passwords are case-sensitive. mysecret#2026 does not match MySecret#2026.

Important: never compare hashes yourself with ==, and never call password_hash() on the login attempt and compare it with the stored value. Because of the random salt it will never match. Always use password_verify().

Step 2: Why md5() and sha1() Are Not Safe for Passwords

Save this as 02_why_not_md5.php:

  1. <?php
  2. // 02_why_not_md5.php - why md5()/sha1() are the wrong tool for passwords
  3. $password = 'password123';
  4.  
  5. echo "md5 : " . md5($password) . "\n";
  6. echo "md5 : " . md5($password) . "  (same input, same output, every time)\n";
  7. echo "sha1: " . sha1($password) . "\n\n";
  8.  
  9. // Count how many hashes each method can make in about 0.2 seconds
  10. function countIn(callable $fn, float $seconds = 0.2): int {
  11.     $n = 0; $end = microtime(true) + $seconds;
  12.     while (microtime(true) < $end) { $fn(); $n++; }
  13.     return $n;
  14. }
  15. $md5 = countIn(fn() => md5('password123'));
  16. $bcrypt = countIn(fn() => password_hash('password123', PASSWORD_DEFAULT));
  17. echo "md5 hashes in 0.2s:    " . ($md5 > 100000 ? 'more than 100,000' : $md5) . "\n";
  18. echo "bcrypt hashes in 0.2s: " . ($bcrypt < 100 ? 'fewer than 100' : $bcrypt) . "\n";

Output:

md5 : 482c811da5d5b4bc6d497ffa98491e38
md5 : 482c811da5d5b4bc6d497ffa98491e38  (same input, same output, every time)
sha1: cbfdac6008f9cab4083784cbd1874f76618d2a97

md5 hashes in 0.2s:    more than 100,000
bcrypt hashes in 0.2s: fewer than 100

Two problems are visible here. First, md5('password123') gives the same result every time and on every website, so attackers simply look the hash up in huge precomputed tables. Second, md5 is extremely fast. On this ordinary machine PHP produced well over a hundred thousand md5 hashes in a fifth of a second, while bcrypt produced fewer than a hundred. For a login form, that delay is unnoticeable. For an attacker guessing millions of passwords, it is a wall.

Step 3: Choosing a Cost and Upgrading Old Hashes

The cost controls how slow bcrypt is. Each step up doubles the work. Save this as 03_cost_and_rehash.php:

  1. <?php
  2. // 03_cost_and_rehash.php - choosing a cost and upgrading old hashes
  3. foreach ([10, 11, 12] as $cost) {
  4.     $start = microtime(true);
  5.     password_hash('test-password', PASSWORD_BCRYPT, ['cost' => $cost]);
  6.     $ms = (microtime(true) - $start) * 1000;
  7.     printf("cost %d: %d ms\n", $cost, $ms); // depends on your CPU
  8. }
  9.  
  10. $oldHash = password_hash('test-password', PASSWORD_BCRYPT, ['cost' => 10]);
  11. $options = ['cost' => 12];
  12.  
  13. echo "\nOld hash: $oldHash\n";
  14. if (password_verify('test-password', $oldHash)
  15.     && password_needs_rehash($oldHash, PASSWORD_DEFAULT, $options)) {
  16.     $newHash = password_hash('test-password', PASSWORD_DEFAULT, $options);
  17.     echo "Rehashed: $newHash\n";
  18.     echo "Needs rehash now? " . (password_needs_rehash($newHash, PASSWORD_DEFAULT, $options) ? 'yes' : 'no') . "\n";
  19. }
  20.  
  21. // bcrypt only uses the first 72 bytes of the password
  22. $long = str_repeat('a', 72);
  23. $h = password_hash($long, PASSWORD_BCRYPT);
  24. echo "\n72 a's + 'XYZ' verifies against hash of 72 a's? ";
  25. var_dump(password_verify($long . 'XYZ', $h));
  26.  
  27. echo "Argon2id available in this PHP build? " . (defined('PASSWORD_ARGON2ID') ? 'yes' : 'no') . "\n";

Output (timings depend on your CPU):

cost 10: 47 ms
cost 11: 95 ms
cost 12: 195 ms

Old hash: $2y$10$ApiW1gsJdvu9VkoBh8wA7.ezgDLhpU5rdcR/EGktQ0LpqqkibKQCO
Rehashed: $2y$12$aO/ZBf0HcAIxrmJrvES0D.ZYqXpgBX61w6hMYUR6jVbD4CiaVK6Fu
Needs rehash now? no

72 a's + 'XYZ' verifies against hash of 72 a's? bool(true)
Argon2id available in this PHP build? no

Notes on this example:

  • The timing doubles with each cost step. A login that takes a few hundred milliseconds or less is fine for users, so many developers aim for roughly 100 to 300 ms on their production server. On PHP 7.4 to 8.3 the default bcrypt cost is 10; PHP 8.4 raised the default to 12.
  • password_needs_rehash() returns true when a stored hash uses an older algorithm or a different cost than you want now. The only moment you know the plain password is right after a successful login, so that is where you rehash and save the new value. We will do exactly that in the login page below.
  • bcrypt only uses the first 72 bytes of a password. Anything after that is ignored, as the last test shows. The simple fix in a normal app is to limit passwords to 72 characters on registration.
  • If your PHP build has Argon2 support, you can use PASSWORD_ARGON2ID. The PHP used for this tutorial does not, and that is fine: bcrypt with PASSWORD_DEFAULT is a solid, widely used choice.

Step 4: Create the Users Table

Now let's build a real registration and login system. Create the database with this schema.sql (import it in phpMyAdmin or run it in the MySQL client):

  1. CREATE DATABASE IF NOT EXISTS secure_login CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
  2. USE secure_login;
  3.  
  4. CREATE TABLE IF NOT EXISTS users (
  5.     id INT AUTO_INCREMENT PRIMARY KEY,
  6.     username VARCHAR(50) NOT NULL UNIQUE,
  7.     password_hash VARCHAR(255) NOT NULL,
  8.     created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
  9. );

The password_hash column is VARCHAR(255). A bcrypt hash is 60 characters today, but the PHP manual recommends 255 so the column still fits if PASSWORD_DEFAULT changes to a longer algorithm in the future.

Step 5: Database Connection and Session Settings

Save this as config.php. Change the database user and password to match your setup (XAMPP uses root with an empty password by default):

  1. <?php
  2. // config.php - database connection and session setup
  3. $host = getenv('DB_HOST') ?: '127.0.0.1';
  4. $port = getenv('DB_PORT') ?: '3306';
  5. $db   = 'secure_login';
  6. $user = getenv('DB_USER') ?: 'root';
  7. $pass = getenv('DB_PASS') ?: '';
  8.  
  9. $pdo = new PDO("mysql:host=$host;port=$port;dbname=$db;charset=utf8mb4", $user, $pass, [
  10.     PDO::ATTR_ERRMODE            => PDO::ERRMODE_EXCEPTION,
  11.     PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
  12. ]);
  13.  
  14.     'cookie_httponly' => true,
  15.     'cookie_samesite' => 'Lax',
  16.     'use_strict_mode' => true,
  17. ]);
  18.  
  19. const HASH_OPTIONS = ['cost' => 12];
  20.  
  21. function e(string $s): string {
  22.     return htmlspecialchars($s, ENT_QUOTES, 'UTF-8');
  23. }

The session options make the session cookie unreadable by JavaScript (cookie_httponly), not sent on cross-site form posts (cookie_samesite), and reject made-up session IDs (use_strict_mode). When your site runs on HTTPS, also add 'cookie_secure' => true. The e() helper escapes output to prevent XSS.

Step 6: The Registration Page

Save this as register.php:

  1. <?php
  2. // register.php - create an account with a hashed password
  3. require 'config.php';
  4.  
  5. $errors = [];
  6. $username = '';
  7.  
  8. if ($_SERVER['REQUEST_METHOD'] === 'POST') {
  9.     $username = trim($_POST['username'] ?? '');
  10.     $password = $_POST['password'] ?? '';
  11.     $confirm  = $_POST['confirm'] ?? '';
  12.  
  13.     if (!preg_match('/^[A-Za-z0-9_]{3,50}$/', $username)) {
  14.         $errors[] = 'Username must be 3-50 letters, numbers or underscores.';
  15.     }
  16.     if (strlen($password) < 8) {
  17.         $errors[] = 'Password must be at least 8 characters.';
  18.     }
  19.     if (strlen($password) > 72) {
  20.         $errors[] = 'Password must be 72 characters or fewer.';
  21.     }
  22.     if ($password !== $confirm) {
  23.         $errors[] = 'Passwords do not match.';
  24.     }
  25.  
  26.     if (!$errors) {
  27.         $stmt = $pdo->prepare('SELECT id FROM users WHERE username = ?');
  28.         $stmt->execute([$username]);
  29.         if ($stmt->fetch()) {
  30.             $errors[] = 'That username is already taken.';
  31.         }
  32.     }
  33.  
  34.     if (!$errors) {
  35.         $hash = password_hash($password, PASSWORD_DEFAULT, HASH_OPTIONS);
  36.         $stmt = $pdo->prepare('INSERT INTO users (username, password_hash) VALUES (?, ?)');
  37.         $stmt->execute([$username, $hash]);
  38.         header('Location: login.php?registered=1');
  39.         exit;
  40.     }
  41. }
  42. ?>
  43. <!doctype html>
  44. <html>
  45. <head><meta charset="utf-8"><title>Register</title></head>
  46. <body>
  47. <h1>Create an Account</h1>
  48. <?php foreach ($errors as $err): ?>
  49.   <p style="color:#b00"><?= e($err) ?></p>
  50. <?php endforeach; ?>
  51. <form method="post">
  52.   <p><label>Username<br><input name="username" value="<?= e($username) ?>" required></label></p>
  53.   <p><label>Password<br><input type="password" name="password" required></label></p>
  54.   <p><label>Confirm password<br><input type="password" name="confirm" required></label></p>
  55.   <button type="submit">Register</button>
  56. </form>
  57. <p>Already have an account? <a href="login.php">Log in</a></p>
  58. </body>
  59. </html>

The registration flow validates the username and password, checks that the username is free with a prepared statement, then stores only the result of password_hash(). The plain password is never written anywhere. After a successful insert we redirect, so refreshing the page does not submit the form twice.

Step 7: The Login Page

Save this as login.php:

  1. <?php
  2. // login.php - verify the password and start a session
  3. require 'config.php';
  4.  
  5. $error = '';
  6.  
  7. if ($_SERVER['REQUEST_METHOD'] === 'POST') {
  8.     $username = trim($_POST['username'] ?? '');
  9.     $password = $_POST['password'] ?? '';
  10.  
  11.     $stmt = $pdo->prepare('SELECT id, username, password_hash FROM users WHERE username = ?');
  12.     $stmt->execute([$username]);
  13.     $user = $stmt->fetch();
  14.  
  15.     if ($user && password_verify($password, $user['password_hash'])) {
  16.         // Upgrade the hash if the algorithm or cost has changed
  17.         if (password_needs_rehash($user['password_hash'], PASSWORD_DEFAULT, HASH_OPTIONS)) {
  18.             $newHash = password_hash($password, PASSWORD_DEFAULT, HASH_OPTIONS);
  19.             $pdo->prepare('UPDATE users SET password_hash = ? WHERE id = ?')
  20.                 ->execute([$newHash, $user['id']]);
  21.         }
  22.  
  23.         session_regenerate_id(true); // prevent session fixation
  24.         $_SESSION['user_id']  = $user['id'];
  25.         $_SESSION['username'] = $user['username'];
  26.         header('Location: dashboard.php');
  27.         exit;
  28.     }
  29.  
  30.     // Same message for unknown user and wrong password
  31.     $error = 'Invalid username or password.';
  32. }
  33. ?>
  34. <!doctype html>
  35. <html>
  36. <head><meta charset="utf-8"><title>Login</title></head>
  37. <body>
  38. <h1>Log In</h1>
  39. <?php if (isset($_GET['registered'])): ?>
  40.   <p style="color:#070">Account created. You can log in now.</p>
  41. <?php endif; ?>
  42. <?php if ($error): ?>
  43.   <p style="color:#b00"><?= e($error) ?></p>
  44. <?php endif; ?>
  45. <form method="post">
  46.   <p><label>Username<br><input name="username" required></label></p>
  47.   <p><label>Password<br><input type="password" name="password" required></label></p>
  48.   <button type="submit">Log in</button>
  49. </form>
  50. <p>No account yet? <a href="register.php">Register</a></p>
  51. </body>
  52. </html>

Four details make this login page safer than most copy-paste examples:

  1. It looks up the user by username only, then checks the password with password_verify(). Never put the password in the WHERE clause.
  2. It shows the same error message for an unknown username and a wrong password, so attackers cannot use the form to discover which usernames exist.
  3. It upgrades old hashes automatically with password_needs_rehash().
  4. It calls session_regenerate_id(true) after login to block session fixation attacks.

Step 8: Protect Pages and Log Out

Any page that requires login just checks the session. Save this as dashboard.php:

  1. <?php
  2. // dashboard.php - members-only page
  3. require 'config.php';
  4.  
  5. if (empty($_SESSION['user_id'])) {
  6.     header('Location: login.php');
  7.     exit;
  8. }
  9. ?>
  10. <!doctype html>
  11. <html>
  12. <head><meta charset="utf-8"><title>Dashboard</title></head>
  13. <body>
  14. <h1>Welcome, <?= e($_SESSION['username']) ?>!</h1>
  15. <p>You are logged in. Only signed-in users can see this page.</p>
  16. <p><a href="logout.php">Log out</a></p>
  17. </body>
  18. </html>

And logout.php clears the session data, deletes the cookie and destroys the session:

  1. <?php
  2. // logout.php - end the session completely
  3. require 'config.php';
  4.  
  5. $_SESSION = [];
  6. if (ini_get('session.use_cookies')) {
  7.     setcookie(session_name(), '', time() - 42000, $p['path'], $p['domain'], $p['secure'], $p['httponly']);
  8. }
  9. header('Location: login.php');

Step 9: Test the Whole Flow

Start the PHP built-in server inside the secure-login folder with php -S localhost:8000 (or put the folder in htdocs) and open http://localhost:8000/register.php. I tested every path with a small curl script, included in the zip as test_login.sh. Here is the result:

1) dashboard as guest:
   302 -> http://localhost:8000/login.php
2) register with short password:
   Password must be at least 8 characters.
3) register juan_dc:
   302 -> http://localhost:8000/login.php?registered=1
4) register juan_dc again:
   That username is already taken.
5) stored row:
   1	juan_dc	$2y$12$	60
6) wrong password:
   Invalid username or password.
7) unknown user:
   Invalid username or password.
8) correct login:
   302 -> http://localhost:8000/dashboard.php
9) dashboard with session:
   <h1>Welcome, juan_dc!</h1>
10) logout then dashboard:
   302 -> http://localhost:8000/login.php
11) old cost-10 account is upgraded on login:
   before: $2y$10$
   after:  $2y$12$

Everything behaves as expected: guests are redirected, weak passwords and duplicate usernames are rejected, the database stores only a 60-character bcrypt hash with cost 12, both kinds of failed login give the same message, and logout really ends the session. Test 11 shows an old cost-10 hash being upgraded to cost 12 the moment that user logs in.

Common Mistakes to Avoid

  • Using md5(), sha1() or hash('sha256') for passwords. They are fine for checksums, not for passwords.
  • Making the column too short. A VARCHAR(32) left over from md5 will cut the hash, and every login will fail.
  • Trimming or changing the password before hashing. Hash exactly what the user typed, and do the same on login.
  • Building SQL with string concatenation. Always use prepared statements, as in this tutorial.
  • Showing "username not found" vs "wrong password". Use one generic message.

Conclusion

Secure password storage in PHP comes down to a handful of built-in functions: password_hash() when a user registers, password_verify() when they log in, and password_needs_rehash() to keep old hashes up to date. Combine them with prepared statements, a regenerated session ID and a generic error message and you have a login system that is far safer than most of the examples still floating around. Download the zip below, run the test script, and drop the pattern into your next capstone or client project.