A login system is one of the first “real” things many developers build with PHP, and one of the easiest to get subtly wrong. This tutorial walks you through a complete, working registration and login system with PHP and MySQL, built the way it should be done today: prepared statements, proper password hashing, secure sessions and CSRF protection.
Every file below has been tested end to end on PHP 8.2 and MariaDB/MySQL: registering, duplicate emails, wrong passwords, protected pages and logging out.
What you need
- PHP 8.1 or newer with the mysqli extension (included in XAMPP, MAMP, Laragon and most hosting)
- A MySQL or MariaDB database and a user with access to it
- Basic HTML and PHP knowledge
You will create six small files in one folder:
config.php: database connection, session setup and two helper functionsregister.php: the sign-up formlogin.php: the login formauth.php: a one-line guard for members-only pageswelcome.php: an example members-only pagelogout.php: ends the session
Step 1: Create the users table
Create a database (for example demo) and run this SQL in phpMyAdmin or the MySQL command line. The email column is unique so the same address can’t register twice, and password_hash is long enough for current and future hashing algorithms. utf8mb4 lets names contain any character, including emoji.
schema.sql
CREATE TABLE users (
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
name VARCHAR(100) NOT NULL,
email VARCHAR(255) NOT NULL,
password_hash VARCHAR(255) NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (id),
UNIQUE KEY email (email)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
Step 2: Connect to the database and set up sessions
config.php is included by every other file. It connects to the database, starts the session with a secure cookie, and defines two helpers: e() escapes anything you print into HTML (protecting against cross-site scripting), and the CSRF functions make sure forms can only be submitted from your own pages.
config.php
<?php
// Database settings: change these to match your server.
const DB_HOST = 'localhost';
const DB_USER = 'demo_user';
const DB_PASS = 'change-this-password';
const DB_NAME = 'demo';
// Throw exceptions on database errors (the default since PHP 8.1).
mysqli_report(MYSQLI_REPORT_ERROR | MYSQLI_REPORT_STRICT);
$db = new mysqli(DB_HOST, DB_USER, DB_PASS, DB_NAME);
$db->set_charset('utf8mb4');
// Secure session cookie, then start the session.
session_set_cookie_params([
'httponly' => true,
'secure' => !empty($_SERVER['HTTPS']),
'samesite' => 'Lax',
]);
session_start();
// Escape output for HTML.
function e(string $value): string
{
return htmlspecialchars($value, ENT_QUOTES, 'UTF-8');
}
// One CSRF token per session, checked on every form post.
function csrf_token(): string
{
if (empty($_SESSION['csrf'])) {
$_SESSION['csrf'] = bin2hex(random_bytes(32));
}
return $_SESSION['csrf'];
}
function csrf_check(): void
{
if (!hash_equals($_SESSION['csrf'] ?? '', $_POST['csrf'] ?? '')) {
http_response_code(400);
exit('Invalid form submission. Please go back and try again.');
}
}
Change the four database settings to match your server. On a live site, keep this file outside the public web folder if you can, or at least make sure it can never be downloaded as plain text.
Step 3: Build the registration form
The registration page validates the input, checks that the email isn’t already registered, hashes the password with password_hash() and saves the new user. Note three details:
- Prepared statements (
prepare+bind_param) keep user input out of the SQL, which prevents SQL injection. - Passwords are never trimmed or stored as-is.
PASSWORD_DEFAULTcurrently uses bcrypt and will move to stronger algorithms as PHP adds them. - Redirect after success. Sending the user to the login page after a successful POST stops a browser refresh from submitting the form twice.
register.php
<?php
require __DIR__ . '/config.php';
// Already logged in? Go to the members page.
if (isset($_SESSION['user_id'])) {
header('Location: welcome.php');
exit;
}
$errors = [];
$name = $email = '';
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
csrf_check();
$name = trim($_POST['name'] ?? '');
$email = trim($_POST['email'] ?? '');
$password = $_POST['password'] ?? '';
$confirm = $_POST['confirm_password'] ?? '';
if ($name === '') {
$errors[] = 'Please enter your name.';
}
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
$errors[] = 'Please enter a valid email address.';
}
if (strlen($password) < 8) {
$errors[] = 'Your password needs at least 8 characters.';
}
if ($password !== $confirm) {
$errors[] = 'The two passwords do not match.';
}
if (!$errors) {
$stmt = $db->prepare('SELECT id FROM users WHERE email = ?');
$stmt->bind_param('s', $email);
$stmt->execute();
$exists = $stmt->get_result()->num_rows > 0;
$stmt->close();
if ($exists) {
$errors[] = 'An account with this email already exists. Try logging in instead.';
} else {
$hash = password_hash($password, PASSWORD_DEFAULT);
$stmt = $db->prepare('INSERT INTO users (name, email, password_hash) VALUES (?, ?, ?)');
$stmt->bind_param('sss', $name, $email, $hash);
$stmt->execute();
$stmt->close();
// Redirect after a successful POST so a refresh doesn't resubmit the form.
header('Location: login.php?registered=1');
exit;
}
}
}
?>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Create an account</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css">
</head>
<body class="container py-5" style="max-width: 480px">
<h1 class="h3 mb-3">Create an account</h1>
<?php foreach ($errors as $error): ?>
<div class="alert alert-danger"><?= e($error) ?></div>
<?php endforeach; ?>
<form method="post" novalidate>
<input type="hidden" name="csrf" value="<?= e(csrf_token()) ?>">
<div class="mb-3">
<label for="name" class="form-label">Full name</label>
<input type="text" id="name" name="name" class="form-control" value="<?= e($name) ?>" autocomplete="name" required>
</div>
<div class="mb-3">
<label for="email" class="form-label">Email address</label>
<input type="email" id="email" name="email" class="form-control" value="<?= e($email) ?>" autocomplete="email" required>
</div>
<div class="mb-3">
<label for="password" class="form-label">Password (at least 8 characters)</label>
<input type="password" id="password" name="password" class="form-control" autocomplete="new-password" minlength="8" required>
</div>
<div class="mb-3">
<label for="confirm_password" class="form-label">Confirm password</label>
<input type="password" id="confirm_password" name="confirm_password" class="form-control" autocomplete="new-password" required>
</div>
<button type="submit" class="btn btn-primary w-100">Create account</button>
</form>
<p class="mt-3">Already have an account? <a href="login.php">Log in</a></p>
</body>
</html>

Step 4: Build the login form
The login page looks the user up by email and checks the password with password_verify(). Two security details matter here:
session_regenerate_id(true)gives the user a fresh session ID when they log in, which blocks session fixation attacks.- One error message for everything. “Email or password is incorrect” is shown for both an unknown email and a wrong password, so attackers can’t use the form to discover which emails have accounts.
login.php
<?php
require __DIR__ . '/config.php';
if (isset($_SESSION['user_id'])) {
header('Location: welcome.php');
exit;
}
$error = '';
$email = '';
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
csrf_check();
$email = trim($_POST['email'] ?? '');
$password = $_POST['password'] ?? '';
$stmt = $db->prepare('SELECT id, name, password_hash FROM users WHERE email = ?');
$stmt->bind_param('s', $email);
$stmt->execute();
$user = $stmt->get_result()->fetch_assoc();
$stmt->close();
if ($user && password_verify($password, $user['password_hash'])) {
// Upgrade the hash automatically if PHP's default algorithm changes.
if (password_needs_rehash($user['password_hash'], PASSWORD_DEFAULT)) {
$hash = password_hash($password, PASSWORD_DEFAULT);
$stmt = $db->prepare('UPDATE users SET password_hash = ? WHERE id = ?');
$stmt->bind_param('si', $hash, $user['id']);
$stmt->execute();
$stmt->close();
}
// New session ID on login prevents session fixation.
session_regenerate_id(true);
$_SESSION['user_id'] = $user['id'];
$_SESSION['user_name'] = $user['name'];
header('Location: welcome.php');
exit;
}
// Same message for a wrong email or a wrong password, so attackers
// can't find out which email addresses have accounts.
$error = 'Email or password is incorrect.';
}
?>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Log in</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css">
</head>
<body class="container py-5" style="max-width: 480px">
<h1 class="h3 mb-3">Log in</h1>
<?php if (isset($_GET['registered'])): ?>
<div class="alert alert-success">Your account is ready. Please log in.</div>
<?php endif; ?>
<?php if ($error): ?>
<div class="alert alert-danger"><?= e($error) ?></div>
<?php endif; ?>
<form method="post">
<input type="hidden" name="csrf" value="<?= e(csrf_token()) ?>">
<div class="mb-3">
<label for="email" class="form-label">Email address</label>
<input type="email" id="email" name="email" class="form-control" value="<?= e($email) ?>" autocomplete="email" required>
</div>
<div class="mb-3">
<label for="password" class="form-label">Password</label>
<input type="password" id="password" name="password" class="form-control" autocomplete="current-password" required>
</div>
<button type="submit" class="btn btn-primary w-100">Log in</button>
</form>
<p class="mt-3">No account yet? <a href="register.php">Create one</a></p>
</body>
</html>

Step 5: Protect members-only pages
Create auth.php and include it at the very top of any page that should only be visible to logged-in users. If there is no user in the session, the visitor is sent to the login page.
auth.php
<?php
require __DIR__ . '/config.php';
// Include this at the top of every members-only page.
if (!isset($_SESSION['user_id'])) {
header('Location: login.php');
exit;
}
Here is a simple members page that uses it. The user’s name is printed through e(), so even a name like <script> is displayed as harmless text.
welcome.php
<?php
require __DIR__ . '/auth.php';
?>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Welcome</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css">
</head>
<body class="container py-5" style="max-width: 480px">
<h1 class="h3">Hello, <?= e($_SESSION['user_name']) ?></h1>
<p>You are logged in. This page is only visible to members.</p>
<form method="post" action="logout.php">
<input type="hidden" name="csrf" value="<?= e(csrf_token()) ?>">
<button type="submit" class="btn btn-outline-secondary">Log out</button>
</form>
</body>
</html>

Step 6: Log out
Logging out clears the session data, deletes the session cookie and destroys the session. It only happens on a POST request with a valid CSRF token, so another website can’t log your users out just by linking to logout.php.
logout.php
<?php
require __DIR__ . '/config.php';
// Log out with a POST + CSRF token, so other sites can't log your users out with a link.
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
csrf_check();
$_SESSION = [];
$params = session_get_cookie_params();
setcookie(session_name(), '', time() - 3600, $params['path'], $params['domain'], $params['secure'], $params['httponly']);
session_destroy();
}
header('Location: login.php');
exit;
Test it
- Open
register.phpin your browser and create an account. You should land on the login page with a success message. - Try registering the same email again: you should see “An account with this email already exists”.
- Log in with a wrong password, then with the right one. The right one takes you to
welcome.php. - Log out, then open
welcome.phpdirectly: you should be sent back to the login page.
Troubleshooting
- “Access denied for user”: the database username or password in
config.phpis wrong, or the user has no rights on the database. - “Table ‘demo.users’ doesn’t exist”: run the SQL from step 1 in the same database named in
DB_NAME. - “Invalid form submission”: the session expired or cookies are blocked. Reload the form and try again.
- You are logged out on every page: something is printed before
session_start()runs. Make sure there is no blank line or space before<?phpin any file.
Before you use this on a live site
This code covers the essentials, but a production login system usually needs a few more pieces:
- HTTPS everywhere, so passwords and session cookies are never sent in plain text.
- Rate limiting on the login form (for example, a short delay after several failed attempts) to slow down password guessing.
- Email verification and password reset, using single-use, expiring tokens.
- “Remember me” only with a separate, securely stored token, never by saving the password in a cookie.
If you are building on WordPress or a framework such as Laravel, use its built-in authentication instead of writing your own: it already handles these details and gets security updates.
Code like this is fine for learning, for prototypes and for small internal tools with a few known users. Once real customers are logging in, switch to maintained code.
Frequently asked questions
Is md5 or sha1 safe for storing passwords?
No. They are far too fast, so stolen hashes can be cracked quickly. Always use PHP’s password_hash() and password_verify().
Should I use mysqli or PDO?
Both are secure when you use prepared statements. PDO works with many database types; mysqli is MySQL-only but slightly simpler for a small project like this one.
How do I stop SQL injection in a PHP login form?
Never put user input directly into SQL. Use prepared statements with bound parameters for every query, as in the code above.



























