After the password is verified, you start a logged-in session: you remember the user’s id in $_SESSION.
The login page
<?php
declare(strict_types=1);
session_set_cookie_params([
"lifetime" => 0,
"path" => "/",
"secure" => !empty($_SERVER["HTTPS"]),
"httponly" => true,
"samesite" => "Lax",
]);
session_start();
require __DIR__ . "/functions.php"; // h(), csrfToken(), csrfCheck(), attemptLogin()
$pdo = require __DIR__ . "/db.php";
// Already logged in? Go to the dashboard.
if (isset($_SESSION["user_id"])) {
header("Location: dashboard.php");
exit;
}
$error = null;
$email = "";
if ($_SERVER["REQUEST_METHOD"] === "POST") {
csrfCheck();
$email = trim((string) ($_POST["email"] ?? ""));
$password = (string) ($_POST["password"] ?? "");
$user = attemptLogin($pdo, $email, $password);
if ($user === null) {
$error = "Email or password is incorrect.";
} else {
session_regenerate_id(true); // new session ID: prevents session fixation
$_SESSION["user_id"] = (int) $user["id"];
$_SESSION["logged_in_at"] = time();
header("Location: dashboard.php");
exit;
}
}
?>
<!DOCTYPE html>
<html lang="en">
<head><meta charset="utf-8"><title>Log in</title></head>
<body>
<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 type="email" id="email" name="email" value="<?= h($email) ?>" autocomplete="username" required>
</p>
<p>
<label for="password">Password</label><br>
<input type="password" id="password" name="password" autocomplete="current-password" required>
</p>
<button type="submit">Log in</button>
</form>
</body>
</html>
The important lines
session_regenerate_id(true) Creates a new session ID at the moment of login and deletes the old session. If an attacker managed to give the victim a known session ID before login (session fixation), that ID becomes useless. See Session Fixation.
$_SESSION["user_id"] = ... Store the id only. Do not store the password, the hash, or the whole user row. Load details from the database when you need them.
One generic error message “Email or password is incorrect.” Never say which one was wrong.
csrfCheck() Protects the login form against CSRF (see CSRF).
autocomplete attributes username and current-password let password managers fill the form, which helps users choose strong, unique passwords.
Who is logged in?
On other pages, read the id and load the user when you need their details.
<?php
function currentUser(PDO $pdo, array $session): ?array
{
$id = $session["user_id"] ?? null;
if (!is_int($id)) {
return null;
}
$statement = $pdo->prepare("SELECT id, name, email, role FROM users WHERE id = :id");
$statement->execute(["id" => $id]);
return $statement->fetch() ?: null;
}
$pdo = require __DIR__ . "/db.php";
$user = currentUser($pdo, ["user_id" => 2]);
echo $user["name"] . " (" . $user["role"] . ")\n";
var_dump(currentUser($pdo, []));
Ben (user)
NULL
Loading the user on each request means that if you deactivate or delete an account, that user is locked out immediately, even if their session is still alive.
Safe redirects after login
Sometimes you send the visitor back to the page they wanted. Never redirect to a URL taken from the request without checking it. See Post/Redirect/Get for an allow-list.
Timeouts
A user who forgets to log out should not stay logged in forever on a shared computer. Check the last activity time and end the session after a period of inactivity. Secure Session Practices shows how.
Storing a "logged_in" flag in a cookie
$_COOKIE["logged_in"] = "1" proves nothing. The login state belongs in the server-side session.