Le projet : mettre une porte avec une serrure
On sait recevoir (projet 10), on sait stocker (projet 11). Mais tout ce qu'on a fait jusqu'ici était ouvert à tous. La question suivante tombe sous le sens : et si une partie de l'appli ne devait être visible qu'après s'être identifié ? Il faut une connexion.
C'est le sujet le plus universel du web, et aussi l'un des plus ratés. On va faire une vraie page de login en PHP : un email, un mot de passe, et derrière, un espace qui n'apparaît qu'une fois authentifié. Le cœur du sujet tient en une phrase : on ne stocke jamais un mot de passe, on stocke son empreinte. Le reste de la leçon explique pourquoi, et tout ce que l'IA oublie au passage.
Comme les projets 10 et 11, ça tourne sur un serveur PHP. Et un avertissement : ce projet ne stocke rien (le compte de démo est en dur), parce qu'une vraie page d'inscription publique demande des protections qu'on ne veut pas improviser dans une démo. On se concentre sur le geste qui compte : vérifier un mot de passe correctement.
Le journal de bord (la vraie vie)
Comme pour les deux projets serveur précédents, voici la boucle telle qu'elle s'est passée : ce qui a marché, et les deux endroits où l'IA m'a tendu un piège.
Prompt 1 — on demande, large
En PHP, fais-moi une page de connexion : un email, un mot de passe, et si c'est bon, on affiche une page « espace membre » réservée.
Ça marche du premier coup. Mais en lisant le code, deux choses me glacent : le mot de passe est enregistré tel quel (en clair), et il est comparé avec ==.
// ⚠️ le mot de passe est stocké EN CLAIR, et comparé avec ==
if ($email === $u['email'] && $_POST['pass'] == $u['password']) {
$_SESSION['user'] = $email; // connecté
}
Pourquoi c'est grave ? Deux raisons.
Le stockage en clair : les bases de données fuitent, c'est une certitude statistique, pas une hypothèse. Le jour où celle-ci fuit, tous les mots de passe sont lisibles d'un coup. Et comme les gens réutilisent le même mot de passe partout, tu n'as pas seulement compromis ton site : tu as offert leur boîte mail et leur banque.
Le == : en PHP, == est laxiste et fait des conversions surprenantes. Avec des hash mal choisis (md5), certaines chaînes comme "0e1234..." sont vues comme le nombre zéro, et deux mots de passe différents finissent « égaux ». C'est le piège des « magic hashes ». Pour un mot de passe, == n'est jamais le bon outil.
Prompt 2 — hacher comme il faut
Ne stocke jamais le mot de passe en clair. À l'inscription, range son empreinte avec password_hash(). À la connexion, vérifie avec password_verify(). Et n'utilise pas == pour comparer.
Voilà le bon geste. password_hash() calcule une empreinte à sens unique (avec un sel aléatoire intégré, automatiquement). On range cette empreinte, jamais le mot de passe. À la connexion, password_verify() répond juste « oui, ce mot de passe correspond à cette empreinte » ou « non », en temps constant (pas de piège ==).
// à l'inscription : on range l'EMPREINTE (≈ 60 caractères, ex: $2y$12$...)
$hash = password_hash($pass, PASSWORD_DEFAULT);
// à la connexion : on vérifie le mot de passe tapé CONTRE l'empreinte
if (password_verify($_POST['pass'], $u['hash'])) {
session_regenerate_id(true); // on y revient juste après
$_SESSION['user'] = $email; // connecté
}
Le réflexe — ne pas bavarder sur les erreurs
Petit détail qui change tout : quand la connexion échoue, l'IA affichait deux messages différents, « cet email n'existe pas » ou « mot de passe incorrect ». Pratique pour l'utilisateur… et pour l'attaquant, qui apprend ainsi quels emails sont inscrits (c'est l'énumération de comptes). On remet un seul message, identique dans les deux cas : « Identifiants invalides ».
Le bug bête — le bon mot de passe est refusé
Une fois branché sur une vraie base, surprise : même avec le bon mot de passe, password_verify() répond toujours « non ». Une heure de doute sur mon code… qui était bon. Le coupable : la colonne SQL qui stocke le hash était déclarée VARCHAR(20). Or une empreinte bcrypt fait 60 caractères. La base la tronquait silencieusement à 20, et un hash tronqué ne correspond plus à rien.
-- ⚠️ trop court : le hash de 60 caractères est tronqué → password_verify échoue toujours
password VARCHAR(20)
-- ✅ assez large pour l'empreinte (et pour les futurs algos de hachage)
password VARCHAR(255) -- ou simplement TEXT
Encore un bug qui ne se voit pas dans la logique PHP : il vient du schéma de la base. Le code disait la vérité, c'est le tuyau qui rabotait la donnée. On ne le trouve qu'en se demandant « et si la donnée stockée n'était pas exactement celle que je crois ? » et en regardant ce qu'il y a vraiment en base.
Et la ligne session_regenerate_id(true) ? Juste après une connexion réussie, on change l'identifiant de session. Sinon, un attaquant qui aurait réussi à fixer l'ID de session d'une victime avant qu'elle se connecte garderait la main une fois qu'elle est authentifiée (la « session fixation »). Un réflexe d'auth à ne jamais oublier.
Bilan : deux failles de fond (mot de passe en clair, comparaison ==), un réflexe (erreurs muettes), un bug de schéma (la colonne trop courte). La connexion qui en sort est courte, mais elle est juste.
Les réflexes « authentification » à retenir
Trois principes, vrais dans tous les langages et tous les frameworks.
1. Un mot de passe ne se stocke jamais, il se hache
On range l'empreinte calculée par password_hash() (bcrypt ou argon2), jamais le mot de passe, jamais un md5/sha1 « maison ». Le hachage est à sens unique et salé automatiquement : même si la base fuite, les mots de passe restent hors de portée. À la connexion, c'est password_verify() qui tranche.
2. Comparer en sécurité, et ne rien révéler
Jamais de == sur un mot de passe ou un hash (type juggling). password_verify() compare en temps constant. Et en cas d'échec, un seul message générique (« identifiants invalides »), qu'on se trompe d'email ou de mot de passe : sinon on offre la liste des comptes existants à qui sait lire (énumération).
3. Protéger la session, ralentir les attaques
Après une connexion réussie, session_regenerate_id(true) (anti-fixation). La colonne qui stocke le hash doit être assez longue (≥ 255). Et comme un robot peut tester des milliers de mots de passe, on limite les tentatives (un compteur, un délai après plusieurs échecs) : c'est ce qui transforme un mot de passe faible en cible coûteuse.
Tester (comme un attaquant, pas comme un gentil utilisateur)
- Connecte-toi avec le compte de démo : tu dois voir l'espace réservé, puis pouvoir te déconnecter.
- Tape un mauvais mot de passe, puis un email inexistant : le message d'erreur doit être exactement le même dans les deux cas.
- Une fois connecté, recharge (F5) : pas de renvoi du formulaire (grâce à la redirection).
- Déconnecte-toi, puis essaie de revenir sur l'espace réservé : il ne doit plus s'afficher.
Le rendu final
La connexion, en vrai, tourne ici. Le compte de démo est affiché dans le formulaire. Essaie aussi de te tromper exprès.
Le code complet
Le fichier PHP entier, exactement celui qui tourne au-dessus. Comme aux projets 10 et 11, on ne le « télécharge » pas : un fichier PHP doit être exécuté par un serveur.
Voir le code complet (212 lignes)
<?php
// ============================================================================
// CONNEXION SÉCURISÉE — un seul fichier PHP, qui gère un VRAI login.
//
// On ne stocke JAMAIS un mot de passe en clair. On stocke son EMPREINTE
// (un hash), calculée par password_hash(). À la connexion, on ne compare pas
// les mots de passe : on demande à password_verify() si le mot de passe tapé
// correspond à l'empreinte stockée. Le hash est à sens unique : même si la base
// fuite, on ne peut pas en retrouver les mots de passe.
//
// PLAN DU FICHIER :
// 1) Session + jeton CSRF
// 2) "Base" des utilisateurs : un compte de démo, avec son hash bcrypt stocké
// (PAS le mot de passe en clair). C'est exactement ce qu'aurait une vraie base.
// 3) Déconnexion (logout)
// 4) Traitement du login : CSRF, validation, password_verify, anti-énumération,
// régénération de l'ID de session (anti-fixation), redirection (PRG)
// 5) Le HTML : soit l'espace connecté, soit le formulaire de connexion
//
// Démo : aucune inscription, aucune écriture. Le compte de démo est affiché à
// l'écran pour que tu puisses te connecter et voir l'espace protégé.
// ============================================================================
session_start(); // tout en haut, AUCUN espace ni ligne vide avant <?php
// --- 1) Jeton CSRF ----------------------------------------------------------
if (empty($_SESSION['login_csrf'])) {
$_SESSION['login_csrf'] = bin2hex(random_bytes(16));
}
$csrf = $_SESSION['login_csrf'];
// --- Langue (fr par défaut) -------------------------------------------------
$lang = ((($_REQUEST['lang'] ?? 'fr')) === 'en') ? 'en' : 'fr';
$S = [
'fr' => [
'title' => "Connexion sécurisée", 'sub' => "Un vrai login : mot de passe haché, jamais stocké en clair.",
'email' => "Email", 'pass' => "Mot de passe", 'login' => "Se connecter", 'logout' => "Se déconnecter",
'ph_email' => "ton@email.fr", 'ph_pass' => "Ton mot de passe",
'err_csrf' => "Jeton de sécurité invalide. Recharge la page et réessaie.",
'err_fields' => "Remplis l'email et le mot de passe.",
// ANTI-ÉNUMÉRATION : un seul message, qu'on se trompe d'email OU de mot de passe.
'err_bad' => "Identifiants invalides. Réessaie.",
'welcome' => "Tu es connecté ✓", 'welcome_text' => "Bienvenue ! Cette zone n'est visible qu'une fois authentifié. Dans une vraie appli, c'est ici que vivrait le tableau de bord.",
'as' => "Connecté en tant que",
'demo_hint' => "Compte de démo : <strong>demo@exemple.fr</strong> / <strong>motdepasse123</strong>",
'note' => "Le mot de passe est vérifié avec password_verify() contre une empreinte. On ne le compare jamais en clair.",
'credit' => 'Login PHP sur un serveur · Mini-projet du cours <a href="/apprendre/projets/">Projets appliqués</a>',
],
'en' => [
'title' => "Secure login", 'sub' => "A real login: hashed password, never stored in clear.",
'email' => "Email", 'pass' => "Password", 'login' => "Log in", 'logout' => "Log out",
'ph_email' => "you@email.com", 'ph_pass' => "Your password",
'err_csrf' => "Invalid security token. Reload the page and try again.",
'err_fields' => "Fill in the email and the password.",
'err_bad' => "Invalid credentials. Try again.",
'welcome' => "You are logged in ✓", 'welcome_text' => "Welcome! This area is only visible once authenticated. In a real app, this is where the dashboard would live.",
'as' => "Logged in as",
'demo_hint' => "Demo account: <strong>demo@exemple.fr</strong> / <strong>motdepasse123</strong>",
'note' => "The password is checked with password_verify() against a hash. We never compare it in clear.",
'credit' => 'PHP login on a server · A mini-project from the <a href="/apprendre/projets/">Applied projects</a> course',
],
][$lang];
function e($s) { return htmlspecialchars((string)$s, ENT_QUOTES, 'UTF-8'); }
// --- 2) "Base" des utilisateurs --------------------------------------------
// En vrai ce serait une table SQL. Ce qu'on y stocke, c'est l'EMPREINTE du mot
// de passe (60 caractères, calculée une fois à l'inscription), JAMAIS le mot de
// passe lui-même. Ce hash est celui de "motdepasse123".
$users = [
'demo@exemple.fr' => '$2y$12$MjVS8JCDniAL27YSMae4UOthBPM2M8vl4042BwII3y0KGLx3N9QCa',
];
// --- 3) Déconnexion ---------------------------------------------------------
if (($_GET['logout'] ?? '') === '1') {
unset($_SESSION['auth_user']);
session_regenerate_id(true); // on repart sur une session neuve
header('Location: ?lang=' . $lang);
exit;
}
$error = '';
// --- 4) Traitement de la connexion ------------------------------------------
if ($_SERVER['REQUEST_METHOD'] === 'POST' && empty($_SESSION['auth_user'])) {
if (!isset($_POST['csrf']) || !hash_equals($csrf, $_POST['csrf'])) {
$error = $S['err_csrf'];
} else {
$email = trim($_POST['email'] ?? '');
$pass = (string)($_POST['pass'] ?? '');
if ($email === '' || $pass === '') {
$error = $S['err_fields'];
} else {
// On récupère l'empreinte stockée pour cet email (ou null si inconnu).
$hash = $users[$email] ?? null;
// password_verify : compare le mot de passe tapé à l'empreinte, en
// temps constant. Si l'email est inconnu, $hash est null → false.
if ($hash !== null && password_verify($pass, $hash)) {
// SUCCÈS. On régénère l'ID de session : un attaquant qui aurait
// "fixé" l'ancien ID avant la connexion ne peut plus s'en servir
// (parade contre la "session fixation").
session_regenerate_id(true);
$_SESSION['auth_user'] = $email;
header('Location: ?lang=' . $lang); // PRG : pas de re-POST au refresh
exit;
}
// ÉCHEC : message identique pour "email inconnu" et "mauvais mot de
// passe". On ne révèle pas quels comptes existent (anti-énumération).
$error = $S['err_bad'];
}
}
}
$logged = $_SESSION['auth_user'] ?? null;
?>
<!DOCTYPE html>
<html lang="<?= e($lang) ?>">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title><?= e($S['title']) ?></title>
<meta name="robots" content="noindex, follow">
<link rel="icon" type="image/svg+xml" href="/assets/favicon.svg">
<style>
:root { --ink:#1a1d24; --muted:#5a6270; --accent:#267d42; --border:#e2e6ea; --warn:#a8341f; }
* { box-sizing:border-box; }
html,body { margin:0; padding:0; }
body { font-family:'Segoe UI',system-ui,-apple-system,Roboto,Helvetica,Arial,sans-serif; background:#f4f6f8; color:var(--ink); min-height:100vh; display:flex; flex-direction:column; align-items:center; padding:32px 18px 48px; line-height:1.5; }
.app { width:100%; max-width:460px; }
header { text-align:center; margin-bottom:18px; }
h1 { font-size:1.5rem; margin:0 0 6px; letter-spacing:-0.01em; }
.sub { color:var(--muted); font-size:0.95rem; margin:0 0 16px; }
.lang-switch { display:inline-flex; border:1.5px solid var(--border); border-radius:999px; overflow:hidden; background:#fff; }
.lang-btn { border:0; background:transparent; padding:7px 18px; font:inherit; font-weight:700; font-size:0.8rem; color:var(--muted); cursor:pointer; text-decoration:none; }
.lang-btn.active { background:var(--accent); color:#fff; }
.lang-btn:focus-visible { outline:3px solid rgba(38,125,66,0.4); outline-offset:2px; }
form, .card { background:#fff; border:1px solid var(--border); border-radius:14px; padding:22px; }
label { display:block; font-weight:700; font-size:0.9rem; margin:14px 0 6px; }
label:first-child { margin-top:0; }
input[type=text], input[type=email], input[type=password] { width:100%; padding:11px 13px; border:1.5px solid var(--border); border-radius:10px; font:inherit; font-size:1rem; color:var(--ink); }
input:focus-visible { outline:none; border-color:var(--accent); box-shadow:0 0 0 3px rgba(38,125,66,0.12); }
.btn { margin-top:18px; width:100%; min-height:50px; border-radius:12px; border:0; background:var(--accent); color:#fff; font:inherit; font-weight:700; font-size:1rem; cursor:pointer; text-align:center; text-decoration:none; display:inline-flex; align-items:center; justify-content:center; }
.btn:hover { background:#1f6a37; }
.btn:focus-visible { outline:3px solid rgba(38,125,66,0.4); outline-offset:2px; }
.btn-ghost { background:#fff; color:var(--accent); border:1.5px solid var(--accent); }
.btn-ghost:hover { background:#eaf6ee; }
.note { color:var(--muted); font-size:0.82rem; margin-top:14px; }
.hint { background:#eef3f8; border:1px solid #d6e1ec; border-radius:10px; padding:10px 13px; font-size:0.85rem; color:#33506b; margin-bottom:16px; }
.hint strong { color:#1f3a52; }
.alert { border-radius:10px; padding:12px 14px; margin-bottom:16px; font-size:0.92rem; }
.alert-err { background:#fdecea; color:var(--warn); border:1px solid #f3c4be; }
.card h2 { margin:0 0 8px; color:var(--accent); }
.who { background:#f4f6f8; border-radius:10px; padding:10px 13px; margin:14px 0; font-size:0.9rem; }
.who dt { font-size:0.72rem; text-transform:uppercase; letter-spacing:0.04em; color:var(--muted); }
.who dd { margin:2px 0 0; font-weight:700; word-break:break-word; }
.credit { text-align:center; color:var(--muted); font-size:0.78rem; margin-top:22px; }
.credit a { color:var(--accent); }
</style>
</head>
<body>
<main class="app">
<header>
<h1><?= e($S['title']) ?></h1>
<p class="sub"><?= e($S['sub']) ?></p>
<div class="lang-switch" role="group" aria-label="Langue / Language">
<a class="lang-btn <?= $lang==='fr'?'active':'' ?>" href="?lang=fr">FR</a>
<a class="lang-btn <?= $lang==='en'?'active':'' ?>" href="?lang=en">EN</a>
</div>
</header>
<?php if ($logged): ?>
<!-- ESPACE PROTÉGÉ : visible seulement si $_SESSION['auth_user'] existe. -->
<div class="card">
<h2><?= e($S['welcome']) ?></h2>
<p><?= e($S['welcome_text']) ?></p>
<dl class="who">
<dt><?= e($S['as']) ?></dt>
<dd><?= e($logged) ?></dd>
</dl>
<a class="btn btn-ghost" href="?lang=<?= e($lang) ?>&logout=1"><?= e($S['logout']) ?></a>
</div>
<?php else: ?>
<form method="post" action="">
<p class="hint"><?= $S['demo_hint'] /* texte rédigé par nous */ ?></p>
<?php if ($error): ?>
<div class="alert alert-err"><?= e($error) ?></div>
<?php endif; ?>
<input type="hidden" name="csrf" value="<?= e($csrf) ?>">
<input type="hidden" name="lang" value="<?= e($lang) ?>">
<label for="email"><?= e($S['email']) ?></label>
<input type="email" id="email" name="email" value="<?= e($_POST['email'] ?? '') ?>" placeholder="<?= e($S['ph_email']) ?>" autocomplete="username" required>
<label for="pass"><?= e($S['pass']) ?></label>
<input type="password" id="pass" name="pass" placeholder="<?= e($S['ph_pass']) ?>" autocomplete="current-password" required>
<button class="btn" type="submit"><?= e($S['login']) ?></button>
<p class="note"><?= e($S['note']) ?></p>
</form>
<?php endif; ?>
<p class="credit"><?= $S['credit'] ?></p>
</main>
</body>
</html>
À toi de jouer
- Ajoute une vraie inscription : un formulaire qui
password_hash()le mot de passe et l'INSERTen base (et souviens-toi du projet 11 : requête paramétrée, colonne du hash en 255 ou TEXT). - Ajoute une limite de tentatives (par exemple, blocage de quelques secondes après 5 échecs) contre les attaques par force brute.
- Réfléchis au « rester connecté » : c'est un cookie signé à durée de vie longue. Pratique, mais c'est une vraie surface de risque, à ne pas improviser.
À chaque ajout, la question « auth » ne change pas : est-ce que je stocke une empreinte plutôt qu'un mot de passe ? (toujours) Est-ce que mes erreurs en disent trop ? (jamais)
Recevoir, stocker, et maintenant authentifier. À chaque marche, l'IA t'a fourni un code qui « marche » mais qui te trahit sur ce qu'on ne voit pas. La prochaine étape est le terrain le plus piégeux du web : laisser les visiteurs envoyer un fichier. Tu vas voir à quel point « ne jamais faire confiance au client » prend tout son sens.
Retour aux projets →The project: put a door with a lock
We can receive (project 10), we can store (project 11). But everything we've done so far was open to everyone. The next question is obvious: what if part of the app should only be visible after identifying yourself? You need a login.
It's the most universal topic of the web, and also one of the most botched. We'll make a real login page in PHP: an email, a password, and behind it, an area that only appears once authenticated. The heart of it fits in one sentence: you never store a password, you store its fingerprint. The rest of the lesson explains why, and everything the AI forgets along the way.
Like projects 10 and 11, this runs on a PHP server. And a warning: this project stores nothing (the demo account is hard-coded), because a real public signup page needs protections we don't want to improvise in a demo. We focus on the gesture that matters: verifying a password correctly.
The logbook (real life)
As with the two previous server projects, here's the loop as it happened: what worked, and the two places where the AI handed me a trap.
Prompt 1 — we ask, broadly
In PHP, make me a login page: an email, a password, and if it's correct, show a reserved "members area" page.
It works first try. But reading the code, two things chill me: the password is saved as-is (in clear), and it's compared with ==.
// ⚠️ the password is stored IN CLEAR, and compared with ==
if ($email === $u['email'] && $_POST['pass'] == $u['password']) {
$_SESSION['user'] = $email; // logged in
}
Why is it serious? Two reasons.
Storing in clear: databases leak, it's a statistical certainty, not a hypothesis. The day this one leaks, every password is readable at once. And since people reuse the same password everywhere, you didn't just compromise your site: you handed over their inbox and their bank.
The ==: in PHP, == is loose and does surprising conversions. With poorly chosen hashes (md5), some strings like "0e1234..." are seen as the number zero, and two different passwords end up "equal". That's the "magic hashes" trap. For a password, == is never the right tool.
Prompt 2 — hash it properly
Never store the password in clear. On signup, store its fingerprint with password_hash(). On login, verify with password_verify(). And don't use == to compare.
That's the right gesture. password_hash() computes a one-way fingerprint (with a random salt built in, automatically). We store that fingerprint, never the password. On login, password_verify() just answers "yes, this password matches this fingerprint" or "no", in constant time (no == trap).
// on signup: we store the FINGERPRINT (≈ 60 chars, e.g. $2y$12$...)
$hash = password_hash($pass, PASSWORD_DEFAULT);
// on login: we verify the typed password AGAINST the fingerprint
if (password_verify($_POST['pass'], $u['hash'])) {
session_regenerate_id(true); // we come back to this right after
$_SESSION['user'] = $email; // logged in
}
The reflex — don't chat about errors
A small detail that changes everything: when login fails, the AI showed two different messages, "this email doesn't exist" or "wrong password". Handy for the user… and for the attacker, who thus learns which emails are registered (that's account enumeration). We put back a single message, identical in both cases: "Invalid credentials".
The silly bug — the correct password is refused
Once wired to a real database, surprise: even with the correct password, password_verify() always answers "no". An hour doubting my code… which was fine. The culprit: the SQL column storing the hash was declared VARCHAR(20). But a bcrypt fingerprint is 60 characters. The database truncated it silently to 20, and a truncated hash matches nothing anymore.
-- ⚠️ too short: the 60-char hash is truncated → password_verify always fails
password VARCHAR(20)
-- ✅ wide enough for the fingerprint (and for future hashing algorithms)
password VARCHAR(255) -- or simply TEXT
Another bug invisible in the PHP logic: it comes from the database schema. The code told the truth, the pipe shaved the data. You only find it by asking "what if the stored data isn't exactly what I think?" and looking at what's really in the database.
And the session_regenerate_id(true) line? Right after a successful login, we change the session identifier. Otherwise, an attacker who managed to fix a victim's session ID before they log in would keep access once they're authenticated ("session fixation"). An auth reflex never to forget.
Tally: two fundamental flaws (password in clear, == comparison), one reflex (silent errors), one schema bug (the too-short column). The login that comes out is short, but it's correct.
The "authentication" reflexes to keep
Three principles, true in every language and every framework.
1. A password is never stored, it's hashed
We store the fingerprint computed by password_hash() (bcrypt or argon2), never the password, never a "homemade" md5/sha1. Hashing is one-way and salted automatically: even if the database leaks, the passwords stay out of reach. On login, password_verify() decides.
2. Compare safely, and reveal nothing
Never == on a password or a hash (type juggling). password_verify() compares in constant time. And on failure, a single generic message ("invalid credentials"), whether the email or the password is wrong: otherwise you hand the list of existing accounts to whoever can read (enumeration).
3. Protect the session, slow down attacks
After a successful login, session_regenerate_id(true) (anti-fixation). The column storing the hash must be long enough (≥ 255). And since a bot can try thousands of passwords, you limit attempts (a counter, a delay after several failures): that's what turns a weak password into an expensive target.
Test (like an attacker, not a nice user)
- Log in with the demo account: you must see the reserved area, then be able to log out.
- Type a wrong password, then a nonexistent email: the error message must be exactly the same in both cases.
- Once logged in, reload (F5): no form resubmission (thanks to the redirect).
- Log out, then try to return to the reserved area: it must no longer show.
The finished result
The login, for real, runs here. The demo account is shown in the form. Try to get it wrong on purpose too.
The full code
The entire PHP file, exactly the one running above. Like projects 10 and 11, you don't "download" it: a PHP file must be run by a server.
View the full code (212 lines)
<?php
// ============================================================================
// CONNEXION SÉCURISÉE — un seul fichier PHP, qui gère un VRAI login.
//
// On ne stocke JAMAIS un mot de passe en clair. On stocke son EMPREINTE
// (un hash), calculée par password_hash(). À la connexion, on ne compare pas
// les mots de passe : on demande à password_verify() si le mot de passe tapé
// correspond à l'empreinte stockée. Le hash est à sens unique : même si la base
// fuite, on ne peut pas en retrouver les mots de passe.
//
// PLAN DU FICHIER :
// 1) Session + jeton CSRF
// 2) "Base" des utilisateurs : un compte de démo, avec son hash bcrypt stocké
// (PAS le mot de passe en clair). C'est exactement ce qu'aurait une vraie base.
// 3) Déconnexion (logout)
// 4) Traitement du login : CSRF, validation, password_verify, anti-énumération,
// régénération de l'ID de session (anti-fixation), redirection (PRG)
// 5) Le HTML : soit l'espace connecté, soit le formulaire de connexion
//
// Démo : aucune inscription, aucune écriture. Le compte de démo est affiché à
// l'écran pour que tu puisses te connecter et voir l'espace protégé.
// ============================================================================
session_start(); // tout en haut, AUCUN espace ni ligne vide avant <?php
// --- 1) Jeton CSRF ----------------------------------------------------------
if (empty($_SESSION['login_csrf'])) {
$_SESSION['login_csrf'] = bin2hex(random_bytes(16));
}
$csrf = $_SESSION['login_csrf'];
// --- Langue (fr par défaut) -------------------------------------------------
$lang = ((($_REQUEST['lang'] ?? 'fr')) === 'en') ? 'en' : 'fr';
$S = [
'fr' => [
'title' => "Connexion sécurisée", 'sub' => "Un vrai login : mot de passe haché, jamais stocké en clair.",
'email' => "Email", 'pass' => "Mot de passe", 'login' => "Se connecter", 'logout' => "Se déconnecter",
'ph_email' => "ton@email.fr", 'ph_pass' => "Ton mot de passe",
'err_csrf' => "Jeton de sécurité invalide. Recharge la page et réessaie.",
'err_fields' => "Remplis l'email et le mot de passe.",
// ANTI-ÉNUMÉRATION : un seul message, qu'on se trompe d'email OU de mot de passe.
'err_bad' => "Identifiants invalides. Réessaie.",
'welcome' => "Tu es connecté ✓", 'welcome_text' => "Bienvenue ! Cette zone n'est visible qu'une fois authentifié. Dans une vraie appli, c'est ici que vivrait le tableau de bord.",
'as' => "Connecté en tant que",
'demo_hint' => "Compte de démo : <strong>demo@exemple.fr</strong> / <strong>motdepasse123</strong>",
'note' => "Le mot de passe est vérifié avec password_verify() contre une empreinte. On ne le compare jamais en clair.",
'credit' => 'Login PHP sur un serveur · Mini-projet du cours <a href="/apprendre/projets/">Projets appliqués</a>',
],
'en' => [
'title' => "Secure login", 'sub' => "A real login: hashed password, never stored in clear.",
'email' => "Email", 'pass' => "Password", 'login' => "Log in", 'logout' => "Log out",
'ph_email' => "you@email.com", 'ph_pass' => "Your password",
'err_csrf' => "Invalid security token. Reload the page and try again.",
'err_fields' => "Fill in the email and the password.",
'err_bad' => "Invalid credentials. Try again.",
'welcome' => "You are logged in ✓", 'welcome_text' => "Welcome! This area is only visible once authenticated. In a real app, this is where the dashboard would live.",
'as' => "Logged in as",
'demo_hint' => "Demo account: <strong>demo@exemple.fr</strong> / <strong>motdepasse123</strong>",
'note' => "The password is checked with password_verify() against a hash. We never compare it in clear.",
'credit' => 'PHP login on a server · A mini-project from the <a href="/apprendre/projets/">Applied projects</a> course',
],
][$lang];
function e($s) { return htmlspecialchars((string)$s, ENT_QUOTES, 'UTF-8'); }
// --- 2) "Base" des utilisateurs --------------------------------------------
// En vrai ce serait une table SQL. Ce qu'on y stocke, c'est l'EMPREINTE du mot
// de passe (60 caractères, calculée une fois à l'inscription), JAMAIS le mot de
// passe lui-même. Ce hash est celui de "motdepasse123".
$users = [
'demo@exemple.fr' => '$2y$12$MjVS8JCDniAL27YSMae4UOthBPM2M8vl4042BwII3y0KGLx3N9QCa',
];
// --- 3) Déconnexion ---------------------------------------------------------
if (($_GET['logout'] ?? '') === '1') {
unset($_SESSION['auth_user']);
session_regenerate_id(true); // on repart sur une session neuve
header('Location: ?lang=' . $lang);
exit;
}
$error = '';
// --- 4) Traitement de la connexion ------------------------------------------
if ($_SERVER['REQUEST_METHOD'] === 'POST' && empty($_SESSION['auth_user'])) {
if (!isset($_POST['csrf']) || !hash_equals($csrf, $_POST['csrf'])) {
$error = $S['err_csrf'];
} else {
$email = trim($_POST['email'] ?? '');
$pass = (string)($_POST['pass'] ?? '');
if ($email === '' || $pass === '') {
$error = $S['err_fields'];
} else {
// On récupère l'empreinte stockée pour cet email (ou null si inconnu).
$hash = $users[$email] ?? null;
// password_verify : compare le mot de passe tapé à l'empreinte, en
// temps constant. Si l'email est inconnu, $hash est null → false.
if ($hash !== null && password_verify($pass, $hash)) {
// SUCCÈS. On régénère l'ID de session : un attaquant qui aurait
// "fixé" l'ancien ID avant la connexion ne peut plus s'en servir
// (parade contre la "session fixation").
session_regenerate_id(true);
$_SESSION['auth_user'] = $email;
header('Location: ?lang=' . $lang); // PRG : pas de re-POST au refresh
exit;
}
// ÉCHEC : message identique pour "email inconnu" et "mauvais mot de
// passe". On ne révèle pas quels comptes existent (anti-énumération).
$error = $S['err_bad'];
}
}
}
$logged = $_SESSION['auth_user'] ?? null;
?>
<!DOCTYPE html>
<html lang="<?= e($lang) ?>">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title><?= e($S['title']) ?></title>
<meta name="robots" content="noindex, follow">
<link rel="icon" type="image/svg+xml" href="/assets/favicon.svg">
<style>
:root { --ink:#1a1d24; --muted:#5a6270; --accent:#267d42; --border:#e2e6ea; --warn:#a8341f; }
* { box-sizing:border-box; }
html,body { margin:0; padding:0; }
body { font-family:'Segoe UI',system-ui,-apple-system,Roboto,Helvetica,Arial,sans-serif; background:#f4f6f8; color:var(--ink); min-height:100vh; display:flex; flex-direction:column; align-items:center; padding:32px 18px 48px; line-height:1.5; }
.app { width:100%; max-width:460px; }
header { text-align:center; margin-bottom:18px; }
h1 { font-size:1.5rem; margin:0 0 6px; letter-spacing:-0.01em; }
.sub { color:var(--muted); font-size:0.95rem; margin:0 0 16px; }
.lang-switch { display:inline-flex; border:1.5px solid var(--border); border-radius:999px; overflow:hidden; background:#fff; }
.lang-btn { border:0; background:transparent; padding:7px 18px; font:inherit; font-weight:700; font-size:0.8rem; color:var(--muted); cursor:pointer; text-decoration:none; }
.lang-btn.active { background:var(--accent); color:#fff; }
.lang-btn:focus-visible { outline:3px solid rgba(38,125,66,0.4); outline-offset:2px; }
form, .card { background:#fff; border:1px solid var(--border); border-radius:14px; padding:22px; }
label { display:block; font-weight:700; font-size:0.9rem; margin:14px 0 6px; }
label:first-child { margin-top:0; }
input[type=text], input[type=email], input[type=password] { width:100%; padding:11px 13px; border:1.5px solid var(--border); border-radius:10px; font:inherit; font-size:1rem; color:var(--ink); }
input:focus-visible { outline:none; border-color:var(--accent); box-shadow:0 0 0 3px rgba(38,125,66,0.12); }
.btn { margin-top:18px; width:100%; min-height:50px; border-radius:12px; border:0; background:var(--accent); color:#fff; font:inherit; font-weight:700; font-size:1rem; cursor:pointer; text-align:center; text-decoration:none; display:inline-flex; align-items:center; justify-content:center; }
.btn:hover { background:#1f6a37; }
.btn:focus-visible { outline:3px solid rgba(38,125,66,0.4); outline-offset:2px; }
.btn-ghost { background:#fff; color:var(--accent); border:1.5px solid var(--accent); }
.btn-ghost:hover { background:#eaf6ee; }
.note { color:var(--muted); font-size:0.82rem; margin-top:14px; }
.hint { background:#eef3f8; border:1px solid #d6e1ec; border-radius:10px; padding:10px 13px; font-size:0.85rem; color:#33506b; margin-bottom:16px; }
.hint strong { color:#1f3a52; }
.alert { border-radius:10px; padding:12px 14px; margin-bottom:16px; font-size:0.92rem; }
.alert-err { background:#fdecea; color:var(--warn); border:1px solid #f3c4be; }
.card h2 { margin:0 0 8px; color:var(--accent); }
.who { background:#f4f6f8; border-radius:10px; padding:10px 13px; margin:14px 0; font-size:0.9rem; }
.who dt { font-size:0.72rem; text-transform:uppercase; letter-spacing:0.04em; color:var(--muted); }
.who dd { margin:2px 0 0; font-weight:700; word-break:break-word; }
.credit { text-align:center; color:var(--muted); font-size:0.78rem; margin-top:22px; }
.credit a { color:var(--accent); }
</style>
</head>
<body>
<main class="app">
<header>
<h1><?= e($S['title']) ?></h1>
<p class="sub"><?= e($S['sub']) ?></p>
<div class="lang-switch" role="group" aria-label="Langue / Language">
<a class="lang-btn <?= $lang==='fr'?'active':'' ?>" href="?lang=fr">FR</a>
<a class="lang-btn <?= $lang==='en'?'active':'' ?>" href="?lang=en">EN</a>
</div>
</header>
<?php if ($logged): ?>
<!-- ESPACE PROTÉGÉ : visible seulement si $_SESSION['auth_user'] existe. -->
<div class="card">
<h2><?= e($S['welcome']) ?></h2>
<p><?= e($S['welcome_text']) ?></p>
<dl class="who">
<dt><?= e($S['as']) ?></dt>
<dd><?= e($logged) ?></dd>
</dl>
<a class="btn btn-ghost" href="?lang=<?= e($lang) ?>&logout=1"><?= e($S['logout']) ?></a>
</div>
<?php else: ?>
<form method="post" action="">
<p class="hint"><?= $S['demo_hint'] /* texte rédigé par nous */ ?></p>
<?php if ($error): ?>
<div class="alert alert-err"><?= e($error) ?></div>
<?php endif; ?>
<input type="hidden" name="csrf" value="<?= e($csrf) ?>">
<input type="hidden" name="lang" value="<?= e($lang) ?>">
<label for="email"><?= e($S['email']) ?></label>
<input type="email" id="email" name="email" value="<?= e($_POST['email'] ?? '') ?>" placeholder="<?= e($S['ph_email']) ?>" autocomplete="username" required>
<label for="pass"><?= e($S['pass']) ?></label>
<input type="password" id="pass" name="pass" placeholder="<?= e($S['ph_pass']) ?>" autocomplete="current-password" required>
<button class="btn" type="submit"><?= e($S['login']) ?></button>
<p class="note"><?= e($S['note']) ?></p>
</form>
<?php endif; ?>
<p class="credit"><?= $S['credit'] ?></p>
</main>
</body>
</html>
Your turn
- Add a real signup: a form that
password_hash()the password andINSERTs it in the database (and remember project 11: parameterized query, hash column as 255 or TEXT). - Add an attempt limit (for example, a few seconds' block after 5 failures) against brute-force attacks.
- Think about "remember me": it's a long-lived signed cookie. Handy, but a real risk surface, not to be improvised.
On every addition, the "auth" question doesn't change: do I store a fingerprint rather than a password? (always) Do my errors say too much? (never)
Receive, store, and now authenticate. At every step, the AI handed you code that "works" but betrays you on what you can't see. The next step is the most treacherous ground on the web: letting visitors upload a file. You'll see just how far "never trust the client" really goes.
Back to the projects →Well done, you just finished all twelve applied projects, from a simple punchline card to a secure login with real passwords. You now have a genuine instinct for building with AI: explore the rest of the catalog to go further.
Back to the catalog →