Skip to content
Blog

Username and Password Login in PHP: Safe Basics

By Published A short read

A safe username and password login in PHP looks the user up with a PDO prepared statement and checks the password with password_verify() against a hash made by password_hash(). Never store or compare plain passwords. On a match, call session_regenerate_id(true) and keep the user's ID in $_SESSION. On a miss, show one generic error.

In this post we rebuild the login form from our 2022 post, which had the password written into the code and a check that ran on every page load. The new version is still small, three PHP files and a SQLite database, but it handles passwords, SQL, sessions and forged forms the way the PHP manual and OWASP recommend. It's a learning example: the end of the post lists what a real site still needs.

Key takeaways

  • Store password_hash($password, PASSWORD_DEFAULT), never the password itself, and check it with password_verify().
  • Put user input into SQL only through a prepared statement with ? placeholders. Then it's data, and it can't change the query.
  • After a successful login, session_regenerate_id(true) gives the user a new session ID, so an ID planted before login is worthless.
  • Check $_SERVER['REQUEST_METHOD'] === 'POST' to know the form was sent (the 2022 check was always true), and add a CSRF token to the form.
  • Say "Incorrect username or password" for a wrong username and for a wrong password alike, and escape anything you print with htmlspecialchars().

What was wrong with the 2022 version

Here's the PHP from the original post, unchanged:

<?php

$username = isset($_POST['username']) ? $_POST['username'] : '';
$password = isset($_POST['password']) ? $_POST['password'] : '';
$submitBtn = isset($_POST['submitBtn']);

$realUsername = 'admin';
$realPassword = 'admin';

if (isset($submitBtn)) {
    if ($username == '' || $password == '') {
        echo '<p>Please enter a username and password</p>';
    } else {
        if ($username == $realUsername && $password == $realPassword) {
            echo '<p>You are logged in!</p>';
        } else {
            echo '<p>Incorrect username or password</p>';
        }
    }
}

?>

Four problems:

  • The password is in the code. Anyone who can read the file, or the Git history, has it. And admin/admin is an easy guess.
  • isset($submitBtn) is always true. $submitBtn already holds true or false, and isset() only says no to a variable that's missing or null. So the check runs on every visit: just opening the page prints "Please enter a username and password".
  • == compares loosely. When both sides look like numbers, PHP compares them as numbers, so '1e3' == '1000' is true. A password check needs an exact comparison, and password_verify() does that for us.
  • Nothing remembers the login. There's no session, so the very next page has no idea who you are.

The plan: three files and a database

login-demo/
  setup.php          adds a user to users.db (run it from a terminal)
  users.db           the database, outside public/ so nobody can download it
  public/            the only folder the web server shows
    login.php        the form, and the check when it's sent
    welcome.php      a page only logged-in users can see

We use SQLite because there's nothing to install: SQLite keeps the whole database in one file, and PDO talks to it. Check that php -m lists pdo_sqlite (the PHP 8.2.12 in our XAMPP does). The same PDO calls (prepare, execute, fetch) work with MySQL too; only the connection line and the CREATE TABLE change.

Step 1: add a user (setup.php)

<?php
// Run it from a terminal, in the login-demo folder: php setup.php
$db = new PDO('sqlite:' . __DIR__ . '/users.db');
$db->exec('CREATE TABLE IF NOT EXISTS users (
  id INTEGER PRIMARY KEY,
  username TEXT NOT NULL UNIQUE,
  password_hash TEXT NOT NULL
)');

echo 'New username: ';
$username = trim(fgets(STDIN));
echo 'Password: ';
$password = rtrim(fgets(STDIN), "\r\n");

$stmt = $db->prepare('INSERT INTO users (username, password_hash) VALUES (?, ?)');
$stmt->execute([$username, password_hash($password, PASSWORD_DEFAULT)]);
echo "Added $username\n";
$ php setup.php
New username: asha
Password: correct-horse-42
Added asha
  • The password is typed in, not written into the code, and only its hash is saved.
  • password_hash() runs a deliberately slow one-way function (bcrypt today) with a random salt, so the same password practically never gives the same hash twice. What's stored looks like $2y$10$ followed by 53 more characters: 2y is bcrypt and 10 is the cost. PHP 8.4 raised the default cost to 12.
  • PASSWORD_DEFAULT may switch to a stronger algorithm in a later PHP, and the PHP manual suggests a column that can hold 255 characters. SQLite's TEXT has no limit; in MySQL, use VARCHAR(255).
  • Run it again to add more users. A name that's already taken stops with a UNIQUE constraint failed error, because of UNIQUE on the column.

Step 2: the login page (public/login.php)

<?php
// A learning example: a small, safe login. Run it with: php -S localhost:8000 -t public
session_start(['use_strict_mode' => true, 'cookie_httponly' => true, 'cookie_samesite' => 'Lax']);

function post(string $key): string
{
    $value = $_POST[$key] ?? '';
    return is_string($value) ? $value : '';
}

if (empty($_SESSION['csrf'])) {
    $_SESSION['csrf'] = bin2hex(random_bytes(32));
}

$message = '';

if ($_SERVER['REQUEST_METHOD'] === 'POST') {
    $username = trim(post('username'));
    $password = post('password');

    if (!hash_equals($_SESSION['csrf'], post('csrf'))) {
        http_response_code(400);
        $message = 'Form expired. Please try again.';
    } elseif ($username === '' || $password === '') {
        $message = 'Please enter a username and password.';
    } else {
        $db = new PDO('sqlite:' . __DIR__ . '/../users.db');
        $stmt = $db->prepare('SELECT id, password_hash FROM users WHERE username = ?');
        $stmt->execute([$username]);
        $user = $stmt->fetch(PDO::FETCH_ASSOC);

        if ($user && password_verify($password, $user['password_hash'])) {
            session_regenerate_id(true);
            $_SESSION = ['user_id' => $user['id'], 'username' => $username];
            header('Location: welcome.php');
            exit;
        }
        $message = 'Incorrect username or password.';
    }
}
?>
<!doctype html>
<html lang="en">
<head><meta charset="utf-8"><title>Log in</title></head>
<body>
  <h1>Log in</h1>
  <?php if ($message !== ''): ?>
    <p role="alert"><?= htmlspecialchars($message) ?></p>
  <?php endif; ?>
  <form method="post" action="login.php">
    <input type="hidden" name="csrf" value="<?= htmlspecialchars($_SESSION['csrf']) ?>">
    <label for="username">Username</label>
    <input id="username" name="username" autocomplete="username" required>
    <label for="password">Password</label>
    <input id="password" name="password" type="password" autocomplete="current-password" required>
    <button type="submit">Log in</button>
  </form>
</body>
</html>

Top to bottom:

  • The session settings. use_strict_mode makes PHP refuse session IDs it didn't create itself, so nobody can pick an ID for you in advance; the PHP manual calls it mandatory for secure sessions, yet it's off by default. cookie_httponly stops JavaScript on the page from reading the session ID, and cookie_samesite set to Lax stops the browser from sending it along with most requests that start on other sites. Once the site runs on HTTPS, add 'cookie_secure' => true so the cookie is never sent unencrypted.
  • post() reads a form field and always returns a string. Someone can send username[]=x, which makes $_POST['username'] an array, and string functions like trim() would stop with an error. Anything that isn't a string counts as empty.
  • The CSRF token is a random value kept in the session and copied into a hidden field. A form forged on another site can't know it, so hash_equals() rejects it with a 400. hash_equals() compares in constant time, so the comparison doesn't leak how much of the token was right. A login form needs this too: OWASP describes "login CSRF", where an attacker logs you into their account without you noticing.
  • $_SERVER['REQUEST_METHOD'] === 'POST' means the check only runs when the form is sent. Opening the page shows the form and nothing else.
  • The prepared statement. The ? is filled in by the database driver, never pasted into the SQL text, so a username like ' OR '1'='1 is just a name that doesn't exist.
  • password_verify() hashes the typed password with the salt and cost stored in the hash and compares the result. The PHP manual notes it's safe against timing attacks.
  • After a match, session_regenerate_id(true) moves the user to a new session ID and deletes the old one, and the session is filled with just the user's ID and name. Anything from before login, including the old CSRF token, is gone. Then we redirect, so pressing refresh afterwards loads welcome.php instead of sending the login form, and the password, again.
  • One message for an unknown username and a wrong password, so the form can't be used to find out which usernames exist.
  • htmlspecialchars() on everything printed into the page, so text can't turn into HTML or script.

Step 3: a page for logged-in users (public/welcome.php)

<?php
session_start(['use_strict_mode' => true, 'cookie_httponly' => true, 'cookie_samesite' => 'Lax']);

if (!isset($_SESSION['user_id'])) {
    header('Location: login.php');
    exit;
}
?>
<!doctype html>
<html lang="en">
<head><meta charset="utf-8"><title>Welcome</title></head>
<body>
  <p>You are logged in as <?= htmlspecialchars($_SESSION['username']) ?>.</p>
</body>
</html>

Every page that needs a logged-in user starts this way: start the session with the same settings, and send anyone without a user_id back to the login page. The username is escaped here too. A user called <b>x</b> shows up as that text, not in bold.

Run it

cd login-demo
php setup.php
php -S localhost:8000 -t public

Open http://localhost:8000/login.php and log in as asha with correct-horse-42. -t public makes public/ the only folder the server shows. PHP's built-in server is for trying things out: the manual says "It should not be used on a public network." With XAMPP's Apache, make public/ the folder Apache serves and keep setup.php and users.db out of htdocs.

Here's what happened when we tried each case against the running server (PHP 8.2.12):

What we sentWhat came back
A made-up session ID in the cookieIgnored: PHP hands out a new one of its own
Open login.phpThe form, and a session cookie marked HttpOnly and SameSite=Lax
asha and correct-horse-42A redirect to welcome.php, with a new session ID
asha and a wrong password"Incorrect username or password."
A username that doesn't existThe same message, word for word
Username ' OR '1'='1The same message: it's just a name nobody has
The form without its CSRF token400, "Form expired. Please try again."
username[]=x (an array)"Please enter a username and password.", not an error page
welcome.php without logging inA redirect to login.php
welcome.php with the session ID from before loginA redirect to login.php: that session was deleted
/users.db or /setup.php404: they're outside public/

What does a real site still need?

This is a learning example. It gets the basics right, but a real login also needs:

  • HTTPS, and then 'cookie_secure' => true.
  • A limit on attempts. Nothing here stops someone trying thousands of passwords. OWASP's Authentication Cheat Sheet covers login throttling and account lockout.
  • A logout button: a POST form with its own CSRF token that empties $_SESSION and calls session_destroy().
  • Password rules at sign-up, such as a minimum length. Our Node.js JWT authentication API explains why it asks for 15 characters.
  • Equal timing. An unknown username skips password_verify(), so it's answered a little faster than a wrong password, and OWASP suggests going through the same work either way.
  • A note on long passwords. bcrypt, today's default, only uses the first 72 bytes of a password; the PHP manual and OWASP both point this out. That's plenty for normal passphrases, but cap the length at sign-up so nobody believes the rest counts.
  • Quiet errors. XAMPP shows PHP errors on the page, so a missing database prints file paths for anyone to see. Turn display_errors off and log errors instead.
  • Rehashing: when PHP's default gets stronger, password_needs_rehash() after a successful login tells you to save a new hash.

Or use a well-maintained framework's login, which handles these for you.

Think these through, then try them on your copy.

1) Why does users.db sit outside public/?
2) Someone types asha' -- as the username, with any password. What happens, and why?
3) In PHP, what does $b = false; var_dump(isset($b)); print?

Show the answers

1) Anything in public/ can be downloaded. If users.db were there, anyone could fetch /users.db and take every password hash home to attack offline.
2) "Incorrect username or password." The prepared statement sends asha' -- to the database as a value, so it's compared as a whole name, and nobody has that name. The quote and the -- never become SQL.
3) bool(true). $b exists and isn't null, so it's "set", even though it's false. That's the 2022 bug in one line.

Questions people ask

How do I make a login page with username and password in PHP?

Store users in a database with a password_hash() of each password. On login, look the user up with a PDO prepared statement, check the typed password with password_verify(), then call session_regenerate_id(true) and save the user's ID in $_SESSION. Protected pages check for that ID and redirect to the login page if it's missing.

How should I store passwords in PHP?

With password_hash($password, PASSWORD_DEFAULT), in a column with room for 255 characters. Never store the password itself, and don't use md5() or sha1(): OWASP explains they were designed to be fast, which makes guessing quick. Password hashes are meant to be slow.

Why use prepared statements for a login?

Because the username comes from the user. With a prepared statement, the value is sent separately from the SQL, so text like ' OR '1'='1 can't change the query. The PHP manual says that if an app only uses prepared statements, no SQL injection will occur, as long as no other part of the query is built from unescaped input.

Why call session_regenerate_id after login?

To stop session fixation. If an attacker got a session ID into your browser before you logged in, logging in would make that ID valid for your account. A new ID at login makes the old one worthless, and the true deletes the old session's data.

Do I need a CSRF token on a login form?

Yes. Without one, another site can submit your login form in the background with the attacker's username and password, so you end up using their account without noticing, and anything you type in is theirs. OWASP recommends a token in the login form.

Can I use this code on a live website?

Not as it is. It's a learning example. Add HTTPS with secure cookies, limits on login attempts, a logout button and password rules first, or use a framework's built-in login.

Keep going

Checked on 20 October 2026 against the 16 sources below.

Sources

Every file in this post was linted and run with PHP 8.2.12 before publishing, and each case in the table above was sent to the running server.