PHP'de kullanıcı şifresini saklamanın doğru yolu, şifreyi düz metin ya da md5() gibi hızlı bir özetle değil, password_hash() ile üretilen tek yönlü bir hash olarak saklamaktır. Giriş sırasında kullanıcının yazdığı şifreyi kayıtlı hash ile password_verify() karşılaştırır; ayarlar zamanla değiştiğinde de password_needs_rehash() hash'in yenilenip yenilenmeyeceğini söyler. Tuz (salt), algoritma ve maliyet bilgisi hash'in içinde saklandığı için ayrı bir sütuna gerek yoktur.
Bu yazı php.net kılavuzunun parola hashleme sayfalarına ve "Safe Password Hashing" SSS bölümüne dayanır. Örnekler PHP 8.5.12 ile çalıştırıldı. Hash'ler her çalıştırmada rastgele tuz ile üretildiği için örneklerde hash'in kendisi yerine uzunluk, önek ve doğrulama sonucu gibi her seferinde aynı çıkan değerler gösteriliyor. Token ve doğrulama kodu üretmek için random_bytes() ve random_int() de ele alınıyor. Serinin genel planı PHP öğrenme rehberi sayfasında.
Neden md5 veya sha1 değil?
Kılavuzun SSS bölümü bunu açık biçimde anlatır: MD5, SHA1 ve SHA256 gibi algoritmalar hızlı ve verimli olacak şekilde tasarlanmıştır. Bu, dosya bütünlüğü kontrolü için iyi bir özelliktir, fakat şifre için zayıflıktır; günümüz donanımıyla bu çıktıları kaba kuvvetle denemek kolaylaşmıştır. Ayrıca tuz kullanılmayan bir özet, aynı şifre için her zaman aynı sonucu verir:
<?php
var_dump(md5('parola')); // çıktı: string(32) "8287458823facb8ff918dbfabcd22ccb"
var_dump(md5('parola') === md5('parola')); // çıktı: bool(true)Aynı girdinin hep aynı çıktıyı vermesi, önceden hesaplanmış girdi ve hash çiftlerinden oluşan "rainbow table" listelerinde aramayı mümkün kılar. Tuz, hash'leme sırasında eklenen rastgele bir veridir ve bu tür hazır listeleri işe yaramaz hâle getirir. Kılavuzun önerdiği yol, hem yeterince pahalı bir algoritma hem de otomatik tuz kullanan yerleşik parola API'sidir. SSS ayrıca önemli bir sınırı da hatırlatır: Hashleme, veri deposu ele geçirildiğinde şifreleri korur; uygulamaya sızmış kötü amaçlı bir kodun şifreyi girildiği anda yakalamasını engellemez.
password_hash: hash üretmek

İmza şöyledir: password_hash(string $password, string|int|null $algo, array $options = []): string. İkinci parametre olarak çoğu durumda PASSWORD_DEFAULT verilir. Bu sabit şu an bcrypt algoritmasına karşılık gelir ve $2y$ önekli, crypt() uyumlu bir hash üretir. Kılavuz bu sabitin, PHP'ye daha güçlü algoritmalar eklendikçe zamanla değişebilecek şekilde tasarlandığını vurgular.
<?php
$hash = password_hash('Gizli-Parola-2026', PASSWORD_DEFAULT);
var_dump(strlen($hash)); // çıktı: int(60)
var_dump(str_starts_with($hash, '$2y$12$')); // çıktı: bool(true)
var_dump(password_verify('Gizli-Parola-2026', $hash)); // çıktı: bool(true)
var_dump(password_verify('gizli-parola-2026', $hash)); // çıktı: bool(false)
$ikinci = password_hash('Gizli-Parola-2026', PASSWORD_DEFAULT);
var_dump($hash === $ikinci); // çıktı: bool(false)
var_dump(password_verify('Gizli-Parola-2026', $ikinci)); // çıktı: bool(true)Bu çıktılar birkaç şeyi gösterir. bcrypt hash'i her zaman 60 karakterdir ve $2y$, ardından maliyet değeri (12) ile başlar. Doğrulama büyük-küçük harfe duyarlıdır. Aynı şifre iki kez hash'lendiğinde iki farklı sonuç çıkar, çünkü her çağrıda yeni bir rastgele tuz üretilir; buna rağmen ikisi de doğrulamayı geçer. Bu nedenle şifre kontrolünü asla "hash'i yeniden üret ve karşılaştır" mantığıyla yapamazsınız; her zaman password_verify() kullanılır.
Veritabanı sütunu: neden 255?
bcrypt bugün 60 karakter üretse de PASSWORD_DEFAULT ileride başka bir algoritmaya geçebileceği için sonuç uzunluğu da değişebilir. Kılavuz, hash'i 60 baytın ötesine genişleyebilen bir sütunda saklamanızı ve 255 baytın iyi bir seçim olduğunu belirtir. CHAR(60) gibi dar bir sütun, gelecekteki bir algoritma değişikliğinde hash'in kesilmesine ve hiçbir kullanıcının giriş yapamamasına yol açabilir.
Maliyet (cost) ve PHP 8.4 değişikliği
bcrypt için cost seçeneği algoritmanın ne kadar pahalı çalışacağını belirler. PHP 8.4 ile varsayılan değer 10'dan 12'ye yükseltildi; bu değişiklik hem fonksiyonun sürüm notlarında hem de 8.4 geçiş belgesinde yer alır. Kılavuz 12'yi iyi bir başlangıç olarak tanımlar ama değerin donanıma göre ayarlanmasını önerir: Etkileşimli girişlerde hash işleminin kendi makinenizde 350 milisaniyenin altında kalacağı en yüksek maliyeti seçmek tavsiye edilir. Maliyeti elle vermek için password_hash($sifre, PASSWORD_BCRYPT, ['cost' => 13]) yazılır.
Hash'in hangi algoritma ve ayarlarla üretildiğini password_get_info() gösterir. password_algos() ise çalışan PHP'nin desteklediği algoritma kimliklerini listeler; aşağıdaki listede Argon2 seçeneklerinin görünmesi, bu derlemenin Argon2 desteğiyle derlendiğini gösterir. Kılavuza göre Argon2 algoritmaları yalnızca PHP Argon2 desteğiyle derlendiğinde kullanılabilir; sunucunuzda farklı bir liste görebilirsiniz.
<?php
$hash = password_hash('parola', PASSWORD_BCRYPT, ['cost' => 10]);
print_r(password_get_info($hash));
/*
Array
(
[algo] => 2y
[algoName] => bcrypt
[options] => Array
(
[cost] => 10
)
)
*/
var_dump(PASSWORD_DEFAULT); // çıktı: string(2) "2y"
var_dump(PASSWORD_BCRYPT_DEFAULT_COST); // çıktı: int(12)
print_r(password_algos());
/*
Array
(
[0] => 2y
[1] => argon2i
[2] => argon2id
)
*/PHP 7.4'ten beri algoritma sabitlerinin değerleri tam sayı değil string'dir; PASSWORD_DEFAULT değerinin "2y" olması bundandır.
Argon2i ve Argon2id
PASSWORD_ARGON2I (PHP 7.2) ve PASSWORD_ARGON2ID (PHP 7.3) sabitleri Argon2 ailesini seçer. Bu algoritmalar üç seçenek alır: memory_cost (kibibayt cinsinden en fazla bellek), time_cost (yineleme sayısı) ve threads (iş parçacığı sayısı). threads seçeneği yalnızca libargon2 uygulamasında geçerlidir, libsodium uygulamasında yoktur. PHP 8.4 geçiş notları, OpenSSL eklentisine OpenSSL 3.2 ve NTS derleme gerektiren bir Argon2 uygulaması eklendiğini belirtir. Hangi algoritmanın kullanılacağına karar verirken önce password_algos() ile sunucunuzun desteğini kontrol edin; yoksa PASSWORD_DEFAULT ile kalmak en taşınabilir seçimdir.
password_verify ve zamanlama saldırıları

password_verify(string $password, string $hash): bool şifre ile hash eşleşirse true, aksi hâlde false döndürür. Algoritma, maliyet ve tuz hash'in içinde olduğu için başka bilgiye ihtiyaç duymaz. Kılavuz bu fonksiyonun zamanlama saldırılarına (timing attack) karşı güvenli olduğunu açıkça belirtir. Zamanlama saldırısı, iki değerin karşılaştırılma süresindeki küçük farklardan gizli değer hakkında bilgi çıkarılmasıdır. SSS bölümü de aynı nedenle, hash'i yeniden üretip saklı değerle karşılaştırmak yerine her zaman password_verify() kullanılmasını ister.
Bir karşılaştırmanın sabit sürede yapılması gerektiği başka durumlar da vardır; örneğin bir imzanın (HMAC) doğrulanması. Bunun için hash_equals() kullanılır. Kılavuz, kullanıcıdan gelen değerin ikinci parametre olarak verilmesi gerektiğini vurgular; uzunlukları farklı iki değer verilirse fonksiyon hemen false döner.
password_needs_rehash: hash'i güncel tutmak
Varsayılan maliyet PHP 8.4'te 10'dan 12'ye çıktığı için, daha eski bir sürümle üretilmiş hash'ler veritabanınızda hâlâ eski ayarla duruyor olabilir. password_needs_rehash(), verilen hash'in istediğiniz algoritma ve seçeneklerle üretilip üretilmediğini kontrol eder; değilse true döner. Şifrenin düz hâline yalnızca giriş anında sahip olduğunuz için yenileme de tam o anda, doğrulama başarılı olduktan sonra yapılır.
<?php
// Eski kayıt: cost 10 ile üretilmiş bir hash
$kayitliHash = password_hash('parola', PASSWORD_BCRYPT, ['cost' => 10]);
function girisYap(string $girilen, string &$hash): bool
{
if (!password_verify($girilen, $hash)) {
return false;
}
if (password_needs_rehash($hash, PASSWORD_DEFAULT)) {
$hash = password_hash($girilen, PASSWORD_DEFAULT);
echo "hash güncellendi\n";
}
return true;
}
var_dump(girisYap('yanlis', $kayitliHash)); // çıktı: bool(false)
var_dump(girisYap('parola', $kayitliHash));
/*
hash güncellendi
bool(true)
*/
var_dump(girisYap('parola', $kayitliHash)); // çıktı: bool(true)
echo substr($kayitliHash, 0, 7), "\n"; // çıktı: $2y$12$İlk başarılı girişte hash eski maliyetle üretildiği için yenilendi; ikinci girişte artık güncel olduğu için yeniden hash'lenmedi. Gerçek bir uygulamada yeni hash'i UPDATE ile veritabanına yazarsınız. Örnekte hash'i fonksiyonun içinden değiştirebilmek için referans parametresi (&$hash) kullanıldı; bu konu PHP referansları yazısında anlatılıyor.
Uygulamalı örnek: kayıt ve giriş
Aşağıdaki küçük örnek, bir üyelik tablosunda şifreyi saklamayı ve doğrulamayı bir araya getirir. Sorgular hazırlanmış ifadelerle çalışır; PDO ayrıntıları PDO veritabanı bağlantısı yazısında.
<?php
$pdo = new PDO('sqlite::memory:');
$pdo->exec('CREATE TABLE uyeler (eposta TEXT PRIMARY KEY, sifre_hash VARCHAR(255) NOT NULL)');
$ekle = $pdo->prepare('INSERT INTO uyeler (eposta, sifre_hash) VALUES (?, ?)');
$ekle->execute(['ayse@example.com', password_hash('Kayıt-Şifresi-1', PASSWORD_DEFAULT)]);
function dogrula(PDO $pdo, string $eposta, string $sifre): bool
{
$s = $pdo->prepare('SELECT sifre_hash FROM uyeler WHERE eposta = ?');
$s->execute([$eposta]);
$hash = $s->fetchColumn();
if ($hash === false) {
return false;
}
return password_verify($sifre, $hash);
}
var_dump(dogrula($pdo, 'ayse@example.com', 'Kayıt-Şifresi-1')); // çıktı: bool(true)
var_dump(dogrula($pdo, 'ayse@example.com', 'kayıt-şifresi-1')); // çıktı: bool(false)
var_dump(dogrula($pdo, 'yok@example.com', 'Kayıt-Şifresi-1')); // çıktı: bool(false)Kayıtlı olmayan bir e-posta ile yanlış şifre aynı sonucu (false) döndürür. Kullanıcıya da "e-posta veya şifre hatalı" gibi tek bir mesaj göstermek, hangi e-postaların sistemde kayıtlı olduğunun dışarıya sızmasını zorlaştırır. Formdan gelen e-postanın biçimini kontrol etmek için filter_var ile doğrulama yazısındaki FILTER_VALIDATE_EMAIL kullanılabilir. Şifrenin kendisini ise doğrulama ya da temizleme filtresinden geçirmeyin; kullanıcının yazdığı haliyle hash'leyin.
bcrypt'in 72 bayt sınırı
Kılavuz önemli bir sınırı "Caution" notuyla belirtir: PASSWORD_BCRYPT kullanıldığında şifre en fazla 72 bayta kesilir. Yani ilk 72 baytı aynı olan iki uzun şifre aynı hash'le doğrulanır:
<?php
$uzun = str_repeat('a', 72);
$hash = password_hash($uzun, PASSWORD_BCRYPT);
var_dump(password_verify($uzun . 'FARKLI-SON', $hash)); // çıktı: bool(true)
var_dump(strlen('ğ')); // çıktı: int(2)Sınır karakter değil bayt üzerindendir. UTF-8'de "ğ", "ş", "ı" gibi Türkçe harfler ikişer bayt tuttuğu için Türkçe karakterli bir şifre daha az harfte bu sınıra ulaşır. Pratikte çoğu kullanıcı şifresi bu uzunluğa yaklaşmaz, ama uzun parola cümlelerine izin veriyorsanız bu davranışı bilmeniz gerekir. Bayt ve karakter farkı mbstring ve UTF-8 yazısında ayrıntılı anlatılıyor.
Hatalar: PHP 8 ile false yerine istisna
PHP 8.0 öncesinde password_hash() başarısızlıkta false döndürebiliyordu. PHP 8.0'dan beri geçersiz bir algoritma verildiğinde ValueError, bilinmeyen bir hata olduğunda Error fırlatılır; dönüş tipi de artık yalnızca string. Aşağıdaki yığın izinde şifre parametresinin değeri yerine Object(SensitiveParameterValue) yazdığına dikkat edin: Parametre #[\SensitiveParameter] ile işaretli olduğu için değeri izde gizlenir.
<?php
password_hash('x', 'olmayan-algoritma');
/*
Fatal error: Uncaught ValueError: password_hash(): Argument #2 ($algo) must be a valid password hashing algorithm in /yol/ornek.php:2
Stack trace:
#0 /yol/ornek.php(2): password_hash(Object(SensitiveParameterValue), 'olmayan-algorit...')
#1 {main}
thrown in /yol/ornek.php on line 2
*/salt seçeneği PHP 7.0'da kullanımdan kaldırıldı ve PHP 8.0'dan beri elle verilen tuz yok sayılıyor. Kılavuz tuzu kendinizin vermemesini, otomatik üretilen tuzu kullanmanızı açıkça önerir. ValueError ve benzeri hataların ayrıntısı TypeError ve ValueError yazısında bulunuyor.
Token ve kod üretmek: random_bytes ve random_int
Şifre sıfırlama bağlantısı, e-posta doğrulama token'ı veya tek kullanımlık kod gibi tahmin edilmemesi gereken değerler için kriptografik olarak güvenli rastgelelik gerekir. random_bytes(int $length) istenen uzunlukta rastgele bayt döndürür; kılavuz bu rastgeleliğin şifreleme anahtarı gibi uzun ömürlü sırlar dahil tüm uygulamalar için uygun olduğunu belirtir. Dönen baytlar yazdırılamayan karakterler içerebileceği için genellikle bin2hex() veya base64_encode() ile kodlanır. random_int(int $min, int $max) ise iki uç değer dahil, eşit olasılıklı bir tam sayı verir.
<?php
$token = bin2hex(random_bytes(16));
var_dump(strlen($token)); // çıktı: int(32)
var_dump(ctype_xdigit($token)); // çıktı: bool(true)
$kod = random_int(100000, 999999);
var_dump($kod >= 100000 && $kod <= 999999); // çıktı: bool(true)
$beklenen = hash_hmac('sha256', 'kullanici=42', 'sunucu-anahtari');
$gelen = hash_hmac('sha256', 'kullanici=42', 'sunucu-anahtari');
var_dump(hash_equals($beklenen, $gelen)); // çıktı: bool(true)Kılavuz, rand() ve mt_rand() sayfalarında bu iki fonksiyonun kriptografik olarak güvenli değer üretmediğini ve tahmin edilmemesi gereken değerler için kullanılmaması gerektiğini açıkça yazar; basit durumlar için random_int() ve random_bytes() fonksiyonlarını, daha ayrıntılı ihtiyaçlar için Random\Engine\Secure motoruyla Random\Randomizer sınıfını önerir. 16 rastgele bayt, onaltılık gösterimde 32 karakterlik bir token eder. Uygun bir rastgelelik kaynağı bulunamazsa her iki fonksiyon da Random\RandomException fırlatır (PHP 8.2 öncesinde düz Exception fırlatılıyordu). Geçersiz bir uzunluk ise ValueError üretir:
<?php
random_bytes(0);
/*
Fatal error: Uncaught ValueError: random_bytes(): Argument #1 ($length) must be greater than 0 in /yol/ornek.php:2
Stack trace:
#0 /yol/ornek.php(2): random_bytes(0)
#1 {main}
thrown in /yol/ornek.php on line 2
*/Şifre sıfırlama token'larını da veritabanına düz hâlde yazmak yerine bir hash'ini saklamak, veritabanı sızıntısında token'ların doğrudan kullanılmasını zorlaştırır. Sıfırlama bağlantısını içeren URL'yi oluştururken çıktıyı kaçırma kuralları için XSS ve CSP yazısına bakabilirsiniz.
Sık yapılan hatalar

- Şifreyi
md5(),sha1()veya tek başınahash('sha256', ...)ile saklamak. Bunlar hızlıdır ve şifre için uygun değildir. - Hash sütununu
CHAR(60)yapmak.PASSWORD_DEFAULTdeğişebileceği için 255 karakterlik bir sütun kullanın. - Girişte şifreyi yeniden hash'leyip
===ile karşılaştırmak. Tuz her seferinde farklıdır;password_verify()kullanın. - Elle tuz vermeye çalışmak. PHP 8.0'dan beri
saltseçeneği yok sayılır. - Şifreyi
trim()veya bir filtreyle değiştirip sonra hash'lemek. Kullanıcı girişte de aynı dönüşümü beklemez; şifreyi olduğu gibi kullanın. - Token veya kod için
rand()ya damt_rand()kullanmak. Güvenlik amaçlı değerlerderandom_bytes()verandom_int()tercih edin. - Gizli değerleri
==ile karşılaştırmak. İmza ve token karşılaştırmasındahash_equals()kullanın.
Sık Sorulan Sorular
password_hash çıktısı neden her seferinde farklı?
Her çağrıda yeni bir rastgele tuz üretilir ve tuz hash'in içine yazılır. Bu yüzden aynı şifrenin hash'leri farklıdır; password_verify() tuzu hash'ten okuyarak doğrulamayı yapar.
Hash'ten şifreyi geri elde edebilir miyim?
Hayır. password_hash() tek yönlü bir hash üretir; amaç şifreyi geri çözmek değil, girilen şifrenin doğru olup olmadığını kontrol etmektir. "Şifremi unuttum" akışı bu yüzden eski şifreyi göstermek yerine yeni şifre belirletir.
PASSWORD_DEFAULT mı, PASSWORD_ARGON2ID mi?
PASSWORD_DEFAULT her PHP kurulumunda çalışır ve şu an bcrypt kullanır. Argon2 yalnızca PHP bu destekle derlendiyse kullanılabilir. Sunucunuz destekliyorsa ve bilinçli bir tercihse Argon2id seçebilirsiniz; emin değilseniz PASSWORD_DEFAULT ile kalın.
PHP 8.4'e geçince eski hash'ler çalışmaz mı?
Çalışır. Hash kendi maliyet değerini taşıdığı için password_verify() eski hash'leri doğrular. Yalnızca password_needs_rehash() artık bu hash'ler için true döner ve giriş sırasında yeni maliyetle güncelleyebilirsiniz.
Marka adları sahiplerine aittir. Bu yazı bağımsız bir teknik rehberdir; adı geçen kuruluşlarla bayilik veya ortaklık ilişkisi bulunduğu anlamına gelmez.
İlgili içerikler: PHP öğrenme rehberi · PHP PDO · PHP filter_var · SQL injection önleme · PHP 8.4 yenilikleri