Table of Contents
A PHP login system is the foundation of almost every web application. Get it wrong – plain text passwords, raw SQL queries, missing session security – and the consequences are serious. Get it right and you have a reusable authentication foundation for every project.
This guide builds a complete PHP login system from scratch – database setup, secure registration with password hashing, login with prepared statements, session handling, CSRF protection, brute force prevention, and page protection. Every security decision is explained, not just shown.
What You Will Build
- User registration with password hashing
- Secure login with prepared statements
- Session handling with session fixation protection
- CSRF token validation on all forms
- Basic brute force rate limiting
- Protected page access control
- Logout with complete session cleanup
What You Need
- PHP 7.4 or higher
- MySQL 5.7 or higher
- A web server (Apache or Nginx) or PHP built-in server
Database Setup
CREATE DATABASE IF NOT EXISTS auth_demo
CHARACTER SET utf8mb4
COLLATE utf8mb4_unicode_ci;
USE auth_demo;
CREATE TABLE IF NOT EXISTS users (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(100) NOT NULL,
email VARCHAR(255) NOT NULL UNIQUE,
password VARCHAR(255) NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- Track failed login attempts for brute force prevention
CREATE TABLE IF NOT EXISTS login_attempts (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
email VARCHAR(255) NOT NULL,
ip_address VARCHAR(45) NOT NULL,
attempted_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
INDEX idx_email_time (email, attempted_at),
INDEX idx_ip_time (ip_address, attempted_at)
);
Database Connection
Use PDO, not mysqli. PDO handles errors through exceptions, supports prepared statements cleanly, and works across multiple database types:
<?php
// db.php - include this on every page that needs database access
function get_db() {
static $pdo = null;
if ($pdo !== null) return $pdo;
$host = 'localhost';
$dbname = 'auth_demo';
$user = 'your_username';
$pass = 'your_password';
$charset = 'utf8mb4';
try {
$pdo = new PDO(
"mysql:host=$host;dbname=$dbname;charset=$charset",
$user,
$pass,
[
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_EMULATE_PREPARES => false,
]
);
return $pdo;
} catch (PDOException $e) {
// Never expose database errors to users in production
error_log("Database connection failed: " . $e->getMessage());
die("Service temporarily unavailable. Please try again later.");
}
}
?>
User Registration
Save this as register.php:
<?php
session_start(); // Always at the very top
require 'db.php';
$errors = [];
$success = '';
// Generate CSRF token if not exists
if (empty($_SESSION['csrf_token'])) {
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
}
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
// Validate CSRF token
if (!hash_equals($_SESSION['csrf_token'], $_POST['csrf_token'] ?? '')) {
die("Invalid request.");
}
$name = trim($_POST['name'] ?? '');
$email = trim($_POST['email'] ?? '');
$password = $_POST['password'] ?? '';
$confirm = $_POST['confirm'] ?? '';
// Validate inputs
if (empty($name) || strlen($name) < 2) {
$errors[] = "Name must be at least 2 characters.";
}
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
$errors[] = "Invalid email address.";
}
if (strlen($password) < 8) {
$errors[] = "Password must be at least 8 characters.";
}
if ($password !== $confirm) {
$errors[] = "Passwords do not match.";
}
if (empty($errors)) {
$pdo = get_db();
// Check if email already exists
$stmt = $pdo->prepare("SELECT id FROM users WHERE email = :email");
$stmt->execute([':email' => $email]);
if ($stmt->fetch()) {
$errors[] = "An account with this email already exists.";
} else {
// Hash password - never store plain text
$hashedPassword = password_hash($password, PASSWORD_DEFAULT);
$stmt = $pdo->prepare(
"INSERT INTO users (name, email, password)
VALUES (:name, :email, :password)"
);
$stmt->execute([
':name' => $name,
':email' => $email,
':password' => $hashedPassword,
]);
$success = "Account created. You can now log in.";
}
}
}
?>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Register</title>
<style>
body { font-family: Arial, sans-serif; max-width: 400px; margin: 60px auto; padding: 0 20px; }
input { width: 100%; padding: 10px; margin: 8px 0; border: 1px solid #ddd; border-radius: 4px; box-sizing: border-box; }
button { width: 100%; padding: 12px; background: #3498db; color: white; border: none; border-radius: 4px; cursor: pointer; font-size: 16px; }
.error { color: #e74c3c; margin: 5px 0; font-size: 14px; }
.success { color: #27ae60; margin: 10px 0; }
</style>
</head>
<body>
<h2>Create Account</h2>
<?php if ($success): ?>
<p class="success"><?= htmlspecialchars($success) ?></p>
<p><a href="login.php">Log in now</a></p>
<?php else: ?>
<?php foreach ($errors as $error): ?>
<p class="error"><?= htmlspecialchars($error) ?></p>
<?php endforeach; ?>
<form method="post">
<input type="hidden" name="csrf_token" value="<?= $_SESSION['csrf_token'] ?>">
<input type="text" name="name" placeholder="Full name" required>
<input type="email" name="email" placeholder="Email address" required>
<input type="password" name="password" placeholder="Password (8+ chars)" required>
<input type="password" name="confirm" placeholder="Confirm password" required>
<button type="submit">Create Account</button>
</form>
<p><a href="login.php">Already have an account?</a></p>
<?php endif; ?>
</body>
</html>
Secure Login
Save this as login.php:
<?php
session_start(); // Must be first - before any output
require 'db.php';
$error = '';
// Redirect if already logged in
if (isset($_SESSION['user_id'])) {
header("Location: dashboard.php");
exit;
}
// Generate CSRF token
if (empty($_SESSION['csrf_token'])) {
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
}
// Brute force check - max 5 attempts per email per 15 minutes
function is_rate_limited($pdo, $email, $ipAddress) {
$stmt = $pdo->prepare(
"SELECT COUNT(*) FROM login_attempts
WHERE (email = :email OR ip_address = :ip)
AND attempted_at > DATE_SUB(NOW(), INTERVAL 15 MINUTE)"
);
$stmt->execute([':email' => $email, ':ip' => $ipAddress]);
return (int) $stmt->fetchColumn() >= 5;
}
function log_attempt($pdo, $email, $ipAddress) {
$stmt = $pdo->prepare(
"INSERT INTO login_attempts (email, ip_address) VALUES (:email, :ip)"
);
$stmt->execute([':email' => $email, ':ip' => $ipAddress]);
}
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
// Validate CSRF
if (!hash_equals($_SESSION['csrf_token'], $_POST['csrf_token'] ?? '')) {
die("Invalid request.");
}
$email = trim($_POST['email'] ?? '');
$password = $_POST['password'] ?? '';
$ipAddress = $_SERVER['REMOTE_ADDR'];
$pdo = get_db();
// Check rate limit before doing anything
if (is_rate_limited($pdo, $email, $ipAddress)) {
$error = "Too many failed attempts. Please wait 15 minutes and try again.";
} elseif (empty($email) || empty($password)) {
$error = "Email and password are required.";
} else {
$stmt = $pdo->prepare("SELECT * FROM users WHERE email = :email");
$stmt->execute([':email' => $email]);
$user = $stmt->fetch();
if ($user && password_verify($password, $user['password'])) {
// Login successful
// Regenerate session ID - prevents session fixation attacks
session_regenerate_id(true);
$_SESSION['user_id'] = $user['id'];
$_SESSION['user_name'] = $user['name'];
$_SESSION['user_email'] = $user['email'];
// Clear failed attempts on successful login
$pdo->prepare(
"DELETE FROM login_attempts WHERE email = :email"
)->execute([':email' => $email]);
header("Location: dashboard.php");
exit;
} else {
// Log the failed attempt
log_attempt($pdo, $email, $ipAddress);
$error = "Invalid email or password.";
}
}
}
?>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Login</title>
<style>
body { font-family: Arial, sans-serif; max-width: 400px; margin: 60px auto; padding: 0 20px; }
input { width: 100%; padding: 10px; margin: 8px 0; border: 1px solid #ddd; border-radius: 4px; box-sizing: border-box; }
button { width: 100%; padding: 12px; background: #3498db; color: white; border: none; border-radius: 4px; cursor: pointer; font-size: 16px; }
.error { color: #e74c3c; margin: 10px 0; }
</style>
</head>
<body>
<h2>Log In</h2>
<?php if ($error): ?>
<p class="error"><?= htmlspecialchars($error) ?></p>
<?php endif; ?>
<form method="post">
<input type="hidden" name="csrf_token" value="<?= $_SESSION['csrf_token'] ?>">
<input type="email" name="email" placeholder="Email address" required autofocus>
<input type="password" name="password" placeholder="Password" required>
<button type="submit">Log In</button>
</form>
<p><a href="register.php">Create an account</a></p>
</body>
</html>
Protecting Pages With Session Check
Create auth.php – include this at the top of every page that requires login:
<?php
// auth.php - include at top of every protected page
if (session_status() === PHP_SESSION_NONE) {
session_start();
}
if (!isset($_SESSION['user_id'])) {
header("Location: login.php");
exit;
}
// Optional: session timeout after 30 minutes of inactivity
$sessionTimeout = 1800; // 30 minutes
if (isset($_SESSION['last_activity'])) {
if (time() - $_SESSION['last_activity'] > $sessionTimeout) {
session_unset();
session_destroy();
header("Location: login.php?reason=timeout");
exit;
}
}
$_SESSION['last_activity'] = time();
?>
Now use it on any protected page:
<?php
require 'auth.php'; // Redirects to login.php if not authenticated
// User is logged in - safe to access session data
$userName = htmlspecialchars($_SESSION['user_name']);
$userEmail = htmlspecialchars($_SESSION['user_email']);
?>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Dashboard</title>
<style>
body { font-family: Arial, sans-serif; max-width: 800px; margin: 40px auto; padding: 0 20px; }
.header { display: flex; justify-content: space-between; align-items: center; border-bottom: 1px solid #ddd; padding-bottom: 15px; }
</style>
</head>
<body>
<div class="header">
<h2>Welcome, <?= $userName ?></h2>
<a href="logout.php">Log out</a>
</div>
<p>You are logged in as: <?= $userEmail ?></p>
</body>
</html>
Logout
Save as logout.php:
<?php
session_start();
// Clear all session data
session_unset();
// Destroy the session
session_destroy();
// Delete the session cookie from the browser
if (ini_get("session.use_cookies")) {
$params = session_get_cookie_params();
setcookie(
session_name(), '',
time() - 42000,
$params["path"],
$params["domain"],
$params["secure"],
$params["httponly"]
);
}
header("Location: login.php");
exit;
?>
The cookie deletion step is important. Without it the session cookie remains in the browser after logout. If someone has access to the browser later they could potentially reuse the cookie. Deleting it on logout ensures a clean exit.
Security Checklist
Before deploying any PHP login system to production go through these:
- HTTPS only – passwords sent over plain HTTP are visible to anyone on the same network. No exceptions.
- password_hash() with PASSWORD_DEFAULT – never MD5, never SHA1, never plain text.
PASSWORD_DEFAULTuses bcrypt currently and will automatically upgrade to stronger algorithms in future PHP versions. - Prepared statements on every query – never concatenate user input into SQL strings.
- session_regenerate_id(true) after login – prevents session fixation. The
trueparameter deletes the old session file. - CSRF tokens on all forms – prevents cross-site request forgery attacks where another site tricks a logged-in user into submitting your form.
- Rate limit login attempts – without this an attacker can try unlimited passwords automatically.
- Generic error messages – “Invalid email or password” not “Email not found” or “Wrong password”. Specific errors help attackers enumerate valid accounts.
Common Mistakes to Avoid
Starting the session after output
<?php
// Wrong - headers already sent by the time session_start() is called
echo "Hello";
session_start(); // Error: Cannot send session cookie
// Right - session_start() must be the first thing in every file
session_start();
echo "Hello";
?>
Skipping session_regenerate_id() after login
<?php
// Wrong - session fixation vulnerability
$_SESSION['user_id'] = $user['id'];
// Right - generate a new session ID after authentication
session_regenerate_id(true);
$_SESSION['user_id'] = $user['id'];
?>
Exposing database errors to users
<?php
// Wrong - exposes server internals
try {
$pdo = new PDO(...);
} catch (PDOException $e) {
die("Connection failed: " . $e->getMessage()); // Never do this
}
// Right - log the error, show generic message
try {
$pdo = new PDO(...);
} catch (PDOException $e) {
error_log($e->getMessage()); // Log to server
die("Service temporarily unavailable."); // Show to user
}
?>
Frequently Asked Questions
Should I use PHP sessions or JWT for authentication?
Sessions for traditional web applications – they’re simpler, stored server-side, and easy to invalidate on logout. JWT (JSON Web Tokens) for APIs and applications where you need stateless authentication across multiple servers or mobile clients. If you’re building a standard PHP web application with a single server, sessions are the right choice.
Why use password_hash() instead of MD5 or SHA1?
MD5 and SHA1 are cryptographic hash functions designed for speed – a modern GPU can crack billions of them per second. password_hash() uses bcrypt which is intentionally slow and includes a salt automatically. Even if your database is compromised, bcrypt hashed passwords are practically impossible to crack at scale. MD5 or SHA1 hashed passwords can be reversed in seconds with rainbow tables.
What is a CSRF token and why is it needed?
A CSRF (Cross-Site Request Forgery) token is a random value added to forms that proves the form was generated by your site and submitted intentionally by the user. Without it, an attacker can trick a logged-in user into submitting your login or account forms from another site. The token is stored in the session and compared to the form value on submission – they only match if the user actually loaded the form from your site.
How do I add password reset functionality?
Generate a cryptographically secure random token with bin2hex(random_bytes(32)), store it in the database with an expiry timestamp, email the user a link containing the token, and when they click the link verify the token matches and hasn’t expired before allowing the password change. Never send passwords via email – only reset links.
How do I keep users logged in with Remember Me?
Generate a secure random token, store it in the database associated with the user, and set it as a cookie with a long expiry using setcookie(). On subsequent visits check for the cookie before showing the login form and if the token matches a valid user start a session for them automatically. Always store the token hashed in the database – treat it like a password.
Summary
A secure PHP login system requires more than just checking a username and password. Every decision matters:
- session_start() at the top of every file – before any output, before any logic
- password_hash() for storage,
password_verify()for checking – never anything else - Prepared statements on every query – no exceptions, no concatenation
- session_regenerate_id(true) after login – not optional
- CSRF tokens on all forms – one line to generate, one line to verify
- Rate limit failed attempts – 5 attempts per 15 minutes is a reasonable starting point
- Generic error messages – don’t tell attackers which part of their credentials is wrong
For storing user data and querying it efficiently, the PHP MySQL guide covers PDO connections, prepared statements, and database best practices in detail. For building the rest of your application on top of this authentication foundation, the PHP web scraper guide demonstrates how to structure larger PHP projects with proper separation of concerns.
Learn more about password security from official PHP documentation.
