Cryptography, Hashing and Passwords
|
This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production. The reference material consulted while preparing these pages is listed in the section bibliography. |
This page is written against PHP 8.5 (with the bundled sodium and OpenSSL extensions), checked on 2026-10-05. The golden rule of cryptography applies: do not invent your own. PHP ships well-designed, high-level APIs — the password functions and libsodium — that make the right choice for you; use them, and treat lower-level primitives (OpenSSL ciphers, raw hashes) as expert tools.
Hashing and message authentication
hash($algo, $data) computes a digest with any algorithm from hash_algos(): sha256, sha512, sha3-256,
blake2 variants through sodium, and fast non-cryptographic ones such as xxh3, xxh128, crc32c and murmur3
(8.1). Use SHA-256 or better for integrity checks, the xxHash family for checksums and cache keys where security does
not matter, and never a plain hash for passwords.
An HMAC (hash_hmac()) authenticates data with a secret key: only someone with the key can produce the same tag, so
it detects tampering with signed URLs, webhooks and cookies. Verify tags with hash_equals(), which compares in
constant time; == or === leak, through timing, how many leading characters matched.
<?php
declare(strict_types=1);
echo hash('sha256', 'Kindred'), "\n";
echo hash('xxh3', 'Kindred'), "\n"; // fast, non-cryptographic
// Signed download link for a member's loan history
$key = random_bytes(32); // in real code: a secret loaded from the environment
$query = http_build_query(['member' => 'M-17', 'expires' => 1_791_300_000]);
$signature = hash_hmac('sha256', $query, $key);
$url = "/loans/export?{$query}&signature={$signature}";
// Verifying a request
parse_str(parse_url($url, PHP_URL_QUERY), $params);
$given = $params['signature'];
unset($params['signature']);
$expected = hash_hmac('sha256', http_build_query($params), $key);
var_dump(hash_equals($expected, $given)); // bool(true) -- constant-time comparison
var_dump((int) $params['expires'] > 1_791_200_000); // and check the expiry
Source: hash, hash_hmac and hash_equals
Storing passwords
Passwords must be stored with a slow, salted, adaptive hash, so that a leaked database cannot be reversed with
precomputed tables or cheap GPUs. password_hash() does exactly this and stores the algorithm, cost and salt inside
the returned string:
-
PASSWORD_DEFAULT— currently bcrypt ($2y$), with a default cost of 12 since PHP 8.4 (10 before); -
PASSWORD_ARGON2ID— Argon2id, memory-hard, available when PHP is built with libargon2 or sodium.
password_verify() checks a candidate against the stored hash, and password_needs_rehash() tells you, at login,
whether the hash was made with older settings so you can upgrade it transparently.
<?php
declare(strict_types=1);
// Registration
$hash = password_hash('correct horse battery staple', PASSWORD_DEFAULT);
echo substr($hash, 0, 7), ' (', strlen($hash), " chars)\n"; // $2y$12$ (60 chars)
// Login
function login(string $password, string $storedHash): bool
{
if (!password_verify($password, $storedHash)) {
return false;
}
if (password_needs_rehash($storedHash, PASSWORD_DEFAULT)) { // e.g. after raising the cost
$newHash = password_hash($password, PASSWORD_DEFAULT);
// save $newHash for this user
}
return true;
}
var_dump(login('correct horse battery staple', $hash), login('Tr0ub4dor&3', $hash)); // bool(true) bool(false)
$argon = password_hash('correct horse battery staple', PASSWORD_ARGON2ID);
print_r(password_get_info($argon)['options']); // memory_cost 65536, time_cost 4, threads 1
Practical notes:
-
Store the hash in a column of at least 255 characters, as future algorithms produce longer strings.
-
bcrypt only uses the first 72 bytes of the password and rejects null bytes (
ValueError); Argon2id has neither limitation. -
Tune the cost so hashing takes roughly 100-500 ms on your servers; it is paid once per login.
-
Never hash passwords with
md5(),sha1()or a singlehash('sha256', …), with or without a salt; never encrypt them (encryption is reversible); never "sanitise" them withstrip_tags()orhtmlspecialchars()before hashing.
Source: Password Hashing, password_hash and Safe Password Hashing FAQ
Encryption with sodium
The sodium extension (bundled since 7.2) exposes libsodium, a modern library whose high-level functions choose safe algorithms and parameters:
| Function family | Purpose |
|---|---|
|
symmetric authenticated encryption with one secret key (XSalsa20-Poly1305) |
|
symmetric AEAD with additional data bound to the ciphertext |
|
public-key authenticated encryption between two parties (X25519) |
|
anonymous public-key encryption (only the recipient can decrypt) |
|
digital signatures (Ed25519) |
|
Argon2id password hashing (an alternative to |
|
BLAKE2b hashing, keyed or not |
|
wipe sensitive strings from memory |
<?php
declare(strict_types=1);
// Symmetric: encrypting a member's private note at rest
$key = sodium_crypto_secretbox_keygen(); // 32 random bytes; store it outside the database
$nonce = random_bytes(SODIUM_CRYPTO_SECRETBOX_NONCEBYTES); // unique per message, stored with the ciphertext
$ciphertext = sodium_crypto_secretbox('prefers large print', $nonce, $key);
$stored = base64_encode($nonce . $ciphertext);
$decoded = base64_decode($stored);
$plain = sodium_crypto_secretbox_open(
substr($decoded, SODIUM_CRYPTO_SECRETBOX_NONCEBYTES),
substr($decoded, 0, SODIUM_CRYPTO_SECRETBOX_NONCEBYTES),
$key,
);
var_dump($plain); // string(19) "prefers large print"
var_dump(sodium_crypto_secretbox_open($ciphertext, $nonce, sodium_crypto_secretbox_keygen())); // bool(false): wrong key
// AEAD: bind the ciphertext to the record it belongs to
$aeadKey = sodium_crypto_aead_xchacha20poly1305_ietf_keygen();
$aeadNonce = random_bytes(SODIUM_CRYPTO_AEAD_XCHACHA20POLY1305_IETF_NPUBBYTES);
$sealed = sodium_crypto_aead_xchacha20poly1305_ietf_encrypt('phone: 555-0100', 'member:M-17', $aeadNonce, $aeadKey);
var_dump(sodium_crypto_aead_xchacha20poly1305_ietf_decrypt($sealed, 'member:M-42', $aeadNonce, $aeadKey)); // bool(false): moved to another record
// Signatures: prove a catalogue export came from us
$keypair = sodium_crypto_sign_keypair();
$signature = sodium_crypto_sign_detached('catalogue-2026-10.csv contents', sodium_crypto_sign_secretkey($keypair));
var_dump(sodium_crypto_sign_verify_detached($signature, 'catalogue-2026-10.csv contents', sodium_crypto_sign_publickey($keypair))); // bool(true)
Use a separate key for each purpose (encryption, HMAC, signing), never reuse a nonce with the same key, and authenticate everything you encrypt (secretbox and AEAD do both at once).
OpenSSL
The OpenSSL extension covers TLS, X.509 certificates, CSRs, public keys and many ciphers. For symmetric encryption it
is lower-level than sodium: you pick the cipher and are responsible for the IV and authentication. If you must use it
(interoperability with other systems), use an AEAD mode such as aes-256-gcm and keep the tag:
<?php
declare(strict_types=1);
$key = random_bytes(32);
$iv = random_bytes(openssl_cipher_iv_length('aes-256-gcm')); // 12 bytes, unique per message
$ciphertext = openssl_encrypt('member note', 'aes-256-gcm', $key, OPENSSL_RAW_DATA, $iv, $tag);
$plain = openssl_decrypt($ciphertext, 'aes-256-gcm', $key, OPENSSL_RAW_DATA, $iv, $tag);
var_dump($plain, strlen($tag)); // string(11) "member note" int(16)
var_dump(openssl_decrypt($ciphertext, 'aes-256-gcm', $key, OPENSSL_RAW_DATA, $iv, str_repeat("\0", 16))); // bool(false)
Avoid ECB and unauthenticated CBC modes; if a format requires CBC, add an HMAC over IV and ciphertext with a separate
key and verify it with hash_equals() before decrypting.
Source: openssl_encrypt and OpenSSL
Random values and secrets
-
random_bytes($n)andrandom_int($min, $max)use the operating system’s CSPRNG and are the only correct sources for keys, nonces, tokens and salts (see Math, Numbers and Randomness). -
Mark parameters that receive secrets with
#[\SensitiveParameter], so they never appear in stack traces and logs; PHP’s ownpassword_hash(),password_verify()and the sodium functions already do. -
Keep keys out of the code base and the database: load them from environment variables or a secrets manager, and plan for rotation (keep the previous key to decrypt old data, encrypt new data with the current one — Laravel’s
APP_PREVIOUS_KEYSdoes this). -
Clear key material after use with
sodium_memzero()in long-running processes.
<?php
declare(strict_types=1);
function encryptNote(string $note, #[\SensitiveParameter] string $key): string
{
$nonce = random_bytes(SODIUM_CRYPTO_SECRETBOX_NONCEBYTES);
return base64_encode($nonce . sodium_crypto_secretbox($note, $nonce, $key));
}
$key = getenv('NOTES_KEY') ?: sodium_crypto_secretbox_keygen(); // demo fallback; never hard-code a key
echo strlen(base64_decode(encryptNote('likes Le Guin', $key))), " bytes\n";
Source: SensitiveParameter and random_bytes
Where older books and tutorials are outdated
Several of the books consulted for this section teach practices that are now known to be insecure:
-
mcrypt (Beak ch. 1, 6 and 8) was deprecated in 7.1 and removed in 7.2; use sodium, or OpenSSL with AES-GCM.
-
MD5 or SHA-1 password hashes (Wiley ch. 8 and 15) and reversible password storage such as MySQL’s
AES_ENCRYPT()with the key interpolated into the SQL (Powers ch. 19) must never be used;password_hash()has existed since 5.5. -
Binding
password_hash()output intoWHERE password = ?(Kromann ch. 14) can never match, because every call uses a new salt: fetch the hash by username, then callpassword_verify(). -
Plain-text passwords (Nixon’s capstone, Kromann’s auto-login) and passwords passed through
strip_tags()orhtmlentities()before hashing (Nixon ch. 12 and 27) are bugs. -
md5(uniqid(rand()))tokens (Kromann ch. 14, Wiley ch. 8) are predictable; usebin2hex(random_bytes(32)). -
HMAC compared with
==, computed over the wrong variable (Kromann ch. 18), and one key reused for encryption and HMAC (Kromann ch. 19) are protocol errors: compare withhash_equals()and use separate keys. -
bcrypt cost 10: the default became 12 in PHP 8.4;
password_needs_rehash()upgrades existing hashes.