Level: intermediate. This project combines the whole Authentication chapter into a working system: register, log in, a protected dashboard and logout.
Files
auth-app/
├── db.php
├── functions.php h(), csrfToken(), csrfCheck(), requireLogin()
├── AuthService.php all rules
├── register.php
├── login.php
├── dashboard.php protected
└── logout.php POST only
The service
<?php
declare(strict_types=1);
final class AuthService
{
private const DUMMY_HASH = '$2y$10$usesomesillystringfore7hnbRJHxXVLeakoG8K30oukPsA.ztMG';
public function __construct(private PDO $pdo, private int $maxFailures = 5, private int $windowSeconds = 900) {}
/** @return array<string, string> field => error */
public function validateRegistration(array $input): array
{
$errors = [];
$name = trim((string) ($input["name"] ?? ""));
$email = trim((string) ($input["email"] ?? ""));
$password = (string) ($input["password"] ?? "");
if ($name === "" || mb_strlen($name) > 100) {
$errors["name"] = "Please enter your name.";
}
if (filter_var($email, FILTER_VALIDATE_EMAIL) === false || mb_strlen($email) > 190) {
$errors["email"] = "Please enter a valid email address.";
}
if (mb_strlen($password) < 10) {
$errors["password"] = "Use at least 10 characters.";
}
if ($password !== (string) ($input["password_confirm"] ?? "")) {
$errors["password_confirm"] = "The passwords do not match.";
}
return $errors;
}
/** @throws DomainException when the email is already used */
public function register(string $name, string $email, string $password): int
{
try {
$this->pdo->prepare("INSERT INTO users (name, email, password_hash, created_at) VALUES (:name, :email, :hash, :created)")
->execute([
"name" => trim($name),
"email" => strtolower(trim($email)),
"hash" => password_hash($password, PASSWORD_DEFAULT),
"created" => date("Y-m-d H:i:s"),
]);
} catch (PDOException $e) {
if ($e->getCode() === "23000") {
throw new DomainException("That email address cannot be used.");
}
throw $e;
}
return (int) $this->pdo->lastInsertId();
}
/** @return array{id: int, name: string, role: string}|null */
public function attempt(string $email, string $password, array &$failures = []): ?array
{
$now = time();
$failures = array_values(array_filter($failures, fn(int $t) => $now - $t < $this->windowSeconds));
if (count($failures) >= $this->maxFailures) {
throw new RuntimeException("Too many attempts. Please wait a few minutes.");
}
$stmt = $this->pdo->prepare("SELECT id, name, role, password_hash FROM users WHERE email = :email");
$stmt->execute(["email" => strtolower(trim($email))]);
$user = $stmt->fetch();
$ok = password_verify($password, $user["password_hash"] ?? self::DUMMY_HASH);
if ($user === false || !$ok) {
$failures[] = $now;
return null;
}
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"]]);
}
return ["id" => (int) $user["id"], "name" => $user["name"], "role" => $user["role"]];
}
public function find(int $id): ?array
{
$stmt = $this->pdo->prepare("SELECT id, name, email, role FROM users WHERE id = :id");
$stmt->execute(["id" => $id]);
return $stmt->fetch() ?: null;
}
}
Two details: failures are counted in a plain array that the caller keeps (in the page it will live in the session, and a stronger system would also store them in the database by IP address), and attempt() runs password_verify() even for unknown emails, so timing does not reveal which addresses exist.
Test every path
<?php
require __DIR__ . "/AuthService.php";
$auth = new AuthService(require __DIR__ . "/db.php", maxFailures: 3);
print_r($auth->validateRegistration(["name" => "", "email" => "x", "password" => "short", "password_confirm" => "different"]));
$id = $auth->register("Dev", "Dev@Example.com", "a long passphrase here");
echo "registered #$id\n";
try {
$auth->register("Dev again", "dev@example.com", "another passphrase");
} catch (DomainException $e) {
echo $e->getMessage() . "\n";
}
$failures = [];
var_dump($auth->attempt("dev@example.com", "wrong password", $failures));
var_dump($auth->attempt("nobody@example.com", "whatever", $failures));
$user = $auth->attempt("DEV@example.com ", "a long passphrase here", $failures);
echo $user["name"] . " logged in as " . $user["role"] . "\n";
echo count($failures) . " failures recorded\n";
try {
$auth->attempt("dev@example.com", "guess", $failures);
$auth->attempt("dev@example.com", "guess", $failures);
$auth->attempt("dev@example.com", "guess", $failures);
} catch (RuntimeException $e) {
echo $e->getMessage();
}
Array
(
[name] => Please enter your name.
[email] => Please enter a valid email address.
[password] => Use at least 10 characters.
[password_confirm] => The passwords do not match.
)
registered #4
That email address cannot be used.
NULL
NULL
Dev logged in as user
2 failures recorded
Too many attempts. Please wait a few minutes.
The whole security logic runs without a browser. (In the demo database, three users exist already, so the new user gets id 4.)
The helper for protected pages
<?php
function requireLogin(AuthService $auth): array
{
$id = $_SESSION["user_id"] ?? null;
$user = is_int($id) ? $auth->find($id) : null;
if ($user === null) {
header("Location: login.php");
exit;
}
return $user;
}
The pages
<?php
declare(strict_types=1);
session_set_cookie_params(["httponly" => true, "samesite" => "Lax", "secure" => !empty($_SERVER["HTTPS"])]);
session_start();
require __DIR__ . "/functions.php";
require __DIR__ . "/AuthService.php";
$auth = new AuthService(require __DIR__ . "/db.php");
$error = null;
$email = "";
if ($_SERVER["REQUEST_METHOD"] === "POST") {
csrfCheck();
$email = trim((string) ($_POST["email"] ?? ""));
$failures = $_SESSION["login_failures"] ?? [];
try {
$user = $auth->attempt($email, (string) ($_POST["password"] ?? ""), $failures);
} catch (RuntimeException $e) {
$user = null;
$error = $e->getMessage();
}
$_SESSION["login_failures"] = $failures;
if ($user !== null) {
session_regenerate_id(true); // prevents session fixation
$_SESSION["user_id"] = $user["id"];
unset($_SESSION["login_failures"]);
header("Location: dashboard.php");
exit;
}
$error ??= "Email or password is incorrect.";
}
?>
<h1>Log in</h1>
<?php if ($error): ?><p role="alert"><?= h($error) ?></p><?php endif; ?>
<form method="post">
<input type="hidden" name="csrf_token" value="<?= h(csrfToken()) ?>">
<p><label for="email">Email</label><br><input id="email" type="email" name="email" value="<?= h($email) ?>" autocomplete="username" required></p>
<p><label for="password">Password</label><br><input id="password" type="password" name="password" autocomplete="current-password" required></p>
<button type="submit">Log in</button>
</form>
<p><a href="register.php">Create an account</a></p>
<?php
declare(strict_types=1);
session_start();
require __DIR__ . "/functions.php";
require __DIR__ . "/AuthService.php";
$auth = new AuthService(require __DIR__ . "/db.php");
$user = requireLogin($auth);
header("Cache-Control: no-store");
?>
<h1>Welcome, <?= h($user["name"]) ?></h1>
<p>Your role: <?= h($user["role"]) ?></p>
<form method="post" action="logout.php">
<input type="hidden" name="csrf_token" value="<?= h(csrfToken()) ?>">
<button type="submit">Log out</button>
</form>
register.php follows Registration, and logout.php is the POST-only logout from Logout.
Security review of your own work
| Check | Done by |
|---|---|
| Passwords hashed | password_hash() in register() |
| Hashes upgraded | password_needs_rehash() in attempt() |
| Generic error, equal timing | Dummy hash |
| Rate limiting | Failure list in the session (add a database counter for real protection) |
| Session fixation | session_regenerate_id(true) |
| CSRF | Token on every form |
| Protected pages | requireLogin() loads the user on every request |
| Cache | no-store on private pages |
| Cookies | HttpOnly, SameSite, Secure |
Challenges
- Add email verification with a random token (Password Reset shows the token pattern).
- Add password reset.
- Add a “remember me” option (Remember Me).
- Store failed attempts per IP and per account in the database.
- Add two-factor authentication with a TOTP library.