In short: In PHP, you store passwords with password_hash() and verify them with password_verify(). The default PASSWORD_DEFAULT currently uses bcrypt, with cost factor 12 since PHP 8.4; where PHP is compiled with Argon2, PASSWORD_ARGON2ID is a good alternative. You migrate old MD5 or SHA-1 hashes by wrapping them in password_hash immediately and replacing them with a direct hash at the next login.
What a Database Leak Has to Do with Your Hashes
After a break-in to a web server, the database is one of the first targets. Anyone who can execute code on the server, as is possible with PHP2Shell, also reads the application's credentials and with them the table of user accounts. Whether the passwords in it become readable after hours or after years is decided not on the day of the attack but during programming.
A surprising number of applications still store passwords with methods that were never meant for this purpose: MD5, SHA-1, a plain SHA-256 or even reversibly encrypted. These methods are fast, and that is exactly the problem. Attackers can try a very large number of candidates per second with them.
PHP has long come with a solution that computes slowly, automatically generates a salt for every password and stores the algorithm and parameters in the hash itself. You just have to use it consistently.
password_hash in PHP: bcrypt or Argon2id?
password_hash() supports several algorithms. The selection according to the PHP manual:
| Constant | Algorithm | Note |
|---|---|---|
PASSWORD_DEFAULT |
currently bcrypt | default, may change in future versions |
PASSWORD_BCRYPT |
bcrypt | cost factor 12 by default since PHP 8.4, 10 before; only the first 72 bytes count |
PASSWORD_ARGON2I |
Argon2i | since PHP 7.2, only if PHP is built with Argon2 support |
PASSWORD_ARGON2ID |
Argon2id | since PHP 7.3, same requirement |
OWASP recommends Argon2id as the first choice, with at least 19 MiB of memory, two iterations and one thread, and bcrypt mainly for existing systems. For most PHP applications, the difference in practice is smaller than the difference between any of these algorithms and MD5. What matters more is that you use one of them at all and adjust the parameters regularly.
A short test shows whether Argon2id is available:
<?php
var_dump(defined('PASSWORD_ARGON2ID'));
var_dump(password_algos());
Storing and Verifying: A Complete Pattern
The following example shows registration and login with automatic updating of outdated hashes. The length check protects against bcrypt's 72-byte limit:
<?php
declare(strict_types=1);
const PASSWORT_ALGO = PASSWORD_DEFAULT;
function passwortSpeichern(string $passwort): string
{
if (strlen($passwort) > 72) {
throw new InvalidArgumentException('Passwort zu lang für das gewählte Verfahren.');
}
return password_hash($passwort, PASSWORT_ALGO);
}
function passwortPruefen(string $eingabe, string $gespeicherterHash, callable $hashAktualisieren): bool
{
if (!password_verify($eingabe, $gespeicherterHash)) {
return false;
}
if (password_needs_rehash($gespeicherterHash, PASSWORT_ALGO)) {
$hashAktualisieren(password_hash($eingabe, PASSWORT_ALGO));
}
return true;
}
strlen() counts bytes, not characters. That is intended here, because the bcrypt limit refers to bytes. According to the manual, the database column should hold more than 60 characters; 255 is a good choice. Never set your own salt: the option is deprecated and has been ignored since PHP 8.0.
With the switch to PHP 8.4, the default cost factor for bcrypt rises from 10 to 12. Thanks to password_needs_rehash(), existing hashes are raised automatically at the next login. On heavily loaded systems, a short load test of the login is worthwhile before switching.
Measure the cost factor on your production server. A single verification should cost noticeable computing time but stay well under one second, so that logins do not become a bottleneck under load.
Migrating Old MD5 and SHA-1 Hashes
The real challenge is usually the existing data. You do not know the passwords themselves, so you cannot simply hash them again. Waiting until every user logs in again leaves inactive accounts unprotected. OWASP therefore describes a two-stage approach:
- Wrap immediately: Wrap every existing MD5 or SHA-1 value with
password_hash()by script and store it with a marker, for example in a columnhash_typ. - Check at login: For marked accounts, first convert the input with the old algorithm and then check it against the wrapped value with
password_verify(). - Replace directly: After a successful login, generate a normal hash from the plaintext and remove the marker.
- Clean up the remainder: Set accounts that still have wrapped hashes after a defined deadline to password reset.
<?php
// Stage 1 (once, by script): $neu = password_hash($alterMd5Wert, PASSWORD_DEFAULT);
// Stages 2 and 3 at login:
if ($konto['hash_typ'] === 'md5-umhuellt') {
$ok = password_verify(md5($eingabe), $konto['passwort_hash']);
if ($ok) {
$speichere(password_hash($eingabe, PASSWORD_DEFAULT), 'direkt');
}
}
Wrapped hashes are better than bare MD5 values but weaker than direct hashes. They are a transitional solution, not a goal. After an incident in which the database may have been affected, this migration does not replace asking users to change their passwords. Which traces point to such an incident is described in Am I Already Compromised?.
Pepper, Encryption and Other Wrong Turns
Some ideas sound like more security but achieve little or do harm:
- Custom hash constructions such as repeated SHA-256 with a self-generated salt are faster than bcrypt and therefore weaker.
- Encrypted passwords are reversible. If the key is on the same server, the encryption hardly protects after a break-in.
- A pepper, that is, a secret additional value for all passwords, can be an extra layer if it is kept separate from the database. But it does not replace a good hash algorithm and makes later changes harder.
- Pre-hashing before bcrypt to get around the 72-byte limit carries risks of its own, according to OWASP. A clear length limit or Argon2id is simpler.
For the rules on new passwords, too, a look at the state of the art is worthwhile: length protects more than complicated requirements about special characters, and very common passwords or ones that have already appeared in data leaks should be rejected. Forced changes at fixed intervals without cause tend, in experience, to lead to predictable patterns rather than stronger passwords.
For login systems, passwords should also never stand alone: two-factor methods for admins and a brake on mass login attempts complement the storage. The guide Protecting Admin Logins Against Brute Force covers both. Other rules apply to reset links and one-time codes, see Secure Random Numbers and Tokens in PHP. An overview of further functions is given in Built-in Security Functions of PHP.
How does PHP SOS help?
- We check how your application and your plugins store passwords, and find outdated methods in secondary functions too.
- We plan the migration of the existing data with wrapping, login migration and a deadline for remaining accounts.
- We tune the cost factor or Argon2 parameters to your servers.
- After an incident, we support the decision on password resets and their technical implementation.
We offer a review of your own PHP software as a security audit and penetration test.
Frequently asked questions
Which algorithm does password_hash use with PASSWORD_DEFAULT?
Currently bcrypt. The PHP manual points out that the default may change when stronger algorithms are added. The database column should therefore hold more than 60 characters; the manual names 255 as a good choice.
Should I encrypt passwords instead of hashing them?
No. Encrypted passwords can be read again with the key, and this key is usually on the same server. A password hash is a one-way street: the application can only check whether an input matches.
How high should the cost factor for bcrypt be?
Since PHP 8.4 the default is 12, before that 10. OWASP names 10 as the lower limit and recommends going as high as your servers can bear. Measure on your own hardware how long a verification takes.
What does the 72-byte limit in bcrypt mean?
bcrypt only takes the first 72 bytes of a password into account; everything after that is cut off. With umlauts, one character often counts as two bytes. So limit the length explicitly, or use Argon2id if your PHP is compiled with it.
Do users have to change their passwords after a break-in?
If it cannot be ruled out that the database was leaked, you should arrange for that. Good hashes slow down cracking considerably, but weak passwords remain vulnerable. Whether a reporting obligation also exists, clarify with your data protection officer.