DEV Community

Aya ait el hachmi
Aya ait el hachmi

Posted on

Building a Secure PHP Authentication System — FashionMood Tutorial

Your outfit, according to your mood
Stack: PHP 8 + PDO | MySQL | Bootstrap 5 | JavaScript
1. Introduction
FashionMood is a personalized fashion web app for women. The concept is simple: each user creates her own style universe by signing up, takes a style quiz, and receives outfit suggestions tailored to her profile and daily mood.
This tutorial walks through the complete authentication system we built from scratch — covering database design, secure registration, login with sessions and cookies, page protection, the style quiz (JavaScript + PHP), and logout.

🎯 What you will learn: Secure PHP authentication with PDO, password hashing, session management, cookie-based remember me, SQL injection prevention, and a JavaScript + PHP quiz system.

2. Project Architecture
2.1 File Structure
index.php — Landing page (HTML/CSS/Bootstrap)
register.php — User registration with validation
login.php — Secure login with sessions and cookies
quiz.php — Style quiz (JavaScript + PHP)
dashbord.php — Personalized user dashboard
logout.php — Session and cookie destruction
fashion.php — PDO database connection
2.2 Database — Table: users
A single table stores all user information:
ColumnTypeDescriptionidINTPrimary key, auto-incrementusernameVARCHAR(50)Unique usernameemailVARCHAR(100)Unique email addresspasswordVARCHAR(255)Bcrypt hashed passwordtelephoneVARCHAR(20)Phone number (+212 format)ageTINYINTAge between 13 and 60hijabTINYINT(1)0 = without hijab, 1 = with hijabstyle_resultVARCHAR(50)Style quiz result (NULL until quiz done)quiz_doneTINYINT(1)0 = not done, 1 = completed

3. Step 1 — Database Connection (fashion.php)
We use PDO (PHP Data Objects) for a secure, object-oriented database connection. PDO supports prepared statements which prevent SQL injection attacks.
php<?php
$host = "localhost";
$dbname = "FashionMood";
$user = "root";
$pass = "";

try {
$pdo = new PDO(
"mysql:host=$host;dbname=$dbname;charset=utf8",
$user, $pass
);
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
} catch (PDOException $e) {
die("Erreur : " . $e->getMessage());
}
?>

💡 Why PDO? PDO supports prepared statements that protect against SQL injection. It also supports multiple database systems (MySQL, PostgreSQL, SQLite) with the same API, making your code more portable.

Best practices applied:

Always set charset=utf8 to support special characters
Use ERRMODE_EXCEPTION so errors throw exceptions instead of silently failing
Never display raw error messages in production — use a generic message instead

4. Step 2 — User Registration (register.php)
The registration page collects user information, validates everything server-side, hashes the password, and inserts securely using PDO.
4.1 Server-Side Validation
We validate every field before touching the database:
phpif (empty($username))
$errors["username"] = "Username is required.";

if (empty($email))
$errors["email"] = "Email is required.";
elseif (!filter_var($email, FILTER_VALIDATE_EMAIL))
$errors["email"] = "Invalid email.";

if (!preg_match('/^+212\s?[67]\d{8}$/', $telephone))
$errors["telephone"] = "Invalid phone number.";

if (strlen($password) < 8)
$errors["password"] = "Min 8 characters.";

if ($password !== $confirm)
$errors["confirm"] = "Passwords do not match.";

⚠️ Lesson learned: Client-side validation (HTML required, minlength) is not enough — users can bypass it using browser DevTools or by sending direct HTTP requests. Server-side validation in PHP is always mandatory.

4.2 Password Hashing
Passwords are NEVER stored in plain text. We use PHP's built-in password_hash() function:
php// Hash the password with bcrypt (PASSWORD_DEFAULT)
$hash = password_hash($password, PASSWORD_DEFAULT);

// password_hash() automatically:
// - Applies bcrypt algorithm
// - Generates a unique random salt
// - Includes the salt in the resulting hash string
4.3 Secure Database Insert
We use named PDO parameters and htmlspecialchars() to prevent both SQL injection and XSS attacks:
php$sql = "INSERT INTO users"
. " (username, email, password, telephone, age, hijab, style_result, quiz_done)"
. " VALUES (:username, :email, :password, :telephone, :age, :hijab, :style_result, :quiz_done)";

$stmt = $pdo->prepare($sql);
$stmt->execute([
":username" => htmlspecialchars($username),
":email" => htmlspecialchars($email),
":password" => $hash,
":telephone" => htmlspecialchars($telephone),
":age" => (int)$age,
":hijab" => $hijab,
":style_result" => null, // Will be set after quiz
":quiz_done" => 0, // 0 = quiz not done yet
]);

🔒 Security note: Never concatenate variables directly into SQL strings. Always use prepared statements with named parameters (:param) or question marks (?). This is the primary defense against SQL injection.

5. Step 3 — Login System (login.php)
5.1 Credential Verification
We fetch the user by email, then verify the password against its stored hash:
php$stmt = $pdo->prepare("SELECT * FROM users WHERE email = ?");
$stmt->execute([$email]);
$user = $stmt->fetch();

if ($user && password_verify($password, $user["password"])) {
// Login successful
} else {
$errors["global"] = "Email or password incorrect.";
}

💡 Why password_verify()? This function securely compares a plain text password with a bcrypt hash. It automatically extracts and uses the salt embedded in the hash string — you never need to handle the salt manually.

5.2 Session Management
After successful login, we store the user's data in PHP session variables:
phpsession_start();
$_SESSION["id"] = $user["id"];
$_SESSION["username"] = $user["username"];
$_SESSION["email"] = $user["email"];
$_SESSION["telephone"] = $user["telephone"];
$_SESSION["age"] = $user["age"];
$_SESSION["hijab"] = $user["hijab"];
$_SESSION["style_result"] = $user["style_result"]; // Critical!

⚠️ Lesson learned: We initially forgot to store style_result in the session during login. This caused the dashboard to show NULL even after the quiz was completed. The fix: always load all relevant user data into session at login time.

5.3 Remember Me Feature
We use a cookie to remember the user's email for 30 days:
phpif (isset($_POST["remember"])) {
// Set cookie for 30 days
setcookie("remember_email", $email, time() + (30 * 24 * 60 * 60), "/");
} else {
// Remove cookie if not checked
setcookie("remember_email", "", time() - 3600, "/");
}

⚠️ Bug we encountered: setcookie() was placed before the $email variable was defined, causing an "undefined variable" error. The fix: always place setcookie() inside the POST block, after $email has been assigned.

5.4 Smart Redirect Based on quiz_done
After login, we check whether the user has already completed the quiz:
phpif ($user["quiz_done"] == 1) {
header("Location: dashbord.php"); // Already done — go to dashboard
} else {
header("Location: quiz.php"); // First time — take the quiz
}
exit();

💡 Why exit() after header()? PHP continues executing code after header() unless you explicitly stop it. Always call exit() immediately after a redirect to prevent unintended code execution.

6. Step 4 — Protecting Pages
Every protected page starts with the same security check:
php<?php
session_start();
if (!isset($_SESSION["id"])) {
header("Location: login.php");
exit();
}
?>

⚠️ Lesson learned: We initially used different session variable names across files — $_SESSION["id"] in some places and $_SESSION["user_id"] in others. This mismatch caused pages to incorrectly redirect logged-in users back to login. Fix: use the exact same session variable names across ALL files.

7. Step 5 — Style Quiz (quiz.php)
The quiz combines JavaScript (client-side interactivity) and PHP (server-side processing).
7.1 JavaScript — Interactive Questions
JavaScript displays questions one at a time, collects answers, and calculates the winning style:
javascriptlet current = 0; // Current question index (0-3)
const answers = []; // Stores user answers

function selectOpt(val, el) {
answers[current] = val;
document.querySelectorAll(".opt").forEach(o => o.classList.remove("sel"));
el.classList.add("sel");
}

// Calculate winner in JavaScript
function showResult() {
const count = {};
answers.forEach(v => count[v] = (count[v] || 0) + 1);
const winner = Object.entries(count).sort((a, b) => b[1] - a[1])[0][0];
// Display result + hidden form to send to PHP
}
7.2 Hidden Inputs — The Bridge Between JS and PHP
JavaScript stores answers in the browser. To send them to PHP, we use hidden form inputs:
html






Découvrir mes tenues →

7.3 PHP — Validation and Saving
PHP receives the POST data, validates it, calculates the winner, and saves to the database:
php// Reset old result each time quiz is taken
unset($_SESSION["style_result"]);

// Validate — only accept known values
$valeurs_autorisees = ["romantique", "elegante", "naturelle", "casual"];
if (!in_array($Q1, $valeurs_autorisees) || ...) {
$erreur = "Réponse invalide";
}

// Calculate winning style
$votes = [$Q1, $Q2, $Q3, $Q4];
$count = array_count_values($votes); // Count votes per style
arsort($count); // Sort descending
$winner = array_key_first($count); // Get the top style

// Save to session and database
$_SESSION["style_result"] = $winner;
$stmt = $pdo->prepare(
"UPDATE users SET style_result = :style, quiz_done = 1 WHERE id = :id"
);
$stmt->execute([":style" => $winner, ":id" => $_SESSION["id"]]);
header("Location: dashbord.php");
exit;

⚠️ Bug we encountered: Clicking "Découvrir mes tenues" returned HTTP 405 error. The cause: quiz.php had no PHP POST handler. The fix: add the complete PHP POST processing block at the top of the file.

8. Step 6 — Logout (logout.php)
Logout must destroy both the session AND the remember-me cookie:
php<?php
session_start();

// Delete the remember me cookie
setcookie("remember_email", "", time() - 3600, "/");

// Destroy the session
session_unset();
session_destroy();

header("Location: login.php");
exit();
?>

💡 Why set the cookie time to the past? Setting a cookie's expiry time to time() - 3600 (one hour in the past) tells the browser to immediately delete it. This is the standard way to remove cookies in PHP.

9. Best Practices & Lessons Learned
9.1 Security Checklist

Never store passwords in plain text — always use password_hash()
Always use PDO prepared statements — never concatenate SQL strings
Validate ALL data server-side (PHP), not just client-side (HTML/JS)
Always call exit() immediately after header("Location: ...")
Use htmlspecialchars() when displaying user data to prevent XSS
Use in_array() to validate form values against a whitelist

9.2 Session Best Practices

Call session_start() before any HTML output
Use consistent session variable names across ALL files — mismatches cause silent bugs
Always destroy both the session AND the cookie on logout
Load all necessary user data into session at login time

9.3 The 4 Real Bugs We Fixed

BugSolution1HTTP 405 on quiz submit buttonAdded complete PHP POST handler to quiz.php2style_result always NULL in databaseAdded $_SESSION["style_result"] to login.php after successful auth3Session variable mismatch (id vs user_id)Standardized to $_SESSION["id"] across all files4Remember Me — $email undefined errorMoved setcookie() inside the POST block, after $email is defined

10. Conclusion
FashionMood implements a complete, production-ready authentication system using PHP and MySQL. The features covered in this tutorial include:

✅ Secure registration with server-side validation
✅ Bcrypt password hashing
✅ PDO prepared statements
✅ PHP sessions
✅ Cookie-based remember me
✅ Smart redirects
✅ Page protection
✅ JavaScript + PHP quiz system

The bugs we encountered — and fixed — taught us more than the code itself. Real debugging experience is irreplaceable.

🔒 Security: password_hash, PDO | 📦 Sessions: $_SESSION, cookies | 🗄️ Database: PDO, MySQL | ⚡ JavaScript: Quiz + hidden inputs

Top comments (0)