Authentication Without a Framework

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

The reference material consulted while preparing these pages is listed in the section bibliography.

This page is written against PHP 8.5 (8.5.11), checked on 2026-10-05; the default bcrypt cost of 12 applies since PHP 8.4 (it was 10 before). Every output was produced by running the code below against SQLite on PHP 8.5.11, and the HTTP Basic example under both php -S and PHP-FPM. The cryptographic building blocks (password_hash(), random_bytes(), hash_equals()) are explained on the PHP Reference’s Cryptography, Hashing and Passwords page. In Laravel, a starter kit or Fortify provides all of this (see Authentication); this page shows what they do for you.

The Auth service

Bookshelf Lite keeps users, failed logins, "remember me" tokens and reset tokens in four tables:

CREATE TABLE users (id INTEGER PRIMARY KEY, email TEXT UNIQUE NOT NULL, password_hash TEXT NOT NULL);
CREATE TABLE login_failures (throttle_key TEXT NOT NULL, failed_at INTEGER NOT NULL);
CREATE TABLE remember_tokens (selector TEXT PRIMARY KEY, validator_hash TEXT NOT NULL,
                              user_id INTEGER NOT NULL, expires_at INTEGER NOT NULL);
CREATE TABLE password_resets (token_hash TEXT PRIMARY KEY, user_id INTEGER NOT NULL, expires_at INTEGER NOT NULL);

The whole service is one class; each part is explained in its own section below.

<?php
declare(strict_types=1);

final class Auth
{
    private const int MAX_ATTEMPTS = 5;
    private const int WINDOW_SECONDS = 900;

    public function __construct(private readonly PDO $pdo) {}

    public function register(string $email, string $password): int
    {
        if (filter_var($email, FILTER_VALIDATE_EMAIL) === false || mb_strlen($password) < 12) {
            throw new InvalidArgumentException('A valid email and a password of at least 12 characters are required.');
        }
        $stmt = $this->pdo->prepare('INSERT INTO users (email, password_hash) VALUES (?, ?)');
        $stmt->execute([mb_strtolower($email), password_hash($password, PASSWORD_DEFAULT)]);
        return (int) $this->pdo->lastInsertId();
    }

    /** @return int|null the user id, or null when the credentials are wrong */
    public function attempt(string $email, string $password, string $ip): ?int
    {
        $key = hash('sha256', mb_strtolower($email) . '|' . $ip);
        if ($this->recentFailures($key) >= self::MAX_ATTEMPTS) {
            throw new RuntimeException('Too many attempts. Try again later.');
        }

        $stmt = $this->pdo->prepare('SELECT id, password_hash FROM users WHERE email = ?');
        $stmt->execute([mb_strtolower($email)]);
        $user = $stmt->fetch(PDO::FETCH_ASSOC);

        // Verify against a dummy hash for unknown emails, so both paths take the same time
        $hash = $user['password_hash'] ?? '$2y$12$' . str_repeat('a', 53);
        if (!password_verify($password, $hash) || $user === false) {
            $this->pdo->prepare('INSERT INTO login_failures (throttle_key, failed_at) VALUES (?, ?)')->execute([$key, time()]);
            return null;
        }

        // Upgrade the hash when the algorithm or cost has changed (e.g. bcrypt cost 10 -> 12 in PHP 8.4)
        if (password_needs_rehash($user['password_hash'], PASSWORD_DEFAULT)) {
            $this->pdo->prepare('UPDATE users SET password_hash = ? WHERE id = ?')
                ->execute([password_hash($password, PASSWORD_DEFAULT), $user['id']]);
        }
        $this->pdo->prepare('DELETE FROM login_failures WHERE throttle_key = ?')->execute([$key]);
        return (int) $user['id'];
    }

    private function recentFailures(string $key): int
    {
        $stmt = $this->pdo->prepare('SELECT COUNT(*) FROM login_failures WHERE throttle_key = ? AND failed_at > ?');
        $stmt->execute([$key, time() - self::WINDOW_SECONDS]);
        return (int) $stmt->fetchColumn();
    }

    /** "Remember me": a selector to find the row and a validator stored only as a hash. */
    public function issueRememberToken(int $userId): string
    {
        $selector = bin2hex(random_bytes(12));
        $validator = bin2hex(random_bytes(32));
        $this->pdo->prepare('INSERT INTO remember_tokens (selector, validator_hash, user_id, expires_at) VALUES (?, ?, ?, ?)')
            ->execute([$selector, hash('sha256', $validator), $userId, time() + 30 * 86400]);
        return "$selector:$validator";                         // the cookie value
    }

    public function consumeRememberToken(string $cookie): ?int
    {
        [$selector, $validator] = explode(':', $cookie, 2) + [1 => ''];
        $stmt = $this->pdo->prepare('SELECT validator_hash, user_id, expires_at FROM remember_tokens WHERE selector = ?');
        $stmt->execute([$selector]);
        $row = $stmt->fetch(PDO::FETCH_ASSOC);
        $this->pdo->prepare('DELETE FROM remember_tokens WHERE selector = ?')->execute([$selector]);   // single use
        if ($row === false || $row['expires_at'] < time() || !hash_equals($row['validator_hash'], hash('sha256', $validator))) {
            return null;
        }
        return (int) $row['user_id'];                          // the caller logs in and issues a new token
    }

    /** Password reset: a random token, stored hashed, valid for one hour, usable once. */
    public function issueResetToken(string $email): ?string
    {
        $stmt = $this->pdo->prepare('SELECT id FROM users WHERE email = ?');
        $stmt->execute([mb_strtolower($email)]);
        $userId = $stmt->fetchColumn();
        if ($userId === false) {
            return null;                                       // say "if the account exists, we sent an email" either way
        }
        $token = bin2hex(random_bytes(32));
        $this->pdo->prepare('INSERT INTO password_resets (token_hash, user_id, expires_at) VALUES (?, ?, ?)')
            ->execute([hash('sha256', $token), $userId, time() + 3600]);
        return $token;                                         // goes into the emailed link
    }

    public function resetPassword(string $token, string $newPassword): bool
    {
        $stmt = $this->pdo->prepare('DELETE FROM password_resets WHERE token_hash = ? AND expires_at > ? RETURNING user_id');
        $stmt->execute([hash('sha256', $token), time()]);
        $userId = $stmt->fetchColumn();
        if ($userId === false) {
            return false;
        }
        $this->pdo->prepare('UPDATE users SET password_hash = ? WHERE id = ?')
            ->execute([password_hash($newPassword, PASSWORD_DEFAULT), $userId]);
        $this->pdo->prepare('DELETE FROM remember_tokens WHERE user_id = ?')->execute([$userId]);   // log out everywhere
        return true;
    }
}

Registration and login

password_hash($password, PASSWORD_DEFAULT) creates a salted, slow hash (bcrypt, cost 12 since PHP 8.4) that includes the algorithm, cost and salt, so the database column only needs room for 255 characters to stay future-proof. password_verify() checks a password against it in constant time. Never compare hashes yourself, never hash with md5()/sha1(), and never limit or alter the password (no trim(), no strip_tags()): a password is opaque bytes.

<?php
declare(strict_types=1);

$auth = new Auth($pdo);
$id = $auth->register('Ada@Example.com', 'correct horse battery');
var_dump($id);                                                                 // int(1)
var_dump($auth->attempt('ada@example.com', 'correct horse battery', '203.0.113.7'));   // int(1)
var_dump($auth->attempt('ada@example.com', 'wrong', '203.0.113.7'));          // NULL
var_dump($auth->attempt('nobody@example.com', 'x', '203.0.113.7'));           // NULL

// An old cost-10 hash is upgraded on the next successful login
$pdo->exec("UPDATE users SET password_hash = '" . password_hash('correct horse battery', PASSWORD_BCRYPT, ['cost' => 10]) . "'");
$auth->attempt('ada@example.com', 'correct horse battery', '203.0.113.7');
echo substr($pdo->query('SELECT password_hash FROM users')->fetchColumn(), 0, 7), "\n";   // $2y$12$
var_dump(password_get_info(password_hash('x', PASSWORD_DEFAULT))['options']);           // ['cost' => 12]

Three details in attempt() matter:

  • Rehash on login. password_needs_rehash() reports that a hash uses an older algorithm or cost, and the only moment the plain password is available to compute a new one is a successful login. The test above turned a $2y$10$ hash into $2y$12$.

  • The same answer and the same time for unknown accounts. The function returns null for a wrong password and for an unknown email alike, and an error message must not say which. Unknown emails are verified against a dummy hash, so that the response time does not reveal whether the account exists: measured on this machine, password_verify() took 236 ms against a real cost-12 hash and 252 ms against the dummy, while skipping the verification would answer almost instantly.

  • Normalisation. Emails are compared lowercased, so Ada@Example.com and ada@example.com are the same account.

After a successful attempt, start the authenticated session as on Sessions and Cookies: session_regenerate_id(true), then store only the user ID ($_SESSION['user_id'] = $id), and load the user from the database on each request. Logging out destroys the session and deletes the user’s remember-me token.

Login throttling

Without a limit, an attacker can try passwords as fast as the server answers. attempt() records each failure under a key made of the email and the client IP, and refuses after five failures within fifteen minutes:

<?php
declare(strict_types=1);

for ($i = 0; $i < 6; $i++) {
    try {
        $auth->attempt('ada@example.com', "guess$i", '198.51.100.9');
    } catch (RuntimeException $e) {
        echo 'attempt ', $i + 1, ': ', $e->getMessage(), "\n";
    }
}
// attempt 6: Too many attempts. Try again later.

Keying on the email alone would let an attacker lock any user out; keying on the IP alone misses attacks spread over many addresses. Real deployments add a global limit per IP, store the counters in Redis rather than the database, and place the client IP behind proxies carefully (trust X-Forwarded-For only from your own load balancer). Laravel’s RateLimiter implements exactly this pattern.

"Remember me"

A persistent login cookie must survive browser restarts without storing the password or anything that can be replayed if the database leaks. The selector/validator scheme does this:

  • the selector (random, stored in clear) finds the row with a normal index lookup;

  • the validator (random, 256 bits) is stored only as a SHA-256 hash and compared with hash_equals(), so a stolen database does not yield usable cookies, and the comparison does not leak timing;

  • the token is single use: it is deleted when consumed, and a new one is issued with the new session.

<?php
declare(strict_types=1);

$cookie = $auth->issueRememberToken($id);
echo strlen($cookie), " character cookie\n";               // 89 character cookie (24 + 1 + 64)
var_dump($auth->consumeRememberToken($cookie));            // int(1): log in, then issue a fresh token
var_dump($auth->consumeRememberToken($cookie));            // NULL: already used
var_dump($auth->consumeRememberToken(explode(':', $auth->issueRememberToken($id))[0] . ':forged'));   // NULL

setcookie('remember', $cookie, [
    'expires' => time() + 30 * 86400, 'path' => '/', 'secure' => true, 'httponly' => true, 'samesite' => 'Lax',
]);

Source: hash_equals and setcookie

A plain SHA-256 hash is right here (unlike for passwords) because the validator is 256 random bits: there is nothing to guess, so a slow hash adds no protection. Treat a remember-me login as weaker than a password login: ask for the password again before changing the email, the password or payment details.

Password reset

The reset flow emails a link with a token. The token must be unpredictable (random_bytes(), never md5(uniqid()) or rand()), stored only as a hash, expire quickly (one hour here), be usable once, and the response to "reset my password" must be the same whether or not the email exists:

<?php
declare(strict_types=1);

$token = $auth->issueResetToken('ada@example.com');        // email https://bookshelf.test/reset?token=...
var_dump($auth->issueResetToken('nobody@example.com'));    // NULL, but show the same message to the user
var_dump($auth->resetPassword($token, 'a brand new passphrase'));   // bool(true)
var_dump($auth->resetPassword($token, 'again'));           // bool(false): single use
var_dump($auth->attempt('ada@example.com', 'a brand new passphrase', '203.0.113.7'));   // int(1)

resetPassword() consumes the token with DELETE ... RETURNING (SQLite 3.35+, PostgreSQL, MariaDB 10.5+), so two concurrent requests cannot both use it, and it deletes all remember-me tokens: a password change should end every other session. Send the reset link to the address on file only, never to an address supplied in the request.

HTTP Basic authentication

HTTP Basic authentication asks the browser for a username and password with a 401 and a WWW-Authenticate header; the browser then sends them base64-encoded (not encrypted) in every request’s Authorization header. It is suitable for a small admin area or an internal tool, only over HTTPS. PHP exposes the credentials as $_SERVER['PHP_AUTH_USER'] and $_SERVER['PHP_AUTH_PW']:

<?php
declare(strict_types=1);

// HTTP Basic authentication for a small admin area (only over HTTPS)
$user = $_SERVER['PHP_AUTH_USER'] ?? '';
$password = $_SERVER['PHP_AUTH_PW'] ?? '';
$expectedHash = getenv('ADMIN_PASSWORD_HASH') ?: '';      // a password_hash() value from the environment

if (!hash_equals('admin', $user) || !password_verify($password, $expectedHash)) {
    header('WWW-Authenticate: Basic realm="Bookshelf admin", charset="UTF-8"');
    http_response_code(401);
    echo "Authentication required\n";
    exit;
}
echo "Hello, admin\n";
$ curl -i localhost:8000/admin.php
HTTP/1.1 401 Unauthorized
WWW-Authenticate: Basic realm="Bookshelf admin", charset="UTF-8"
Authentication required
$ curl -u admin:wrong localhost:8000/admin.php
Authentication required
$ curl -u admin:s3cret-admin-pass localhost:8000/admin.php
Hello, admin

PHP_AUTH_USER was filled under both php -S and PHP-FPM behind Nginx in the test runs. Some CGI setups and Apache configurations do not pass the Authorization header to PHP; then read $_SERVER['HTTP_AUTHORIZATION'] (Apache needs CGIPassAuth On). There is no logout in Basic authentication: the browser keeps sending the credentials until it is closed.

Where older books and tutorials are outdated

The authentication code in the older books contains several mistakes that must not be copied:

  • Plain-text passwords in Nixon’s social-network capstone (ch. 27) and Kromann’s auto-login (ch. 17): store only password_hash() output.

  • MD5 or SHA-1 password hashes (Wiley ch. 8 and 15) and reversible AES_ENCRYPT() storage with the key interpolated into SQL (Powers ch. 19): fast or reversible hashes are cracked in bulk after a breach.

  • Binding password_hash() output into WHERE password = ? (Kromann ch. 14) can never match, because every hash has a new salt: fetch the hash by email, then call password_verify().

  • Passwords passed through strip_tags() or htmlentities() before hashing (Nixon ch. 12 and 27) silently change the password; hash exactly what the user typed.

  • md5(uniqid(rand())) reset and CSRF tokens (Kromann ch. 14, Wiley ch. 8) are predictable; use bin2hex(random_bytes(32)). Kromann’s reset token also never expires and uses an off-by-one random_int() range; tokens must expire and be single use.

  • bcrypt cost 10 in examples written before PHP 8.4: the default is now 12, and password_needs_rehash() upgrades existing hashes at login.