Download the full source codeComplete working project · Setup guide included · Free for commercial use
Free ZIP
Almost every website with members needs the same three things: a login form, an accounts table, and some PHP to tie them together. That's what we're building here - a complete login system with PHP and MySQL that checks credentials with prepared statements, verifies passwords with PHP's built-in hashing, and keeps users logged in with sessions.
It's all plain PHP - no framework, nothing to install beyond a web server. By the end you'll have a styled login page, a protected home page, a profile page, and a logout script, with a follow-up registration tutorial to let people sign up. Prefer a finished product? The Advanced Package bundles account activation, remember me, an admin panel, and PDO and MVC versions.
To build a secure login system in PHP, hash the password with password_hash() when the user registers, then on login look the account up with a prepared statement, verify the submitted password with password_verify(), and on success regenerate the session ID and store the user's ID in $_SESSION. Prepared statements keep the query safe from SQL injection.
Contents
- Getting Started
- How the Login System Works
- Creating the Login Form with HTML and CSS
- Setting Up the MySQL Database
- Authenticating Users with PHP and MySQL
- Creating the Home Page
- Creating the Profile Page
- Creating the Logout Script
- PHP Login Security Best Practices
- Common Problems and How to Fix Them
- Frequently Asked Questions
1. Getting Started
Before we write any code, we need a local web server with PHP and MySQL running. If you already have one set up, skip ahead to the file structure below.
1.1. Requirements
- If you don't have a local web server yet, download and install XAMPP. It bundles Apache, PHP, MySQL (MariaDB), and phpMyAdmin in a single installer, which saves you from configuring each piece separately on a development machine.
- Any recent PHP version will do; the code in this tutorial works on PHP 7.4 all the way through PHP 8.4. The
mysqliextension needs to be enabled, which it is by default in XAMPP.
1.2. What You Will Learn in this Tutorial
- Form Design - Build a clean login form with HTML and CSS that posts credentials to the server.
- Prepared Statements - Query the database the safe way, so SQL injection has nothing to grab onto.
- Password Verification - Check a submitted password against the bcrypt hash stored in your database using
password_verify(). - Session Management - Keep users logged in across pages. Session data lives on the server and is tied to a unique ID stored in the browser as a cookie.
- Basic Validation - Make sure the form data actually exists before the server tries to work with it.
1.3. File Structure & Setup
Start your web server and create the files and directories we'll be working with.
- Open XAMPP Control Panel
- Next to the Apache module click Start
- Next to the MySQL module click Start
- Navigate to XAMPP's installation directory (C:\xampp)
- Open the htdocs directory
- Create the following directories and files:
File Structure
\-- phplogin
|-- index.php
|-- style.css
|-- authenticate.php
|-- logout.php
|-- home.php
|-- profile.php
Here's what each file is for:
- index.php - The login form itself, built with HTML and CSS, plus a check that redirects users who are already logged in.
- style.css - The stylesheet for every page in the system.
- authenticate.php - The heart of the system. It connects to MySQL, validates the submitted form, looks up the account, verifies the password, and creates the session.
- logout.php - Destroys the session and sends the user back to the login page.
- home.php - A simple protected home page for logged-in users.
- profile.php - Retrieves the user's account details from MySQL and displays them.
2. How the Login System Works
It helps to see the whole picture before writing the individual pieces. When someone logs in, this is the entire journey their request takes:
- The user enters a username and password into the form on index.php and hits the login button.
- The browser sends both values to authenticate.php in a POST request.
- PHP looks up the username in the accounts table using a prepared statement, so the input never touches the SQL string directly.
- If an account exists,
password_verify()compares the submitted password against the bcrypt hash stored in the database. The real password is never stored anywhere. - On a match, PHP regenerates the session ID and saves the user's ID and username in session variables. That session is what every protected page checks before letting the user in.
Logging out is just the reverse: destroy the session, and the protected pages no longer recognize the visitor. That's the whole trick: PHP user authentication boils down to a form, one SQL query, and a session.
3. Creating the Login Form with HTML and CSS
Let's start with the login page itself, the part your users will actually see. The form is plain HTML with a short PHP snippet at the top. If someone who's already logged in opens the login page, there's no point showing it to them again, so we redirect them to the home page instead.
Edit the index.php file with your favorite code editor and add the following code:
<?php
// We need to use sessions, so you should always initialize sessions using the below function
session_start();
// If the user is logged in, redirect to the home page
if (isset($_SESSION['account_loggedin'])) {
header('Location: home.php');
exit;
}
?>
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,minimum-scale=1">
<title>Login</title>
</head>
<body>
<div class="login">
<h1>Member Login</h1>
<form action="authenticate.php" method="post" class="form login-form">
<label class="form-label" for="username">Username</label>
<div class="form-group">
<svg class="form-icon-left" width="14" height="14" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512"><!--!Font Awesome Free 6.5.1 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free Copyright 2024 Fonticons, Inc.--><path d="M224 256A128 128 0 1 0 224 0a128 128 0 1 0 0 256zm-45.7 48C79.8 304 0 383.8 0 482.3C0 498.7 13.3 512 29.7 512H418.3c16.4 0 29.7-13.3 29.7-29.7C448 383.8 368.2 304 269.7 304H178.3z"/></svg>
<input class="form-input" type="text" name="username" placeholder="Username" id="username" required>
</div>
<label class="form-label" for="password">Password</label>
<div class="form-group mar-bot-5">
<svg class="form-icon-left" xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 448 512"><!--!Font Awesome Free 6.5.1 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free Copyright 2024 Fonticons, Inc.--><path d="M144 144v48H304V144c0-44.2-35.8-80-80-80s-80 35.8-80 80zM80 192V144C80 64.5 144.5 0 224 0s144 64.5 144 144v48h16c35.3 0 64 28.7 64 64V448c0 35.3-28.7 64-64 64H64c-35.3 0-64-28.7-64-64V256c0-35.3 28.7-64 64-64H80z"/></svg>
<input class="form-input" type="password" name="password" placeholder="Password" id="password" required>
</div>
<button class="btn blue" type="submit">Login</button>
<p class="register-link">Don't have an account? <a href="register.php" class="form-link">Register</a></p>
</form>
</div>
</body>
</html>
If we navigate to the index page in a browser (localhost/phplogin/index.php), it should resemble the following:
Functional, but not something you'd want on a real site. Let's fix that with CSS.
Add the following code to the style.css file:
* {
box-sizing: border-box;
font-family: system-ui, "Segoe UI", Roboto, Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol";
font-size: 16px;
}
body, html {
background-color: #f3f5f7;
margin: 0;
padding: 0;
}
h1, h2, h3, h4, h5, h6 {
margin: 0;
padding: 0;
color: #474b50;
}
.form {
display: flex;
flex-flow: column;
width: 100%;
}
.form .form-label {
display: block;
padding: 20px 0 10px 0;
font-weight: 500;
font-size: 14px;
color: #474b50;
}
.form .form-group {
display: flex;
position: relative;
justify-content: space-between;
align-items: center;
width: 100%;
}
.form .form-group .form-icon-left, .form .form-group .form-icon-right {
fill: #c1c6cb;
width: 40px;
position: absolute;
transform: translateY(-50%);
top: 50%;
pointer-events: none;
}
.form .form-group .form-icon-left {
left: 0;
}
.form .form-group .form-icon-left + .form-input {
padding-left: 40px;
}
.form .form-group .form-icon-right {
right: 0;
}
.form .form-group .form-icon-right + .form-input {
padding-right: 40px;
}
.form .form-group:focus-within .form-icon-left {
fill: #989fa8;
}
.form .form-input {
width: 100%;
height: 43px;
border: 1px solid #dee1e6;
padding: 0 15px;
border-radius: 4px;
color: #000;
}
.form .form-input::placeholder {
color: #989fa8;
}
.form .form-link {
color: #2a8eeb;
font-weight: 500;
text-decoration: none;
font-size: 14px;
}
.form .form-link:hover {
color: #136fc5;
}
.form p.register-link {
margin: 0;
padding: 20px 0 0 0;
font-size: 14px;
color: #6b7179;
}
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
text-decoration: none;
appearance: none;
cursor: pointer;
border: 0;
background-color: #3e7bd6;
color: #FFFFFF;
padding: 0 14px;
font-size: 14px;
font-weight: 600;
border-radius: 4px;
height: 42px;
box-shadow: 0px 0px 6px 1px rgba(45, 57, 68, 0.1);
}
.btn:hover {
background-color: #3172d3;
}
.login, .register {
display: flex;
flex-flow: column;
width: 400px;
max-width: 95%;
background-color: #ffffff;
box-shadow: 0px 0px 7px 1px rgba(45, 57, 68, 0.05);
border-radius: 5px;
margin: 100px auto;
padding: 35px;
}
.login h1, .register h1 {
text-align: center;
font-size: 24px;
font-weight: 500;
padding: 15px 0;
margin: 0;
}
.pad-1 { padding: 5px; }
.mar-1 { margin: 5px; }
.pad-2 { padding: 10px; }
.mar-2 { margin: 10px; }
.pad-3 { padding: 15px; }
.mar-3 { margin: 15px; }
.pad-4 { padding: 20px; }
.mar-4 { margin: 20px; }
.pad-5 { padding: 25px; }
.mar-5 { margin: 25px; }
.pad-bot-1 { padding-bottom: 5px; }
.pad-top-1 { padding-top: 5px; }
.pad-left-1 { padding-left: 5px; }
.pad-right-1 { padding-right: 5px; }
.pad-x-1 { padding-left: 5px; padding-right: 5px; }
.pad-y-1 { padding-top: 5px; padding-bottom: 5px; }
.mar-bot-1 { margin-bottom: 5px; }
.mar-top-1 { margin-top: 5px; }
.mar-left-1 { margin-left: 5px; }
.mar-right-1 { margin-right: 5px; }
.mar-x-1 { margin-left: 5px; margin-right: 5px; }
.mar-y-1 { margin-top: 5px; margin-bottom: 5px; }
.pad-bot-2 { padding-bottom: 10px; }
.pad-top-2 { padding-top: 10px; }
.pad-left-2 { padding-left: 10px; }
.pad-right-2 { padding-right: 10px; }
.pad-x-2 { padding-left: 10px; padding-right: 10px; }
.pad-y-2 { padding-top: 10px; padding-bottom: 10px; }
.mar-bot-2 { margin-bottom: 10px; }
.mar-top-2 { margin-top: 10px; }
.mar-left-2 { margin-left: 10px; }
.mar-right-2 { margin-right: 10px; }
.mar-x-2 { margin-left: 10px; margin-right: 10px; }
.mar-y-2 { margin-top: 10px; margin-bottom: 10px; }
.pad-bot-3 { padding-bottom: 15px; }
.pad-top-3 { padding-top: 15px; }
.pad-left-3 { padding-left: 15px; }
.pad-right-3 { padding-right: 15px; }
.pad-x-3 { padding-left: 15px; padding-right: 15px; }
.pad-y-3 { padding-top: 15px; padding-bottom: 15px; }
.mar-bot-3 { margin-bottom: 15px; }
.mar-top-3 { margin-top: 15px; }
.mar-left-3 { margin-left: 15px; }
.mar-right-3 { margin-right: 15px; }
.mar-x-3 { margin-left: 15px; margin-right: 15px; }
.mar-y-3 { margin-top: 15px; margin-bottom: 15px; }
.pad-bot-4 { padding-bottom: 20px; }
.pad-top-4 { padding-top: 20px; }
.pad-left-4 { padding-left: 20px; }
.pad-right-4 { padding-right: 20px; }
.pad-x-4 { padding-left: 20px; padding-right: 20px; }
.pad-y-4 { padding-top: 20px; padding-bottom: 20px; }
.mar-bot-4 { margin-bottom: 20px; }
.mar-top-4 { margin-top: 20px; }
.mar-left-4 { margin-left: 20px; }
.mar-right-4 { margin-right: 20px; }
.mar-x-4 { margin-left: 20px; margin-right: 20px; }
.mar-y-4 { margin-top: 20px; margin-bottom: 20px; }
.pad-bot-5 { padding-bottom: 25px; }
.pad-top-5 { padding-top: 25px; }
.pad-left-5 { padding-left: 25px; }
.pad-right-5 { padding-right: 25px; }
.pad-x-5 { padding-left: 25px; padding-right: 25px; }
.pad-y-5 { padding-top: 25px; padding-bottom: 25px; }
.mar-bot-5 { margin-bottom: 25px; }
.mar-top-5 { margin-top: 25px; }
.mar-left-5 { margin-left: 25px; }
.mar-right-5 { margin-right: 25px; }
.mar-x-5 { margin-left: 25px; margin-right: 25px; }
.mar-y-5 { margin-top: 25px; margin-bottom: 25px; }
The spacing utility classes at the bottom (.pad-* and .mar-*) aren't all used right away, but they'll come in handy as you extend the system. Feel free to adjust colors and fonts to match your site.
For the styles to take effect, we need to include the stylesheet in our index.php file, so add the following line to the head section:
<link href="style.css" rel="stylesheet" type="text/css">
Refresh the page and the form should look a lot more presentable:
Two details in the form markup matter more than anything else:
-
Form - The
actionattribute points to authenticate.php, which is where the browser sends the form data on submission. Themethodattribute is set topost, so the credentials travel in the request body rather than being appended to the URL (you really don't want passwords showing up in server logs and browser history).- Input (text/password) - Each field has a
nameattribute, and that name is how PHP finds the value on the server. The username field is namedusername, so in authenticate.php it's available as$_POST['username']. - Input (submit) - Clicking the button submits the form and triggers the whole authentication flow.
- Input (text/password) - Each field has a
4. Setting Up the MySQL Database
The login system needs somewhere to store accounts, so let's create the database next. Most people on XAMPP use phpMyAdmin for this, but any MySQL client works; the SQL is the same either way.
If you're using phpMyAdmin, follow these steps:
- In the XAMPP control panel, click Admin next to MySQL
- Wait for phpMyAdmin to open in your browser (localhost/phpmyadmin)
- Click the Databases tab at the top
- Under Create database, enter phplogin in the text box
- Select utf8mb4_unicode_ci as the collation
- Click Create
You can name the database whatever you like, but the code in this tutorial expects phplogin.
All we need inside it is a single accounts table to hold the usernames, hashed passwords, and email addresses of everyone who registers.
Select the database on the left side panel (phplogin) and execute the following SQL statement:
CREATE TABLE IF NOT EXISTS `accounts` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`username` varchar(50) NOT NULL,
`password` varchar(255) NOT NULL,
`email` varchar(100) NOT NULL,
`registered` datetime NOT NULL,
PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=2 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
INSERT INTO `accounts` (`id`, `username`, `password`, `email`, `registered`) VALUES (1, 'test', '$2y$10$SfhYIDtn.iOuCW7zfoFLuuZHX6lja4lF4XA4JqNmpiH/.P3zB8JCa', 'test@example.com', '2025-01-01 00:00:00');
In phpMyAdmin, this should resemble the following:
A couple of things worth pointing out. The password column is varchar(255), not 60, even though a bcrypt hash is exactly 60 characters today. PHP's hashing algorithms can change over time, and a longer column means new hash formats will fit without a schema change. Shortening this column is one of the most common ways people accidentally break their login system, because a truncated hash will never verify.
The INSERT statement also creates a test account with the username test and the password test. That long string starting with $2y$10$ is the bcrypt hash of the word test. We'll use this account to confirm everything works before the registration system exists.
5. Authenticating Users with PHP and MySQL
With the database ready, we can write the code that decides whether a login attempt succeeds. This lives in a dedicated file that processes the details submitted from the index.php form.
Edit the authenticate.php file and add the following PHP code. This is the complete file, top to bottom, so you can copy it in one go - the walkthrough below explains every part:
<?php
// Start the session
session_start();
// Change the below variables to reflect your MySQL database details
$DATABASE_HOST = 'localhost';
$DATABASE_USER = 'root';
$DATABASE_PASS = '';
$DATABASE_NAME = 'phplogin';
// Try and connect using the info above
$con = mysqli_connect($DATABASE_HOST, $DATABASE_USER, $DATABASE_PASS, $DATABASE_NAME);
// Check for connection errors
if (mysqli_connect_errno()) {
// If there is an error with the connection, stop the script and display the error
exit('Failed to connect to MySQL!');
}
// Now we check if the data from the login form was submitted, isset() will check if the data exists
if (!isset($_POST['username'], $_POST['password'])) {
// Could not get the data that should have been sent
exit('Please fill both the username and password fields!');
}
// Prepare our SQL, which will prevent SQL injection
if ($stmt = $con->prepare('SELECT id, password FROM accounts WHERE username = ?')) {
// Bind parameters (s = string, i = int, b = blob, etc), in our case the username is a string so we use "s"
$stmt->bind_param('s', $_POST['username']);
$stmt->execute();
// Store the result so we can check if the account exists in the database
$stmt->store_result();
// Check if account exists with the input username
if ($stmt->num_rows > 0) {
// Account exists, so bind the results to variables
$stmt->bind_result($id, $password);
$stmt->fetch();
// Note: remember to use password_hash in your registration file to store the hashed passwords
if (password_verify($_POST['password'], $password)) {
// Password is correct! User has logged in!
// Regenerate the session ID to prevent session fixation attacks
session_regenerate_id();
// Declare session variables (they basically act like cookies but the data is remembered on the server)
$_SESSION['account_loggedin'] = TRUE;
$_SESSION['account_name'] = $_POST['username'];
$_SESSION['account_id'] = $id;
// Redirect to the home page
header('Location: home.php');
exit;
} else {
// Incorrect password
echo 'Incorrect username and/or password!';
}
} else {
// Incorrect username
echo 'Incorrect username and/or password!';
}
// Close the prepared statement
$stmt->close();
}
?>
Quite a lot happens in that one file, so let's take it from the top.
The session. Everything starts with session_start(). HTTP forgets everything the moment a request completes, so sessions are how the site remembers who a user is between page loads. Without them, users would have to send their password with every page they visit.
The connection. Update the four database variables if your credentials differ from the defaults. On stock XAMPP the user is root with an empty password, which is fine locally and not fine on a live server.
The guard clause. If someone opens authenticate.php directly without submitting the form, the POST variables won't exist, so the isset() check stops the script before PHP starts throwing warnings.
The query. We select the id and password columns for the submitted username, but notice the ? placeholder. The username is bound separately with bind_param(), so MySQL treats it purely as data, never as SQL. Someone can type ' OR 1=1-- into your login form all day long and it will simply be looked up as a (nonexistent) username.
Tip Prepared statements only protect you if every user-supplied value goes through them. The moment you concatenate input into a query string, you're back to being injectable.
The password check. num_rows tells us whether the username exists at all; if it does, we bind and fetch the $id and $password variables. Then password_verify compares the password the user just typed against the hash from the database. It only works with hashes created by password_hash, which is what our registration system will use.
The error message. It's the same whether the username or the password was wrong. That's intentional: a more specific message would let an attacker confirm which usernames exist and focus on cracking just the passwords.
The success path. When both checks pass, we regenerate the session ID, store the user's ID and username in session variables, and redirect to the home page (built in the next step). Session data lives on the server, tied to the ID cookie in the browser, so users can't edit it; that's exactly why login state belongs there. Protect the ID itself by serving the site over HTTPS, or anyone on the same network can hijack the session.
Did you know?The session_regenerate_id() function helps prevent session fixation attacks. The user gets a brand-new session ID at the moment of login, so any ID an attacker planted beforehand becomes worthless.
If you're stuck with a legacy database of plain-text passwords that hasn't been migrated yet, you could temporarily replace the following line:
if (password_verify($_POST['password'], $password)) {
With:
if ($_POST['password'] === $password) {
But treat that as a temporary crutch, never a production setup. If your database ever leaks, every password in it is readable, and since people reuse passwords everywhere, the damage won't stop at your site. Hashing costs two function calls; skipping it can cost your users their email accounts.
One last note before we move on: we can't test a successful login quite yet, because it redirects to home.php, which doesn't exist until the next step. What you can already do is open index.php and submit a made-up username. The page should respond with Incorrect username and/or password! If you see Failed to connect to MySQL! or a blank page instead, jump ahead to the troubleshooting section.
6. Creating the Home Page
The home page is where users land after logging in, and it's our first protected page. The rule is simple: no session, no access. Anyone who tries to open it without logging in gets redirected straight back to the form.
Edit the home.php file and add the following code:
<?php
// We need to use sessions, so you should always initialize sessions using the below function
session_start();
// If the user is not logged in, redirect to the login page
if (!isset($_SESSION['account_loggedin'])) {
header('Location: index.php');
exit;
}
?>
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,minimum-scale=1">
<title>Home</title>
<link href="style.css" rel="stylesheet" type="text/css">
</head>
<body>
<header class="header">
<div class="wrapper">
<h1>Website Title</h1>
<nav class="menu">
<a href="home.php">Home</a>
<a href="profile.php">Profile</a>
<a href="logout.php">
<svg width="12" height="12" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><!--!Font Awesome Free 6.5.1 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free Copyright 2024 Fonticons, Inc.--><path d="M377.9 105.9L500.7 228.7c7.2 7.2 11.3 17.1 11.3 27.3s-4.1 20.1-11.3 27.3L377.9 406.1c-6.4 6.4-15 9.9-24 9.9c-18.7 0-33.9-15.2-33.9-33.9l0-62.1-128 0c-17.7 0-32-14.3-32-32l0-64c0-17.7 14.3-32 32-32l128 0 0-62.1c0-18.7 15.2-33.9 33.9-33.9c9 0 17.6 3.6 24 9.9zM160 96L96 96c-17.7 0-32 14.3-32 32l0 256c0 17.7 14.3 32 32 32l64 0c17.7 0 32 14.3 32 32s-14.3 32-32 32l-64 0c-53 0-96-43-96-96L0 128C0 75 43 32 96 32l64 0c17.7 0 32 14.3 32 32s-14.3 32-32 32z"/></svg>
Logout
</a>
</nav>
</div>
</header>
<div class="content">
<div class="page-title">
<div class="wrap">
<h2>Home</h2>
<p>Welcome back, <?=htmlspecialchars($_SESSION['account_name'], ENT_QUOTES)?>!</p>
</div>
</div>
<div class="block">
<p>This is the home page. You are logged in!</p>
</div>
</div>
</body>
</html>
The four lines of PHP at the top are the entire access control for the page. Remember the $_SESSION['account_loggedin'] variable we set in authenticate.php? This is where it pays off. If it isn't set, the visitor never logged in, so off to index.php they go. You'll add the same check to the top of every members-only page you ever create.
Coding Tip The isset() function checks whether a variable exists before you read it. Get in the habit of using it with anything that comes from $_POST, $_GET, or $_SESSION. It's the difference between a clean redirect and a page full of warnings.
In the HTML, the interesting line is the welcome message. It echoes $_SESSION['account_name'], the username we stored at login, wrapped in htmlspecialchars() so a username containing HTML can't inject anything into the page. Session data feels trustworthy, but it originally came from user input, so it gets escaped like everything else.
The home and profile pages share some styles we haven't written yet. Add the following code to the style.css file:
.header {
background-color: #333941;
height: 60px;
width: 100%;
}
.header .wrapper {
display: flex;
justify-content: space-between;
align-items: center;
position: relative;
margin: 0 auto;
width: 900px;
height: 100%;
}
.header .wrapper h1, .header .wrapper a {
display: inline-flex;
align-items: center;
}
.header .wrapper h1 {
font-size: 20px;
padding: 0;
margin: 0;
color: #fff;
font-weight: normal;
}
.header .wrapper .menu {
display: flex;
align-items: center;
}
.header .wrapper .menu a {
display: flex;
align-items: center;
justify-content: center;
height: 32px;
padding: 0 12px;
margin: 0 3px;
text-decoration: none;
color: #dddfe2;
font-weight: 500;
font-size: 16px;
line-height: 16px;
}
.header .wrapper .menu a svg {
fill: #dddfe2;
margin: 2px 8px 0 0;
}
.header .wrapper .menu a:hover, .header .wrapper .menu a:active {
color: #ebebec;
}
.header .wrapper .menu a:hover svg, .header .wrapper .menu a:active svg {
fill: #ebebec;
}
.header .wrapper .menu a:last-child {
margin-right: 0;
}
.content {
width: 900px;
margin: 0 auto;
}
.content .page-title {
display: flex;
align-items: center;
padding: 25px 0 10px 0;
}
.content .page-title h2 {
margin: 0;
padding: 0 0 7px 0;
font-size: 20px;
font-weight: 600;
color: #53585e;
}
.content .page-title p {
margin: 0;
padding: 0;
font-size: 14px;
color: #777e86;
}
.content .block {
box-shadow: 0px 0px 7px 1px rgba(45, 57, 68, 0.05);
margin: 25px 0;
padding: 25px;
border-radius: 5px;
background-color: #fff;
}
.content .block p {
padding: 7px;
margin: 0;
}
.content .profile-detail {
display: flex;
flex-flow: column;
font-size: 18px;
padding: 5px 0;
}
.content .profile-detail strong {
display: block;
color: #92979e;
font-size: 14px;
font-weight: 500;
margin-bottom: 2px;
}
Now for the moment of truth. Open index.php in your browser and log in with test for both the username and the password. You should land right here:
It's deliberately bare. This is the skeleton you'll hang your actual application on. If logging in bounces you back to the form instead, the session isn't surviving between pages - the troubleshooting section covers the usual causes.
7. Creating the Profile Page
The profile page displays the logged-in user's account details. The username and ID are already in the session, but the email address and registration date aren't, so this page also shows you the pattern for fetching extra account data from MySQL on any page that needs it.
Edit the profile.php file and add the following code:
<?php
// We need to use sessions, so you should always initialize sessions using the below function
session_start();
// If the user is not logged in, redirect to the login page
if (!isset($_SESSION['account_loggedin'])) {
header('Location: index.php');
exit;
}
// Change the below variables to reflect your MySQL database details
$DATABASE_HOST = 'localhost';
$DATABASE_USER = 'root';
$DATABASE_PASS = '';
$DATABASE_NAME = 'phplogin';
// Try and connect using the info above
$con = mysqli_connect($DATABASE_HOST, $DATABASE_USER, $DATABASE_PASS, $DATABASE_NAME);
// Ensure there are no connection errors
if (mysqli_connect_errno()) {
exit('Failed to connect to MySQL!');
}
// We don't have the email or registered info stored in sessions so instead we can get the results from the database
$stmt = $con->prepare('SELECT email, registered FROM accounts WHERE id = ?');
// In this case, we can use the account ID to get the account info
$stmt->bind_param('i', $_SESSION['account_id']);
$stmt->execute();
$stmt->bind_result($email, $registered);
$stmt->fetch();
$stmt->close();
?>
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,minimum-scale=1">
<title>Home</title>
<link href="style.css" rel="stylesheet" type="text/css">
</head>
<body>
<header class="header">
<div class="wrapper">
<h1>Website Title</h1>
<nav class="menu">
<a href="home.php">Home</a>
<a href="profile.php">Profile</a>
<a href="logout.php">
<svg width="12" height="12" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><!--!Font Awesome Free 6.5.1 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free Copyright 2024 Fonticons, Inc.--><path d="M377.9 105.9L500.7 228.7c7.2 7.2 11.3 17.1 11.3 27.3s-4.1 20.1-11.3 27.3L377.9 406.1c-6.4 6.4-15 9.9-24 9.9c-18.7 0-33.9-15.2-33.9-33.9l0-62.1-128 0c-17.7 0-32-14.3-32-32l0-64c0-17.7 14.3-32 32-32l128 0 0-62.1c0-18.7 15.2-33.9 33.9-33.9c9 0 17.6 3.6 24 9.9zM160 96L96 96c-17.7 0-32 14.3-32 32l0 256c0 17.7 14.3 32 32 32l64 0c17.7 0 32 14.3 32 32s-14.3 32-32 32l-64 0c-53 0-96-43-96-96L0 128C0 75 43 32 96 32l64 0c17.7 0 32 14.3 32 32s-14.3 32-32 32z"/></svg>
Logout
</a>
</nav>
</div>
</header>
<div class="content">
<div class="page-title">
<div class="wrap">
<h2>Profile</h2>
<p>View your profile details below.</p>
</div>
</div>
<div class="block">
<div class="profile-detail">
<strong>Username</strong>
<?=htmlspecialchars($_SESSION['account_name'])?>
</div>
<div class="profile-detail">
<strong>Email</strong>
<?=htmlspecialchars($email)?>
</div>
<div class="profile-detail">
<strong>Registered</strong>
<?=htmlspecialchars($registered)?>
</div>
</div>
</div>
</body>
</html>
The PHP at the top follows a pattern you've seen twice now: the same protection check as the home page, then a database connection, then one prepared statement. We bind $_SESSION['account_id'] as an integer this time ('i' instead of 's') and pull the email and registered columns for that account. There's no need to fetch the username or ID; those have been sitting in the session since login.
Navigate to profile.php while logged in and you'll see the account details laid out:
Want to show more fields later, like a display name, a bio, or an avatar path? Add the columns to the table, add them to the SELECT query, and bind them to variables. The pattern doesn't change.
8. Creating the Logout Script
Logging out is the easiest part of the whole system: destroy the session, and the user is a stranger again.
Edit the logout.php file and add the following code:
<?php
// Start the session
session_start();
// Destroy the active session, which logs the user out
session_destroy();
// Redirect to the login pag
header('Location: index.php');
exit;
?>
Because every protected page checks for $_SESSION['account_loggedin'], and session_destroy() wipes it, the user is locked out of members-only pages the instant this script runs. The logout link in the header of the home and profile pages already points here.
9. PHP Login Security Best Practices
What we've built is a solid, safe foundation: prepared statements, bcrypt password hashing, session regeneration, and escaped output already put you ahead of a frightening number of production sites. Before you take a system like this live, though, work through this list:
- Escape user data with
htmlspecialchars()whenever you print it into a page, even data that took a detour through the database or a session first. - Move the database credentials into a config file stored outside the webroot, so a server misconfiguration can't serve your password as plain text.
- Serve the site over HTTPS with a valid SSL certificate. Without it, credentials and session cookies travel the network in the clear.
- Harden your session settings; the PHP manual's session security page covers the INI options worth changing, like
session.cookie_httponly. - Disable error display in production with
error_reporting(0)and log errors to a file instead, because stack traces are a gift to attackers. - Add CSRF tokens to your forms so other sites can't submit them on a logged-in user's behalf.
- Consider rate limiting or temporary lockouts after repeated failed logins to slow brute-force attacks to a crawl.
- Never run XAMPP as a production server; it's a development environment with development defaults.
10. Common Problems and How to Fix Them
These are the issues that come up again and again in the comments. If your login system misbehaves, start here.
"Failed to connect to MySQL!"
The database connection itself is failing. Check that the MySQL module is actually running in the XAMPP control panel, and that the four $DATABASE_* variables match your setup. On a default XAMPP install the user is root with an empty password. If you changed MySQL's port from 3306, you'll need to append it to the hostname.
The correct username and password are rejected
Almost always a hashing problem. If you inserted the test account by copying the SQL from this page, make sure the full 60-character hash made it into the password column, because a truncated or partially pasted hash will never verify. The same applies later when you build registration: if the password column is shorter than the hash, MySQL silently cuts it off and every future login fails. Keep the column at varchar(255).
"Warning: Cannot modify header information - headers already sent"
PHP can only send a redirect before any output has gone to the browser. This warning means something was printed first, usually a space or blank line before the opening <?php tag, an echo above the header() call, or a file saved as UTF-8 with BOM. Remove the stray output (or re-save the file without BOM) and the redirect will work.
I log in successfully but home.php sends me back to the login form
The session isn't surviving between pages. Confirm that session_start() is the first thing called on every page that touches $_SESSION. It's needed on every page, not just the login one. If it's there, check that your browser accepts cookies for localhost and that PHP's session.save_path points to a directory the server can write to.
Blank white page or HTTP 500 error
A PHP error occurred but error display is switched off. While developing, put error_reporting(E_ALL) and ini_set('display_errors', 1) at the top of the script, reload, and PHP will tell you exactly which line to look at. Nine times out of ten it's a missing semicolon or an unclosed brace.
Frequently Asked Questions
Should I use MySQLi or PDO for a login system?
Either one. Security-wise they're equivalent as long as you use prepared statements, which both support. This tutorial uses MySQLi because it's enabled everywhere and the syntax is easy to follow. PDO's advantages are named placeholders and support for databases other than MySQL; if you'd like to see it in action, our CRUD tutorial is built on PDO, and the Advanced Package of this login system includes a PDO version.
How do I keep users logged in after they close the browser?
That's the classic remember me feature. The session cookie normally expires with the browser, so you add a second, long-lived cookie containing a random token, store a hash of that token in the database, and log the user back in automatically when the cookie and the stored hash match. Never put the password itself, or anything predictable, in the cookie. The Advanced Package includes a ready-made implementation.
Is password_hash() secure, and do I need to add my own salt?
Yes, and no. password_hash() uses bcrypt by default, generates a unique random salt for every password, and stores the salt inside the hash string itself, which is why password_verify() needs no separate salt column. Rolling your own salting scheme, or using md5() or sha1() for passwords, would make things worse, not better.
How do PHP login sessions work, and how are they different from cookies?
When a user logs in, PHP stores their data on the server and hands the browser a random session ID cookie. On every request, that ID reconnects the visitor with their data. A normal cookie can be read and edited by the user, but session data never leaves the server. That's why login state belongs in the session: a visitor can't simply edit account_loggedin to true.
How do I redirect users to different pages based on their role?
Add a role column to the accounts table (admin, member, and so on), select it in authenticate.php along with the id and password, and store it in a session variable after a successful login. Then each page can check the role and either render, redirect, or refuse. For anything beyond a couple of roles, look at the admin panel included in the Advanced Package for a working example.
How do I limit failed login attempts?
Track the number of failed attempts and the time of the last one, either per account or per IP address, and refuse further attempts for a cooldown period once a threshold is hit. Five failures and a fifteen-minute lockout is a common starting point. It's a small change that makes brute-forcing wildly impractical. The Advanced Package offers a brute force protection add-on if you'd rather not build it yourself.
How do I add a forgot password feature?
The safe pattern: the user enters their email address, you generate a random single-use token, store its hash with an expiry time, and email a reset link containing the token. When the link is opened you verify the token, let the user set a new password, and hash it with password_hash() before saving. Never email the old password; with proper hashing you couldn't anyway, which is rather the point.
Where can I download the full login source code?
Every line of the login system is on this page, free to copy: index.php, style.css, authenticate.php, home.php, profile.php, and logout.php. If you'd prefer a ready-made download, the Advanced Package includes the complete tutorial source code plus extra features like account activation, remember me, and an admin panel.
How do I make a PHP login form secure?
Five habits cover most of it: run every query through prepared statements, hash passwords with password_hash(), serve the site over HTTPS, regenerate the session ID at login, and keep error messages generic so attackers can't probe for valid usernames. Add rate limiting on failed attempts and CSRF tokens, and you're ahead of the vast majority of login pages on the web. Section 9 walks through the full checklist.
Conclusion
That's a complete login system with PHP and MySQL: form, database, authentication, protected pages, and logout. More importantly, you now know why each piece is there: prepared statements to shut out SQL injection, bcrypt hashing so a database leak doesn't spill passwords, session regeneration against fixation attacks, and identical error messages that keep attackers guessing. The whole login script is yours to reuse in your own projects.
Right now there's just one test account, so the obvious next step is letting visitors sign up themselves. That's the next tutorial in this series: Secure Registration System with PHP and MySQL. It reuses everything you built today.
If this tutorial helped you, share it with someone else who's stuck, and if something isn't working, leave a comment below and we'll figure it out.
Thank you for reading, and enjoy coding!
Skip the copy-pasting and get the complete, working project from this tutorial as a free ZIP with a setup guide included. Free for personal and commercial use.
If you would like to support us, consider the advanced secure login & registration system below. It will greatly help us create more tutorials and keep our website up and running. The advanced package includes improved code and more features.
— CSRF Protection
— Brute Force Protection
— reCAPTCHA v3 Protection
— Two-Factor Authentication
— OAuth Login (Google, Facebook, and more)
— View Dashboard
— Create, edit, and delete accounts
— Search, sort, and filter accounts
— Manage Email Templates
— Edit Settings
— Export & Import Accounts (CSV, JSON, etc.)
* Advanced package also includes the tutorial source and basic package.
* Instant download after payment.
To learn more about the advanced package, please visit the Advanced Secure Login & Registration System page.