Home >PHP >PHP and databases >Secure sessions and login

Secure sessions and login

After login, the session keeps the user on the website logged in – and whoever obtains its identifier is logged in instead of them. How to set up the session cookie, what to do at login and logout and how to limit password guessing.

The examples use the $pdo connection from the article on PDO and the users table from the article on storing passwords.

Session cookie settings

session_set_cookie_params([
  "lifetime" => 0,
  "path" => "/",
  "secure" => true,
  "httponly" => true,
  "samesite" => "Lax",
]);
ini_set("session.use_strict_mode", "1");
session_start();
  • httponly – JavaScript cannot read the cookie. A possible XSS vulnerability therefore cannot steal the session identifier.
  • secure – the cookie is sent only over HTTPS. The whole website must therefore run on HTTPS.
  • samesite – the value Lax prevents the cookie from being sent with POST requests from other websites, which is additional protection against CSRF. The value Strict would not send the cookie even when the user arrives through a link from another website, and after clicking the link the user would appear to be logged out.
  • lifetime 0 – the cookie is valid until the browser is closed.
  • use_strict_mode – PHP does not accept a session identifier that it has not created itself. An attacker therefore cannot slip the user an identifier known in advance.

Passing the settings as an array works since PHP 7.3. The settings must be made before session_start and before any output of the page.

Login

function log_in(PDO $pdo, string $email, string $password): bool
  {
    $stmt = $pdo->prepare("SELECT id, password FROM users WHERE email = ?");
    $stmt->execute([$email]);
    $user = $stmt->fetch();
    if(!$user || !password_verify($password, $user["password"])) return false;
    session_regenerate_id(true);
    $_SESSION["user_id"] = (int)$user["id"];
    return true;
  }

session_regenerate_id(true) assigns a new session identifier after login and discards the old one. Even if an attacker knew the identifier from before the login, it is now useless. Do the same when privileges change. Store only the user ID in the session, load the user details and privileges from the database.

Logout

$_SESSION = [];
$params = session_get_cookie_params();
setcookie(session_name(), "", [
  "expires" => time() - 3600,
  "path" => $params["path"],
  "domain" => $params["domain"],
  "secure" => $params["secure"],
  "httponly" => $params["httponly"],
  "samesite" => $params["samesite"],
]);
session_destroy();

Logging out deletes the session data on the server as well as the cookie in the browser. Put the logout button in a form submitted via POST with a CSRF token.

Limiting login attempts

Without a limit, an attacker can try passwords automatically, thousands per minute. Failed attempts are therefore written to a table:

CREATE TABLE login_attempts (
  id INT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
  ip VARCHAR(45) NOT NULL,
  attempted_at DATETIME NOT NULL,
  KEY idx_ip_attempted_at (ip, attempted_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

After five failed attempts from one IP address within 15 minutes, further logins are refused:

function too_many_attempts(PDO $pdo, string $ip): bool
  {
    $stmt = $pdo->prepare("SELECT COUNT(*) FROM login_attempts WHERE ip = ? AND attempted_at > NOW() - INTERVAL 15 MINUTE");
    $stmt->execute([$ip]);
    return (int)$stmt->fetchColumn() >= 5;
  }
function log_failed_attempt(PDO $pdo, string $ip): void
  {
    $stmt = $pdo->prepare("INSERT INTO login_attempts (ip, attempted_at) VALUES (?, NOW())");
    $stmt->execute([$ip]);
  }
// processing the login form (after checking the CSRF token)
$email = is_string($_POST["email"] ?? null) ? trim($_POST["email"]) : "";
$password = is_string($_POST["password"] ?? null) ? $_POST["password"] : "";
$ip = $_SERVER["REMOTE_ADDR"];
if(too_many_attempts($pdo, $ip))
  {
    $error = "Too many attempts. Please try again in 15 minutes.";
  }
elseif(log_in($pdo, $email, $password))
  {
    header("Location: /admin/");
    exit;
  }
else
  {
    log_failed_attempt($pdo, $ip);
    $error = "Incorrect e-mail or password.";
  }
  • Only the IP address and the time are stored. The entered e-mail and password are not written anywhere.
  • The ip column has 45 characters so that an IPv6 address fits too.
  • Delete old records regularly, for example once a day: DELETE FROM login_attempts WHERE attempted_at < NOW() - INTERVAL 1 DAY.
  • If the website runs behind a reverse proxy or a CDN, REMOTE_ADDR contains the address of the proxy and the limit would apply to all visitors at once. The real address is then passed in a header from the proxy – you may trust it only if the request really came through your proxy.

Other principles

  • Run the whole website on HTTPS and set up the redirect from HTTP on the server.
  • Store passwords with password_hash, protect forms with a CSRF token and access the database only through prepared statements.
  • You can protect the administration with an additional layer, such as restricting access by IP address.

Related articles:
Storing passwords with password_hash
Protecting forms against CSRF
Prepared statements in PDO