Hashowanie haseł — password_hash i password_verify
🎯 Po tej lekcji będziesz bezpiecznie hashować hasła przez password_hash i weryfikować je przez password_verify, rozumiejąc dlaczego md5 i sha1 są niebezpieczne.
Baza danych wycieka. Statystycznie to nie „czy" — to „kiedy". Jeśli hasła twoich użytkowników są w bazie jako zwykły tekst lub md5, atakujący w kilka minut pozna je wszystkie. Ta lekcja nauczy cię jedynego właściwego sposobu przechowywania haseł w PHP.
Dlaczego nie wolno używać md5 ani sha1
Zobaczmy najpierw, jak nie hashować haseł:
<?php
// ❌ ANTY-PRZYKŁAD — NIGDY tak nie rób!
declare(strict_types=1);
$haslo = 'tajneHaslo';
$hash = md5($haslo); // wynik: np. "d0f85e85b1..." ← NIEBEZPIECZNE
$hash2 = sha1($haslo); // wynik: np. "f5af..." ← TEŻ NIEBEZPIECZNE
// Oba algorytmy są łatwe do złamania przez:
// - Bazy tęczowych tablic (miliardy preobliczonych par haslo→hash)
// - Szybkie GPU (miliardy prób na sekundę)
Problemy z md5 i sha1:
- Są zbyt szybkie — GPU może sprawdzić miliardy kombinacji na sekundę.
- Istnieją ogólnodostępne bazy (rainbow tables) z miliardami gotowych hashy.
- Są deterministyczne bez soli — to samo hasło daje ten sam hash u każdego użytkownika.
md5($sól . $hasło)) — nie rób tego. PHP ma gotowe, bezpieczne narzędzie.password_hash — jedyne właściwe narzędzie
PHP 5.5 dodał funkcję password_hash, która rozwiązuje wszystkie powyższe problemy:
<?php
declare(strict_types=1);
$haslo = 'tajneHaslo';
$hash = password_hash($haslo, PASSWORD_DEFAULT);
echo $hash;
// wynik: $2y$12$UJ7FSmB2s4eP7y... (60 znaków, bcrypt)
Co robi password_hash za kulisami:
- Generuje losową sól — automatycznie, przy każdym wywołaniu. Dlatego ten sam ciąg wejściowy daje różny hash za każdym razem.
- Używa bcrypt lub argon2 — algorytmów celowo zaprojektowanych do haseł, celowo powolnych, żeby GPU nie pomogło.
- Zawiera sól w hashu — nie musisz jej przechowywać osobno. Wynikowy ciąg to pełna informacja: algorytm + koszt + sól + hash.
<?php
declare(strict_types=1);
$haslo = 'tajneHaslo';
$hash1 = password_hash($haslo, PASSWORD_DEFAULT);
$hash2 = password_hash($haslo, PASSWORD_DEFAULT);
// Te dwa hashe RÓŻNIĄ SIĘ, choć hasło to samo:
var_dump($hash1 === $hash2); // wynik: bool(false)
// Oba są jednak poprawne — sprawdzisz to przez password_verify.
Algorytmy
Stała PASSWORD_DEFAULT to zalecana wartość:
- W PHP 8 wybiera bcrypt (
$2y$). - Gdy PHP doda lepszy domyślny algorytm, twoje nowe hashe automatycznie go użyją.
- Możesz też użyć
PASSWORD_ARGON2ID(argon2id — nowszy, odporniejszy na ataki GPU/FPGA) jeśli twój serwer ma PHP skompilowane z obsługą argon2.
<?php
declare(strict_types=1);
// bcrypt (domyślny, zawsze dostępny):
$hash = password_hash($haslo, PASSWORD_DEFAULT);
// argon2id (nowszy, bezpieczniejszy, wymaga obsługi serwera):
$hash = password_hash($haslo, PASSWORD_ARGON2ID);
haslo_hash. Nie wpisuj 32 znaków jak dla md5 — hash nie zmieści się i zostanie obcięty.password_verify — sprawdzenie hasła przy logowaniu
Przy logowaniu nie możesz porównać hashy wprost ($podany === $hash) — bo sól jest losowa i każdy hash jest inny. Używasz password_verify:
<?php
declare(strict_types=1);
// Symulacja: hash przechowywany w bazie
$hashZBazy = password_hash('tajneHaslo', PASSWORD_DEFAULT);
// Użytkownik próbuje się zalogować:
$hasloPodaneWFormularzu = 'tajneHaslo';
if (password_verify($hasloPodaneWFormularzu, $hashZBazy)) {
echo 'Zalogowano!';
} else {
echo 'Złe hasło.';
}
// wynik: Zalogowano!
// Próba z błędnym hasłem:
if (password_verify('innePaslo', $hashZBazy)) {
echo 'Zalogowano!';
} else {
echo 'Złe hasło.';
}
// wynik: Złe hasło.
password_verify wyciąga sól i algorytm z hasha, oblicza hash podanego hasła i porównuje bezpiecznie (timing-safe comparison wbudowane w PHP).
Kompletny przykład — rejestracja i logowanie
Oto uproszczona implementacja rejestracji i logowania z hashowaniem:
<?php
declare(strict_types=1);
// ── REJESTRACJA ──────────────────────────────────────────────
// (w rzeczywistości $pdo pochodzi z klasy połączenia PDO)
function zarejestrujUzytkownika(PDO $pdo, string $login, string $haslo): void
{
// Walidacja
if (strlen($login) < 3) {
throw new InvalidArgumentException('Login musi mieć co najmniej 3 znaki.');
}
if (strlen($haslo) < 8) {
throw new InvalidArgumentException('Hasło musi mieć co najmniej 8 znaków.');
}
// Hashowanie — JEDYNA właściwa metoda
$hashHasla = password_hash($haslo, PASSWORD_DEFAULT);
// Zapis do bazy przez prepared statement
$stmt = $pdo->prepare(
'INSERT INTO users (login, haslo_hash) VALUES (:login, :hash)'
);
$stmt->execute([':login' => $login, ':hash' => $hashHasla]);
}
// ── LOGOWANIE ────────────────────────────────────────────────
function zalogujUzytkownika(PDO $pdo, string $login, string $podaneHaslo): ?array
{
// Pobierz użytkownika z bazy
$stmt = $pdo->prepare(
'SELECT id, login, haslo_hash FROM users WHERE login = :login'
);
$stmt->execute([':login' => $login]);
$uzytkownik = $stmt->fetch();
if ($uzytkownik === false) {
// Login nie istnieje — nie mów o tym użytkownikowi (nie zdradzaj co istnieje)
return null;
}
if (!password_verify($podaneHaslo, $uzytkownik['haslo_hash'])) {
return null;
}
return $uzytkownik;
}
Kolejność sprawdzania — ważna dla bezpieczeństwa
Zauważ: gdy login nie istnieje, zwracamy null — ten sam wynik co przy złym haśle. Nie mów użytkownikowi „login nie istnieje" (atakujący mógłby sprawdzać, które loginy istnieją w systemie).
Nigdy własne solenie — dlaczego
Może masz ochotę napisać: md5($sol . $haslo). Nie rób tego:
- Sól nie ratuje słabego algorytmu — md5 dalej jest za szybki, GPU rozbije hash z solą.
password_hashsoli automatycznie — lepiej, bezpieczniej, bez twojego udziału.- Własna implementacja = nieznane błędy — kryptografia to teren, gdzie drobny błąd niszczy całe zabezpieczenie.
password_hash i password_verify — używaj ich. Koniec.Podsumowanie
| Sytuacja | Co robisz |
|---|---|
| Rejestracja (zapis hasła) | password_hash($haslo, PASSWORD_DEFAULT) → zapisz wynik do bazy |
| Logowanie (sprawdzenie hasła) | password_verify($podane, $hashZBazy) → true/false |
| Nigdy | md5, sha1, sha256, własne solenie, zwykły tekst |
W kolejnej lekcji zajmiemy się ochroną przed XSS i CSRF — dwoma atakami, które działają po stronie przeglądarki użytkownika.
Funkcje w tej lekcji
Sprawdź się
Dlaczego nie wolno używać md5() do hashowania haseł użytkowników?
Algorytm md5 powstał jako suma kontrolna pliku, nie jako funkcja do haseł. Atakujący korzystają z gotowych baz tęczowych tablic (rainbow tables) z miliardami preobliczonych hashy. Znalezienie oryginału zajmuje ułamek sekundy.
Co zwraca password_verify('tajne', $hash), gdy $hash jest poprawnym hashem słowa 'tajne'?
password_verify() sprawdza zgodność hasła z hashem i zwraca true lub false. Nie odtwarza oryginalnego hasła — hashowanie jest jednokierunkowe. To właśnie o to chodzi: nawet PHP nie zna hasła użytkownika, zna tylko jego hash.
Który stały algorytm zaleca się przekazać do password_hash() w PHP 8?
PASSWORD_DEFAULT używa bcrypt w PHP 8 i automatycznie przejdzie na lepszy algorytm gdy PHP go doda. Jeśli zahaszujesz hasła z PASSWORD_DEFAULT, funkcja password_needs_rehash() poinformuje cię gdy warto je odświeżyć.
Ćwiczenie
Napisz dwie funkcje: zarejestrujUzytkownika(string $login, string $haslo): array — zwraca tablicę ['login' => $login, 'hash' => $hash]. Oraz zalogujUzytkownika(string $podaneHaslo, array $uzytkownik): bool — sprawdza czy podane hasło pasuje do hasha w tablicy. Przetestuj obie funkcje.
Pokaż rozwiązanie
<?php
declare(strict_types=1);
function zarejestrujUzytkownika(string $login, string $haslo): array
{
$hash = password_hash($haslo, PASSWORD_DEFAULT);
return ['login' => $login, 'hash' => $hash];
}
function zalogujUzytkownika(string $podaneHaslo, array $uzytkownik): bool
{
return password_verify($podaneHaslo, $uzytkownik['hash']);
}
// Symulacja rejestracji:
$uzytkownik = zarejestrujUzytkownika('ola', 'SuperHaslo99!');
echo $uzytkownik['hash']; // np. $2y$12$...
// Symulacja logowania:
var_dump(zalogujUzytkownika('SuperHaslo99!', $uzytkownik)); // wynik: bool(true)
var_dump(zalogujUzytkownika('zleHaslo', $uzytkownik)); // wynik: bool(false)