Salts
A salt is extra data added to a string before password hashing. It is named “salt” because it is similar to adding table salt to food—it modifies the food slightly and improves it. A string being encrypted is improved by adding a salt value because the algorithm outputs a different hash than what it would without the salt. This prevents the hash from matching any pre-computed hash stored in rainbow tables. Using salt with passwords is the best defense against rainbow tables.
A salt adds some piece of custom data which is unique to this hashing process so that it will not be like anyone else’s hashing process. With a salt in place, an attacker would need to know the salt value and then also re-compute an entire set of rainbow tables to crack the password.
Salt with md5() (using a non-random salt)
<?php
$password = 'password1234';
echo md5($password);
// bdc87b9c894da5168059e00ebffb9077
echo md5($password . 'any string');
// 17b5c2d5e5bc4418a65c19b2af58ce2d
?>
Rainbow tables could have any entry for the first hash—sadly ‘password1234’ is a common password. Rainbow tables could have an entry for the second hash too, but if they do, the string that is associated with it would not be the correct password.
Fresh, Random Salt
While adding any salt at all will prevent rainbow tables from finding the password, unfortunately, the password will still not be very secure. If an attacker is able to decrypt a few of the passwords, the salt would become obvious.
Instead of using a fixed string, a salt should always be a random string, generated fresh for each hash. If every password gets its own random salt, then discovering one will not be helpful for any other.
As funny as it sounds, random functions do not return equally random values. Some are “more random” than others. For cryptography it is important to pick values from the “most random” end of the scale. random_bytes() is well-designed and is the easiest way to get values random enough for use in encryption. The return value needs to be converted from binary to a string using base64_encode().
<?php
function random_string($length=22) {
// random_bytes requires an integer of at least 1
$length = max(1, (int) $length);
// generates a longer string than needed
$rand_str = base64_encode(random_bytes($length));
// substr cuts it to the correct size
return substr($rand_str, 0, $length);
}
?>
For hashing algorithms, this means keeping track of the salt used so that it can be used again to hash a candidate password to see if it matches the stored password. The salt could be stored in a database (as a new column next to the encrypted password column). Alternatively, the salt can be joined with the encrypted password with a distinct separating character between them (to split them up again later). This is the approach which bcrypt uses.
Salts for bcrypt
Salt values used for bcrypt are 22 characters long and contain only letters, numbers, . and /. In modern PHP you should not generate this salt or call crypt() yourself. Use password_hash() instead — it picks a cryptographically secure random salt for every hash and embeds it (together with the algorithm and cost) in the returned string. This is both safer and easier to use correctly than rolling the salt manually.
<?php
$password = 'password1234';
// password_hash() picks the salt for you and embeds it in the output.
$hash = password_hash($password, PASSWORD_BCRYPT, ['cost' => 12]);
echo $hash;
// $2y$12$2701e447941be9fd4652duwJbh/eGrIGzuSpJVL3t2GEwYXD2Gx1q
?>
Notice that the format string ($2y$12$) and a 22-character salt are included at the start of the returned hash. This is the technique bcrypt uses to keep track of the salt it used for hashing: it prepends the algorithm identifier, cost, and salt value to the hash itself.
When OWASP’s Password Storage guidance is followed, Argon2id is preferred over bcrypt where available. PHP exposes Argon2id through the same API:
<?php
$hash = password_hash($password, PASSWORD_ARGON2ID);
?>
To verify a candidate password against a stored hash, use password_verify(). The PHP manual notes that this function is safe against timing attacks.
<?php
$is_match = password_verify($new_password, $hashed_password);
?>
Do not compare hashes with == or ===. Those comparisons return as soon as the first differing byte is found, so the time taken leaks information about the stored hash. password_verify() performs the comparison in constant time. (If you need to compare other secrets in PHP, use hash_equals(), which has the same property.)