Créa-blog

#100JoursPourCoder
Projet Créa-code

Ressources pour développeur web

Théme de la semaine : La cryptographie

PHP : Comment stocker et sécuriser un mot de passe sur un site web

⏱️ Temps de lecture estimé : 17 minutes
Accueil PHP 8 PHP : Comment stocker et sécuriser un mot de passe sur un site web

Chaque jour, des millions d’internautes se connectent à des sites web en saisissant leur identifiant et leur mot de passe. Derrière ce geste banal se cache pourtant un enjeu crucial : la sécurité. Si un site ne protège pas correctement les données de ses utilisateurs, celles-ci peuvent être volées, revendues, voire utilisées pour accéder à d’autres comptes. Le mot de passe est souvent la clé d’entrée de toute une vie numérique.

Dans ce chapitre, nous allons découvrir en détail comment les développeurs PHP stockent et sécurisent les mots de passe. Vous comprendrez pourquoi il est dangereux de stocker les mots de passe en clair, comment les fonctions de hachage et de salage fonctionnent, et quelles sont les bonnes pratiques modernes recommandées par la communauté.

Nous verrons également comment implémenter ces techniques avec des exemples concrets de code PHP, afin que vous puissiez les appliquer dans vos propres projets en toute confiance. Ce chapitre s’adresse aussi bien aux débutants curieux qu’aux développeurs souhaitant renforcer leurs bases en sécurité web.

Pourquoi la sécurité des mots de passe est essentielle

Lorsque vous créez un site web avec un système d’inscription, la première chose à laquelle vous pensez est souvent la fonctionnalité : permettre aux utilisateurs de créer un compte, se connecter, modifier leur profil, etc. Pourtant, la sécurité des mots de passe devrait être votre priorité absolue.

Un mot de passe mal protégé peut avoir des conséquences graves. Si votre base de données est compromise, un pirate pourrait accéder à des milliers de comptes. Dans le cas où les mots de passe sont enregistrés en clair (sans aucune protection), le pirate n’aurait qu’à les lire directement.

Prenons un exemple simple. Supposons que vous ayez une base de données avec une table users contenant une colonne password. Si vous stockez les mots de passe tels quels, voici ce que cela pourrait donner :

id | email               | password
---+---------------------+------------
1  | [email protected]       | azerty123
2  | [email protected]   | motdepasse
3  | [email protected]     | 123456

Dans ce cas, si un attaquant accède à la base de données, il obtient immédiatement les mots de passe de tous vos utilisateurs.

Or, beaucoup de personnes réutilisent le même mot de passe sur plusieurs sites. Une simple fuite pourrait donc permettre à un hacker d’accéder à leurs comptes bancaires, messageries, réseaux sociaux, etc.

C’est pour cette raison qu’il est strictement interdit de stocker les mots de passe en clair. En PHP comme dans tout autre langage, il faut appliquer un hachage cryptographique avant de les enregistrer dans la base.

Comprendre le principe du hachage

Le hachage est un processus qui transforme une donnée (ici, un mot de passe) en une chaîne de caractères apparemment aléatoire et irréversible. Contrairement au chiffrement, le hachage ne peut pas être “décrypté” : on ne peut pas retrouver le mot de passe d’origine à partir de son hash.

Par exemple, si vous hachez le mot de passe azerty123 avec l’algorithme SHA-256, vous obtiendrez une chaîne comme celle-ci :

echo hash('sha256', 'azerty123');
// Résultat : e1a1dbe8a70d1a7a83df42dc2ab63f43e9fbd90e3fef6b939d12b0c73c8f7d5d

Ce hash sera toujours le même pour le même mot de passe. C’est un des inconvénients des fonctions de hachage simples : elles sont déterministes. Si deux utilisateurs choisissent le même mot de passe, ils auront le même hash.

De plus, des bases de données appelées tables arc-en-ciel (rainbow tables) répertorient les correspondances entre des millions de mots de passe et leurs hash connus. Un attaquant pourrait donc retrouver un mot de passe en comparant le hash volé avec ceux présents dans sa table.

Pour pallier ce problème, on ajoute une étape supplémentaire : le salage.

Le salage des mots de passe

Le sel (ou salt en anglais) est une donnée aléatoire que l’on ajoute au mot de passe avant de le hacher. Cela permet de rendre chaque hash unique, même si deux utilisateurs ont le même mot de passe.

Par exemple :

$password = 'azerty123';
$salt = bin2hex(random_bytes(16)); // génère un sel aléatoire
$hashed = hash('sha256', $salt . $password);

echo "Sel : $salt\n";
echo "Hash : $hashed\n";

Le résultat pourrait ressembler à ceci :

Sel : 9f3b45d0e1a6b8d4f2c6e49e5f7a1234
Hash : 89b32fae932d0e9fae78a4f96c5d3acb1ad8b41a47d4f4ea1b5e7deac54c9a12

Ainsi, même si un autre utilisateur choisit le même mot de passe, il aura un hash complètement différent grâce à son sel unique.

Cependant, gérer manuellement les sels devient vite complexe. C’est pourquoi PHP propose des fonctions intégrées pour simplifier tout cela.

Les fonctions modernes de hachage en PHP

Depuis PHP 5.5, une fonction dédiée au hachage sécurisé des mots de passe a été introduite : password_hash(). Elle remplace les anciennes méthodes basées sur md5()sha1() ou hash(), qui sont aujourd’hui considérées comme obsolètes pour la sécurité.

Voici comment elle s’utilise :

$password = 'azerty123';
$hashedPassword = password_hash($password, PASSWORD_DEFAULT);

echo $hashedPassword;

Le résultat ressemblera à ceci :

$2y$10$0kzXZ9r.y5v.qhSDGZ1lueYp2j0uPc5DLhDgE3xepuQxIGfZp4pPO

Cette chaîne n’est pas qu’un simple hash : elle contient aussi le sel et des informations sur l’algorithme utilisé (par défaut bcrypt). PHP gère donc automatiquement le salage, le hachage et la vérification.

Pour vérifier un mot de passe lors d’une connexion, on utilise password_verify() :

if (password_verify($passwordSaisi, $hashedPassword)) {
    echo "Mot de passe correct !";
} else {
    echo "Mot de passe incorrect.";
}

Cette fonction compare le mot de passe saisi avec le hash stocké dans la base de données, sans jamais révéler le mot de passe original.

Pourquoi il ne faut plus utiliser MD5 ou SHA1

Pendant longtemps, beaucoup de développeurs ont utilisé md5() ou sha1() pour hacher les mots de passe. Ces fonctions ont été conçues pour la vérification d’intégrité, pas pour la sécurité. Elles sont très rapides, ce qui est paradoxalement une faiblesse.

Les attaques modernes, notamment les attaques par force brute ou par dictionnaire, exploitent cette rapidité. En quelques secondes, un ordinateur peut tester des millions de combinaisons et retrouver le mot de passe d’origine.

Voici un exemple de mauvaise pratique :

$password = 'azerty123';
$hashed = md5($password); // à ne jamais faire !

Les algorithmes comme MD5 et SHA1 sont donc à proscrire. Ils ont été cassés et ne garantissent plus aucune sécurité.

Les fonctions modernes comme password_hash() utilisent des algorithmes spécialement conçus pour être lents et coûteux en calcul, rendant les attaques beaucoup plus difficiles.

Algorithmes modernes : bcrypt et Argon2

Depuis l’arrivée de password_hash() en PHP, deux familles d’algorithmes sont recommandées : bcrypt et Argon2. Ces algorithmes sont conçus pour être coûteux en temps et/ou en mémoire, ce qui ralentit considérablement les attaques massives par force brute.

bcrypt est largement supporté et stable. Il est basé sur l’algorithme Blowfish et permet de configurer un facteur de coût (work factor) qui détermine le nombre d’itérations. Plus le coût est élevé, plus le hachage est lent à calculer, donc plus il est résistant aux attaques. Dans PHP, PASSWORD_DEFAULT renvoie généralement à bcrypt sur les versions anciennes à intermédiaires, mais peut évoluer. Il est possible de forcer bcrypt via PASSWORD_BCRYPT.

Argon2 est le vainqueur du concours Password Hashing Competition et comporte plusieurs variantes : Argon2i, Argon2d et Argon2id. Argon2id est le bon choix pour la plupart des usages car il combine résistance aux attaques par canal latéral et bonnes propriétés contre les attaques par GPU. Argon2 permet de régler trois paramètres : la mémoire (en kilobytes), le nombre d’itérations et le degré de parallélisme. Ces paramètres rendent les attaques par GPU ou ASIC beaucoup plus coûteuses.

Exemple d’utilisation d’Argon2 avec password_hash() en PHP :

$options = [
    'memory_cost' => 1<<17, // 131072 KB = 128 MB
    'time_cost'   => 4,      // nombre d'itérations
    'threads'     => 2,      // parallélisme
];

$hash = password_hash($password, PASSWORD_ARGON2ID, $options);

Ce hash contient déjà le sel et les paramètres utilisés. Pour vérifier, utilisez password_verify() comme précédemment. Pour savoir si un hash doit être recalculé avec de nouveaux paramètres (par exemple si vous augmentez memory_cost), utilisez password_needs_rehash().

if (password_needs_rehash($storedHash, PASSWORD_ARGON2ID, $options)) {
    $newHash = password_hash($passwordSaisi, PASSWORD_ARGON2ID, $options);
    // mettre à jour le hash en base
}

Le choix entre bcrypt et Argon2 dépend du support de votre environnement PHP et de vos contraintes matérielles. Si PHP est récent (7.2+), Argon2id est disponible et recommandé. Dans tous les cas, vérifiez les performances sur votre serveur avant de fixer des paramètres. Un bon point de départ est de viser un temps de hachage de l’ordre de 100 à 300 ms par opération sur votre infrastructure.

Schéma de base de données et stockage des hashes

La manière dont vous stockez les mots de passe en base est simple mais doit respecter quelques règles. Stockez uniquement le hash, jamais le mot de passe en clair, jamais le sel séparément si vous utilisez password_hash() (car il est inclus). Conservez aussi des métadonnées utiles comme la date de création du hash, la date du dernier changement de mot de passe et éventuellement un champ pour marquer qu’un compte a été compromis.

Exemple de table users minimale en MySQL :

CREATE TABLE users (
  id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
  email VARCHAR(255) NOT NULL UNIQUE,
  password_hash VARCHAR(255) NOT NULL,
  password_changed_at DATETIME NULL,
  created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
  is_active TINYINT(1) NOT NULL DEFAULT 1
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

Le champ password_hash peut contenir jusqu’à 255 caractères pour couvrir différents algorithmes et leurs métadonnées. Conserver password_changed_at permet d’imposer des politiques de renouvellement ou d’invalider des sessions après un changement.

Exemple concret d’inscription sécurisé en PHP avec PDO

Le code suivant illustre un flux d’inscription simple et sécurisé. Il utilise PDO pour éviter les injections SQL et password_hash() pour le hachage.

// db.php (exemple de connexion PDO)
$pdo = new PDO('mysql:host=localhost;dbname=crea_blog;charset=utf8mb4', 'user', 'pass', [
    PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
    PDO::ATTR_EMULATE_PREPARES => false,
]);

// register.php
require 'db.php';

$email = trim($_POST['email'] ?? '');
$password = $_POST['password'] ?? '';

if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
    throw new Exception('Email invalide');
}

if (strlen($password) < 12) {
    throw new Exception('Le mot de passe doit contenir au moins 12 caractères');
}

// vérifier s'il existe déjà
$stmt = $pdo->prepare('SELECT id FROM users WHERE email = ?');
$stmt->execute([$email]);
if ($stmt->fetch()) {
    throw new Exception('Un compte existe déjà pour cet email');
}

// hacher le mot de passe avec Argon2id si disponible
$options = [
    'memory_cost' => 1<<16, // 65536 KB = 64 MB
    'time_cost' => 3,
    'threads' => 2,
];

$hash = password_hash($password, defined('PASSWORD_ARGON2ID') ? PASSWORD_ARGON2ID : PASSWORD_BCRYPT, $options);

$insert = $pdo->prepare('INSERT INTO users (email, password_hash) VALUES (?, ?)');
$insert->execute([$email, $hash]);

echo 'Inscription réussie';

Ce code met l’accent sur la validation d’entrée, l’utilisation de requêtes préparées et un hachage sécurisé. N’oubliez pas d’ajouter la validation côté client pour l’expérience utilisateur, mais considérez toujours la validation côté serveur comme seule source de vérité.

Connexion et vérification des mots de passe

Pour authentifier un utilisateur, récupérez le hash stocké et utilisez password_verify() pour comparer le mot de passe saisi avec le hash.

// login.php
require 'db.php';

$email = $_POST['email'] ?? '';
$password = $_POST['password'] ?? '';

$stmt = $pdo->prepare('SELECT id, password_hash FROM users WHERE email = ? AND is_active = 1');
$stmt->execute([$email]);
$user = $stmt->fetch(PDO::FETCH_ASSOC);

if (!$user || !password_verify($password, $user['password_hash'])) {
    // éviter de préciser quel champ est incorrect
    http_response_code(401);
    echo 'Identifiants invalides';
    exit;
}

// si besoin, rehasher avec de nouveaux paramètres
if (password_needs_rehash($user['password_hash'], PASSWORD_ARGON2ID, $options)) {
    $newHash = password_hash($password, PASSWORD_ARGON2ID, $options);
    $update = $pdo->prepare('UPDATE users SET password_hash = ?, password_changed_at = NOW() WHERE id = ?');
    $update->execute([$newHash, $user['id']]);
}

// créer la session utilisateur en toute sécurité
session_start();
session_regenerate_id(true);
$_SESSION['user_id'] = $user['id'];

echo 'Connexion réussie';

Important : n’indiquez pas si c’est l’email ou le mot de passe qui est incorrect pour éviter de faciliter le travail des attaquants.

Réinitialisation de mot de passe : sécurité et UX

La fonctionnalité « mot de passe oublié » est souvent exploitée par des attaquants si elle est mal conçue. Un bon flux doit inclure une tokenisation unique, une durée de validité courte et une invalidation après usage. Ne renvoyez jamais un mot de passe par email.

Exemple simplifié de workflow sécurisé :

Formation web et informatique - Alban Guillier - Formateur

Des formations informatique pour tous !

Débutant ou curieux ? Apprenez le développement web, le référencement, le webmarketing, la bureautique, à maîtriser vos appareils Apple et bien plus encore…

Formateur indépendant, professionnel du web depuis 2006, je vous accompagne pas à pas et en cours particulier, que vous soyez débutant ou que vous souhaitiez progresser. En visio, à votre rythme, et toujours avec pédagogie.

Découvrez mes formations Qui suis-je ?
  1. L’utilisateur demande la réinitialisation en fournissant son email.
  2. Le serveur crée un token cryptographiquement sûr via random_bytes() et en stocke le hash en base avec une date d’expiration.
  3. Le lien envoyé par email contient uniquement le token en clair et la durée est courte (par exemple 1 heure).
  4. Lorsqu’un utilisateur clique, le serveur vérifie le token en comparant le hash stocké puis autorise la saisie d’un nouveau mot de passe.
  5. Après usage, le token est supprimé ou marqué comme utilisé.

Exemple de génération de token :

$token = bin2hex(random_bytes(32));
$tokenHash = hash('sha256', $token); // stocker le hash du token
$expiresAt = (new DateTime('+1 hour'))->format('Y-m-d H:i:s');

$insert = $pdo->prepare('INSERT INTO password_resets (user_id, token_hash, expires_at) VALUES (?, ?, ?)');
$insert->execute([$userId, $tokenHash, $expiresAt]);

// envoyer par email un lien comme : https://votresite.fr/reset.php?token=$token

Comparer le token en hashant la valeur reçue côté serveur et en la comparant au token_hash stocké. Utiliser des comparaisons en temps constant (hash_equals()) pour éviter les fuites par timing.

Concepts avancés : pepper, rate limiting et détection

Le sel est stocké avec le hash lorsqu’on utilise password_hash(). Le pepper est une donnée secrète stockée en dehors de la base de données, idéalement dans une variable d’environnement ou un module matériel sécurisé (HSM). Le pepper ajoute une protection supplémentaire si la base de données est compromise mais le serveur reste sûr. Utiliser un pepper signifie concaténer le mot de passe avec la valeur secrète avant de hacher, ou mieux, ajouter le pepper dans le processus avant l’algorithme si vous gérez votre propre pipeline, mais attention à ne pas casser les fonctions standard.

Rate limiting et mécanismes anti-brute-force sont essentiels. Implémenter un système de verrouillage temporaire après plusieurs tentatives, mais faire attention à l’abus de verrouillage (possibilité de DoS ciblant un compte). Une stratégie courante est de combiner un rate limit ip-based et user-based, avec des verrous progressifs et des notifications au propriétaire du compte.

Surveillance et logs doivent être mis en place pour détecter des flux suspects. Stocker les logs d’échec de connexion, analyser les patterns et mettre en place des alertes. Veiller à ne pas logger les mots de passe en clair, ni les tokens sensibles.

Pour aller plus loin en pentesting :

Sécurité réseau et déploiement : TLS, HSTS et headers

Le chiffrement des mots de passe côté serveur n’est pas suffisant si la connexion entre le navigateur et le serveur est interceptée. Toujours forcer HTTPS sur l’ensemble du site via TLS moderne. Activer HSTS (HTTP Strict Transport Security) pour éviter les downgrades. Utiliser des en-têtes de sécurité comme Content-Security-PolicyX-Frame-OptionsReferrer-Policy et X-Content-Type-Options pour réduire d’autres vecteurs d’attaque.

Séparez l’accès à la base de données derrière des réseaux privés, limitez les accès SSH et utilisez des clés, pas des mots de passe, pour les opérations administratives. Gardez vos packages et le runtime PHP à jour pour bénéficier des correctifs.

Migration et maintenance des hash

Au fil du temps, vous voudrez peut-être augmenter la difficulté des paramètres de hachage. La méthode recommandée est progressive : lors de la connexion d’un utilisateur, si password_needs_rehash() renvoie true avec vos nouveaux paramètres, recalculer et mettre à jour le hash. Ainsi la migration s’effectue naturellement et sans forcer les utilisateurs à changer de mot de passe simultanément.

Pour une migration massive, vous pouvez aussi forcer un reset de mot de passe en envoyant une campagne sécurisée, mais c’est plus intrusif. La solution progressive est préférable.

Tests et audits

Ne négligez pas les tests unitaires et d’intégration qui vérifient l’authentification, la réinitialisation, la gestion des sessions et la politique de verrouillage. Utilisez des tests de fuzzing et des outils d’audit de sécurité pour découvrir les failles. Considérez une revue de code et, si possible, un audit externe ou un bug bounty pour les projets importants.

Résumé pratique : Checklist rapide (pour usage interne, sans puces)

Valider le hachage avec password_hash() et password_verify(). Préférer Argon2id si disponible; sinon bcrypt. Ne stocker que les hashes. Utiliser PDO avec requêtes préparées. Forcer HTTPS et configurer HSTS. Implémenter un flux de réinitialisation basé sur tokens signés et expirés. Utiliser rate limiting et surveillance des tentatives de connexion. Penser au pepper pour une protection supplémentaire, stockée hors de la base. Mettre en place la rehashing progressif avec password_needs_rehash(). Tester et auditer régulièrement.

Guide pas à pas : formulaire d’inscription sécurisé (HTML + PHP)

Voici un formulaire HTML simple et accessible. L’accent est mis sur la propreté, l’accessibilité et l’intégration d’un token CSRF.

<!-- register_form.html -->
<!doctype html>
<html lang="fr">
<head>
  <meta charset="utf-8">
  <title>Inscription</title>
  <meta name="viewport" content="width=device-width,initial-scale=1">
</head>
<body>
  <h1>Créer un compte</h1>

  <form method="post" action="/register.php" autocomplete="off" novalidate>
    <label for="email">Adresse email</label>
    <input id="email" name="email" type="email" required>

    <label for="password">Mot de passe</label>
    <input id="password" name="password" type="password" minlength="12" required>

    <input type="hidden" name="csrf_token" value="<?= htmlspecialchars($_SESSION['csrf_token'] ?? '') ?>">

    <button type="submit">S'inscrire</button>
  </form>
</body>
</html>

Côté serveur, il faut générer et vérifier le token CSRF. Le token est créé lors de la première visite et stocké en session, puis réutilisé dans le formulaire.

// init.php - inclus sur chaque page avant envoi d'en-têtes
session_start();

// paramètres de cookie de session sécurisés
$secure = true; // sur production vous devez avoir HTTPS
$httponly = true;
$samesite = 'Lax'; // ou 'Strict' selon UX
session_set_cookie_params([
    'lifetime' => 0,
    'path' => '/',
    'domain' => $_SERVER['HTTP_HOST'],
    'secure' => $secure,
    'httponly' => $httponly,
    'samesite' => $samesite
]);

// régénérer l'id de session à l'init si nécessaire
if (!isset($_SESSION['init_time'])) {
    session_regenerate_id(true);
    $_SESSION['init_time'] = time();
}

// générer le token CSRF si absent
if (empty($_SESSION['csrf_token'])) {
    $_SESSION['csrf_token'] = bin2hex(random_bytes(32));
}

Et voici register.php qui reçoit la requête, vérifie le CSRF, valide l’input et crée le compte de façon sécurisée.

// register.php
require 'init.php';
require 'db.php'; // connexion PDO

if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
    http_response_code(405);
    exit;
}

// vérification CSRF
$token = $_POST['csrf_token'] ?? '';
if (!hash_equals($_SESSION['csrf_token'], $token)) {
    http_response_code(400);
    echo 'Requête invalide (CSRF).';
    exit;
}

// nettoyage et validation
$email = filter_var(trim($_POST['email'] ?? ''), FILTER_VALIDATE_EMAIL);
$password = $_POST['password'] ?? '';

if (!$email) {
    echo 'Email invalide.';
    exit;
}

if (strlen($password) < 12) {
    echo 'Le mot de passe doit contenir au moins 12 caractères.';
    exit;
}

// vérification d'existence
$stmt = $pdo->prepare('SELECT id FROM users WHERE email = ?');
$stmt->execute([$email]);
if ($stmt->fetch()) {
    echo 'Un compte existe déjà.';
    exit;
}

// pepper (facultatif, lire l'explication plus bas)
$pepper = getenv('PASSWORD_PEPPER') ?: ''; // stocké dans l'environnement, jamais en base

// hachage sécurisé: Argon2id si disponible, sinon bcrypt
$options = [
    'memory_cost' => 1<<16,
    'time_cost' => 3,
    'threads' => 2,
];

$toHash = $password . $pepper; // si vous utilisez pepper, concaténez avant le hachage
$algo = defined('PASSWORD_ARGON2ID') ? PASSWORD_ARGON2ID : PASSWORD_BCRYPT;
$hash = password_hash($toHash, $algo, $options);

// insertion
$insert = $pdo->prepare('INSERT INTO users (email, password_hash) VALUES (?, ?)');
$insert->execute([$email, $hash]);

echo 'Inscription réussie.';

Explications claires pour chaque étape : la génération du token CSRF empêche une autre page de soumettre un formulaire à la place de l’utilisateur. hash_equals() empêche les attaques par timing. Le pepper est une couche optionnelle qui doit être à l’extérieur de la base de données.

Protection CSRF : pourquoi et comment l’appliquer partout

Le CSRF (Cross-Site Request Forgery) force un utilisateur authentifié à exécuter une action non désirée. Protéger toutes les requêtes modifiant l’état (POST, PUT, DELETE) est indispensable. Utilisez un token unique par session et vérifiez-le côté serveur. Pour les API REST, utilisez des tokens d’accès (JWT, OAuth) ou des en-têtes personnalisés (par exemple X-Requested-With) combinés à des contrôles CORS stricts.

Pour les formulaires envoyés via JavaScript (fetch/ajax), envoyez le token CSRF dans un en-tête X-CSRF-Token et vérifiez $_SERVER['HTTP_X_CSRF_TOKEN'] côté PHP. Toujours exiger HTTPS.

Gestion sécurisée des sessions

La session contient l’identifiant utilisateur et, parfois, des drapeaux d’autorisation. Voici les bonnes pratiques concrètes :

Session en HTTPS et cookies sécurisés : configurez session.cookie_secure = 1 et session.cookie_httponly = 1 pour éviter que les cookies ne soient lus côté client par JavaScript. Activez session.use_strict_mode = 1 pour empêcher des sessions préfixées d’être acceptées. Définissez session.cookie_samesite = Lax ou Strict selon votre UX.

Regénération d’ID : appelez session_regenerate_id(true) après authentification pour éviter le session fixation. Limitez la durée de session et créez une logique d’expiration côté serveur.

Stockage minimal : n’enregistrez jamais de mot de passe ou d’informations sensibles non chiffrées dans la session. Stockez l’user_id, les rôles et un timestamp d’activité.

Protection contre la détérioration : liez la session à des caractéristiques de l’utilisateur comme l’adresse IP partielle ou le user-agent, mais attention aux IP mobiles/proxies. Utilisez cette information comme indicateur plutôt que comme blocage strict.

Si vous devez implémenter une fonctionnalité “se souvenir de moi”, ne stockez pas d’identifiants en clair. Créez un token long (par exemple random_bytes(32)), stockez le hash dans la base et envoyez le token en cookie HttpOnly. À chaque utilisation, regénérez un nouveau token et supprimez l’ancien (token rotation).

Exemple de “Remember me” sécurisée

// lors de la connexion si l'utilisateur coche "se souvenir de moi"
$selector = bin2hex(random_bytes(9)); // identifiant public (18 hex chars)
$validator = bin2hex(random_bytes(33)); // secret long
$validatorHash = hash('sha256', $validator);

$expires = (new DateTime('+30 days'))->format('Y-m-d H:i:s');

// stocker selector, hash et user_id en base (table persistent_logins)
$pdo->prepare('INSERT INTO persistent_logins (selector, validator_hash, user_id, expires_at) VALUES (?, ?, ?, ?)')
    ->execute([$selector, $validatorHash, $userId, $expires]);

// cookie envoyé au client : selector:validator en base64
setcookie('remember', base64_encode($selector . ':' . $validator), [
    'expires' => time() + 60*60*24*30,
    'path' => '/',
    'domain' => $_SERVER['HTTP_HOST'],
    'secure' => true,
    'httponly' => true,
    'samesite' => 'Lax',
]);

À la connexion automatique, lisez le cookie, séparez selector et validator, recherchez le selector en base, comparez hash('sha256', $validator) avec le validator_hash stocké via hash_equals(). En cas de validation, regénérez un nouveau token pour empêcher le vol réutilisable. En cas d’échec, supprimez tous les tokens liés à l’utilisateur pour limiter l’impact.

Réinitialisation sécurisée : implémentation détaillée

Rappel du schéma sécurisé : on envoie par email un lien contenant un token unique (en clair) ; on stocke uniquement le hash du token en base avec une expiration. On oblige la mise en place d’un nouveau mot de passe et on invalide le token après usage.

// demander_reset.php
$email = $_POST['email'] ?? '';
$stmt = $pdo->prepare('SELECT id FROM users WHERE email = ?');
$stmt->execute([$email]);
$user = $stmt->fetch();

if ($user) {
    $token = bin2hex(random_bytes(32));
    $tokenHash = hash('sha256', $token);
    $expiresAt = (new DateTime('+1 hour'))->format('Y-m-d H:i:s');

    $pdo->prepare('INSERT INTO password_resets (user_id, token_hash, expires_at) VALUES (?, ?, ?)')
        ->execute([$user['id'], $tokenHash, $expiresAt]);

    // envoyer par email un lien du type :
    // https://votresite.fr/reset_password.php?token=$token
    // n'envoyez jamais le hash, seulement le token
}

Lors de la validation :

// reset_password.php
$token = $_GET['token'] ?? '';
if (!$token) { /* erreur */ }

$tokenHash = hash('sha256', $token);
$stmt = $pdo->prepare('SELECT user_id, expires_at FROM password_resets WHERE token_hash = ?');
$stmt->execute([$tokenHash]);
$entry = $stmt->fetch();

if (!$entry) { /* token invalide */ }

if (new DateTime() > new DateTime($entry['expires_at'])) {
    // token expiré ; supprimer l'entrée
    $pdo->prepare('DELETE FROM password_resets WHERE token_hash = ?')->execute([$tokenHash]);
    /* informer l'utilisateur */
}

// autoriser la modification du mot de passe, puis supprimer le token
$newPassword = $_POST['password'] ?? '';
// validation puis hachage comme vu précédemment
$pdo->prepare('UPDATE users SET password_hash = ? WHERE id = ?')->execute([$newHash, $entry['user_id']]);
$pdo->prepare('DELETE FROM password_resets WHERE token_hash = ?')->execute([$tokenHash]);

Utilisez hash_equals() lors des comparaisons et ne logguez jamais le token. Conservez un historique des réinitialisations si vous souhaitez détecter des abus.

Pepper : usage, avantages et limites

Le pepper est une valeur secrète globale, stockée hors de la base de données, par exemple dans une variable d’environnement ou dans un coffre (vault/HSM). Il protège en cas de fuite de la base : un attaquant qui ne possède que la base ne peut pas lancer des attaques en clair sans connaître le pepper.

Méthode simple : concaténez le pepper avec le mot de passe avant d’appeler password_hash().

$pepper = getenv('PASSWORD_PEPPER'); // ex: 32 bytes secret
$toHash = $password . $pepper;
$hash = password_hash($toHash, PASSWORD_ARGON2ID, $options);

Limite : si l’attaquant obtient à la fois la base et le serveur (ou la variable d’environnement), le pepper ne protège plus. Le pepper ajoute donc une barrière supplémentaire mais ne remplace pas d’autres protections. Ne mettez jamais le pepper dans la base de données ni dans votre dépôt git.

Configuration PHP / php.ini recommandée pour l’authentification

Voici des réglages php.ini utiles pour renforcer la sécurité (expliquer chaque option en phrase, pas en liste).

Il est important d’activer session.use_strict_mode pour empêcher l’utilisation de sessions préfixées. session.cookie_httponlyempêche l’accès JavaScript aux cookies de session. session.cookie_secure force l’envoi de cookies uniquement via HTTPS.

Limitez session.gc_maxlifetime pour réduire la fenêtre d’utilisation d’une session volée. Désactivez expose_php pour réduire la fuite d’informations sur la version PHP. Activez open_basedir pour limiter les chemins auxquels PHP peut accéder.

Configurez display_errors à off en production pour éviter de divulguer des informations sensibles ; utilisez les logs pour diagnostiquer. Mettez en place error_log vers un emplacement protégé.

Tenez à jour memory_limit et max_execution_time mais sans sacrifier les besoins des opérations autorisées. Pour les envois d’emails en production, utilisez des bibliothèques robustes comme PHPMailer ou Symfony Mailer plutôt que mail().

Détection et gestion d’un compte compromis

Si vous détectez (ou qu’un utilisateur signale) un compte compromis, agissez rapidement. Les étapes concrètes sont :

  1. Révoquer toutes les sessions actives de l’utilisateur en supprimant ou marquant comme invalides les tokens de sessions et les cookies persistants.
  2. Forcer la réinitialisation du mot de passe en générant un token sécurisé et en envoyant un email avec instructions.
  3. Invalider toutes les sessions remember me en purgeant les enregistrements persistent_logins.
  4. Notifier l’utilisateur par email et proposer des étapes de sécurisation : changer de mot de passe sur d’autres services, activer l’authentification à deux facteurs si disponible.
  5. Enregistrer les logs de l’incident (IP, user-agent, timestamp) pour analyse et poursuite éventuelle.
  6. Si vous constatez un accès massif ou une fuite de la base, activer un plan d’intervention : communiquer clairement aux utilisateurs, forcer des changements, et si nécessaire alerter les autorités compétentes selon les obligations légales (ex. RGPD en Europe).

La réactivité et la transparence sont essentielles pour maintenir la confiance des utilisateurs.

Mesures complémentaires : MFA, monitoring et audits

L’authentification multifacteur (MFA) réduit dramatiquement le risque d’accès non autorisé même si le mot de passe est compromis. Proposez MFA via une application de type TOTP (Google Authenticator, Authy) ou via des clés matérielles (WebAuthn). Stockez uniquement ce qui est nécessaire pour la gestion des MFA (par exemple la clé publique WebAuthn), et protégez ces données comme toute donnée d’authentification.

Surveillance : mettez en place des alertes pour un volume inhabituel d’échecs de connexion, connexions depuis des pays inhabituels ou changements fréquents de mot de passe. Les logs doivent être stockés en lecture seule pour permettre d’enquêter.

Audits réguliers : re-lancez des scans de vulnérabilité, tests d’intrusion, et revues de dépendances. Maintenez un processus de correctifs rapides.

Étude de cas : comment réagir à une fuite de la base d’utilisateurs

Supposons que vous découvrez une exportation non autorisée de la table users. Première action : couper l’accès compromis et isoler le serveur. Évaluez l’étendue de la fuite (champs exportés, présence de hash ou de données en clair). Si seuls les hashes ont été exportés, informez les utilisateurs et forcez un reset généralisé du mot de passe en envoyant des emails avec token d’expiration courte. Recommandez aux utilisateurs de ne pas réutiliser leur mot de passe ailleurs et d’activer MFA. Si des données sensibles au-delà des hashes ont fuité, suivez les obligations légales de notification (ex. RGPD), documentez l’incident et collaborez avec les autorités compétentes.

Posture de sécurité et bonnes pratiques durables

La sécurité des mots de passe est bien plus qu’une simple fonction technique à implémenter. C’est une discipline qui combine cryptographie, bonnes pratiques de développement, configuration système, surveillance et gestion des incidents. Vous avez vu comment utiliser password_hash() et password_verify() pour protéger les mots de passe, pourquoi préférer des algorithmes modernes comme Argon2id, comment implémenter un flux d’inscription et de connexion sécurisé, comment protéger vos formulaires avec CSRF, et comment gérer les sessions et la réinitialisation des mots de passe. Vous avez aussi appris l’intérêt d’un pepper, l’utilité d’un système “remember me” correctement conçu, et l’importance d’un plan pour réagir rapidement si un compte est compromis.

La sécurité n’est pas un produit fini mais un effort continu. Mesurez le temps de hachage sur votre infrastructure, adaptez les paramètres d’Argon2 en fonction des performances, testez vos flows, auditez régulièrement vos logs et préparez un plan d’intervention. La meilleure défense est la défense en profondeur : plusieurs couches de protections qui, ensemble, rendent l’exploitation d’une faille coûteuse et peu rentable pour un attaquant.