Building a Login System in PHP

Build secure registration and login: hashing passwords with password_hash, checking them with password_verify, sessions, protecting pages, logging out and limiting login attempts.

Lesson 23 of 86Advanced17 min read

Almost every web application needs accounts: a shop remembers your orders, a forum shows your posts, an admin panel lets only staff in. Building a login system is a rite of passage for PHP developers, and it's also one of the places where small mistakes cause the biggest damage. Leaked password databases have exposed millions of people, mostly because passwords were stored badly.

PHP makes the hard parts simple. password_hash() turns a password into a secure, salted hash using a slow algorithm designed for passwords, and password_verify() checks a login attempt against it. You never store, and never need to know, anyone's actual password.

In this lesson you'll build registration, login and logout with sessions, protect private pages, and add the protections real sites use.

Storing passwords: hashes, not passwords

SQL
CREATE TABLE users (
  id INT AUTO_INCREMENT PRIMARY KEY,
  email VARCHAR(255) NOT NULL UNIQUE,
  password_hash VARCHAR(255) NOT NULL,
  created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
  • password_hash($password, PASSWORD_DEFAULT) returns a string like $2y$10$... containing the algorithm, a random salt and the hash. Two users with the same password get completely different hashes.
  • The hash is one-way: it can't be turned back into the password.
  • The algorithm is deliberately slow, so an attacker who steals the database can only test a tiny number of guesses per second.
  • Store it in a VARCHAR(255) column, because future algorithms may produce longer hashes.

Logging in

To log someone in:

  1. Find the user by email with a prepared statement.
  2. Check the password with password_verify($typedPassword, $user['password_hash']).
  3. If it matches, call session_regenerate_id(true), then store the user's id in $_SESSION.
  4. If it doesn't, show one vague message, "Email or password is incorrect", without saying which part was wrong.

Regenerating the session id at login prevents session fixation, an attack where someone plants a known session id in a victim's browser before they log in. The vague message stops attackers discovering which emails have accounts.

Protecting pages and logging out

Write a small helper that every private page calls at the top:

PHP
function require_login(): void {
    if (empty($_SESSION['user_id'])) {
        header('Location: login.php');
        exit;
    }
}

To log out, clear the session array, delete the session cookie and destroy the session. Do it with a POST form, so another site can't log your users out with a simple link or image.

After a successful login, check password_needs_rehash(). If PHP's recommended algorithm has changed since the hash was created, re-hash the password while you have it and update the row. Users' security improves without them doing anything.

Slowing down guessing

Attackers try thousands of common passwords against login forms. Simple defences:

  • Count failed attempts per email and per IP address, for example in a login_attempts table.
  • After 5 failures in 15 minutes, refuse further attempts for a while, or require a CAPTCHA.
  • Never reveal which part was wrong, and log suspicious activity.

For extra safety on important accounts, add two-factor authentication, such as a code from an authenticator app, which frameworks and libraries make easy to add.

PHP Login Syntax

Authentication functions

$hash = password_hash($password, PASSWORD_DEFAULT);
password_verify($typed, $hash);            // true or false
password_needs_rehash($hash, PASSWORD_DEFAULT);
session_start();
session_regenerate_id(true);
$_SESSION['user_id'] = $user['id'];
$_SESSION = [];  session_destroy();

PHP Login Summary

  • Store only password_hash() results, never plain passwords or reversible encryption.
  • Check logins with password_verify(), then call session_regenerate_id(true).
  • Keep the user id in $_SESSION and protect private pages with a require_login() helper.
  • Give one vague error for a wrong email or password, and limit repeated failed attempts.
  • Log out with a POST form that destroys the session; rehash with password_needs_rehash when needed.

PHP Login Examples

Each example shows the code, the output it produces and what each part does.

Example 1

Hashing and verifying

PHP
<?php
$password = 'correct horse battery staple';
$hash1 = password_hash($password, PASSWORD_DEFAULT);
$hash2 = password_hash($password, PASSWORD_DEFAULT);

echo substr($hash1, 0, 4) . "... (" . strlen($hash1) . " characters)\n";
echo $hash1 === $hash2 ? "Same hash\n" : "Different hashes\n";
var_dump(password_verify('correct horse battery staple', $hash1));
var_dump(password_verify('Correct horse battery staple', $hash1));
Output
$2y$... (60 characters)
Different hashes
bool(true)
bool(false)
Line-by-line explanation
  1. Lines 3 to 4Hashing the same password twice gives different results, because each hash has its own random salt.
  2. Line 6$2y$ means bcrypt, PHP's current default algorithm. Its hashes are 60 characters long and also record the cost: how slow each check is.
  3. Lines 8 to 9password_verify reads the salt from the hash and checks the attempt. Passwords are case-sensitive.
Example 2

Registration

PHP
<?php
// register.php
require __DIR__ . '/db.php';
$errors = [];
$email = strtolower(trim($_POST['email'] ?? ''));

if ($_SERVER['REQUEST_METHOD'] === 'POST') {
    $password = $_POST['password'] ?? '';
    if (!filter_var($email, FILTER_VALIDATE_EMAIL)) $errors[] = 'Enter a valid email.';
    if (strlen($password) < 10) $errors[] = 'Use at least 10 characters for your password.';

    if (!$errors) {
        try {
            $stmt = $pdo->prepare('INSERT INTO users (email, password_hash) VALUES (?, ?)');
            $stmt->execute([$email, password_hash($password, PASSWORD_DEFAULT)]);
            session_regenerate_id(true);
            $_SESSION['user_id'] = (int)$pdo->lastInsertId();
            header('Location: account.php');
            exit;
        } catch (PDOException $e) {
            if ($e->errorInfo[1] === 1062) {
                $errors[] = 'An account with that email already exists.';
            } else {
                throw $e;
            }
        }
    }
}
Output
"sam@example" + "short"          -> Enter a valid email. / Use at least 10 characters...
"sam@example.com" + "a-long-pass" -> account created, signed in, redirected to account.php
same email again                 -> An account with that email already exists.
Line-by-line explanation
  1. Line 5Emails are trimmed and lower-cased so Sam@Example.com and sam@example.com are the same account.
  2. Line 10Longer passwords matter more than complicated rules.
  3. Lines 14 to 15Only the hash is stored, never the password itself.
  4. Lines 16 to 17Sign the new user in straight away, with a fresh session id.
  5. Lines 20 to 21MySQL error 1062 means the UNIQUE email already exists.
Example 3

Login, protected page and logout

PHP
<?php
// login.php
require __DIR__ . '/db.php';
$error = null;

if ($_SERVER['REQUEST_METHOD'] === 'POST') {
    $email = strtolower(trim($_POST['email'] ?? ''));
    $stmt = $pdo->prepare('SELECT id, password_hash FROM users WHERE email = ?');
    $stmt->execute([$email]);
    $user = $stmt->fetch();

    if ($user && password_verify($_POST['password'] ?? '', $user['password_hash'])) {
        session_regenerate_id(true);
        $_SESSION['user_id'] = $user['id'];
        if (password_needs_rehash($user['password_hash'], PASSWORD_DEFAULT)) {
            $pdo->prepare('UPDATE users SET password_hash = ? WHERE id = ?')
                ->execute([password_hash($_POST['password'], PASSWORD_DEFAULT), $user['id']]);
        }
        header('Location: account.php');
        exit;
    }
    $error = 'Email or password is incorrect.';
}

// logout.php (reached by a POST form)
$_SESSION = [];
setcookie(session_name(), '', time() - 3600, '/');
session_destroy();
header('Location: login.php');
exit;
Output
Correct details   -> new session id, user_id stored, redirected to account.php
Wrong password    -> "Email or password is incorrect."
Unknown email     -> "Email or password is incorrect."  (same message)
Logout            -> session emptied, cookie removed, back to login.php
Line-by-line explanation
  1. Lines 8 to 10Look the user up by email with a prepared statement.
  2. Line 12Both the user must exist and the password must verify.
  3. Line 13A fresh session id prevents session fixation.
  4. Lines 15 to 18Upgrade old hashes silently while the password is known.
  5. Line 22One message for every failure, so attackers can't test which emails exist.
  6. Lines 26 to 28Logging out empties the session, deletes its cookie and destroys it on the server.

Common PHP Login Mistakes

Storing passwords with md5 or sha1

Don't
$hash = md5($password);       // fast and unsalted: cracked in seconds
Do
$hash = password_hash($password, PASSWORD_DEFAULT);

Comparing hashes yourself

Don't
if (password_hash($typed, PASSWORD_DEFAULT) === $user['password_hash']) { ... }   // never equal
Do
if (password_verify($typed, $user['password_hash'])) { ... }

Revealing which part was wrong

Don't
$error = $user ? 'Wrong password.' : 'No account with that email.';
Do
$error = 'Email or password is incorrect.';

PHP Login in Real Projects

Logins in practice

Every serious PHP application, from WordPress to Laravel apps, hashes passwords, regenerates session ids on login and rate-limits login forms. Password resets and two-factor codes build on the same foundations.

PHP Login FAQ

Which algorithm does PASSWORD_DEFAULT use?

Currently bcrypt. PHP may change the default to a stronger algorithm in a future version, which is why the hash includes the algorithm name and why password_needs_rehash() exists. PASSWORD_ARGON2ID is also available when PHP is built with Argon2 support.

How do I build a "forgot password" feature?

Generate a long random token with random_bytes, store a hash of it with the user ID and a short expiry, such as one hour, and email a link containing the token. When the link is used, check the hash and expiry, let the user set a new password, and delete the token so it works only once.

Is there a maximum password length?

Allow long passwords, including passphrases, and don't impose a small maximum. Be aware that bcrypt only uses the first 72 bytes of a password, so very long passwords beyond that add no extra protection; Argon2id doesn't have this limit. A sensible upper limit, such as a few hundred characters, prevents abuse.

What makes a good password rule?

Require a minimum length, such as 8 to 12 characters, allow every character including spaces, and reject passwords known from data breaches or very common lists. Forced mixtures of symbols and regular expiry tend to produce predictable passwords, so current guidance favours length and breach checks instead.

How long should a login session last?

It depends on the risk. Shops and forums often keep people logged in for days with a "remember me" option, while banking and admin areas log out after a few minutes of inactivity. Always regenerate the session ID at login and ask for the password again before sensitive changes, such as changing the email address.

PHP Login Exercises

Answer each one and check it instantly. If you get stuck, open the hint before the answer.

Fill in the blankseasy

Complete the line that checks a login attempt against the stored hash.

if (($typed, $user['password_hash'])) {
    echo "Welcome back";
}
Multiple choiceeasy

What should be stored in the database for each user's password?

Multiple choicemedium

Why call session_regenerate_id(true) after logging in?

Predict the outputmedium

You hash "pa55word" twice with password_hash. Are the two hashes equal?

Multiple choicehard

Why show the same error for an unknown email and a wrong password?

PHP Login Quiz

5 questions to check what you learned.

  1. Which function creates a password hash?

    • md5
    • hash_password
    • password_hash
    • crypt_password
  2. How long should the password_hash column be?

    • 32 characters
    • 60 characters exactly
    • 10 characters
    • VARCHAR(255), to allow for future algorithms
  3. What does password_needs_rehash tell you?

    • The password is wrong
    • The hash was made with older settings and should be updated
    • The user must change their password
    • The session expired
  4. How should the logout action be triggered?

    • A POST form
    • A plain link
    • Closing the browser
    • A GET request with the user id
  5. Why are password-hashing algorithms deliberately slow?

    • To annoy users
    • Because servers are slow
    • To save storage
    • So attackers who steal the database can test only a few guesses per second