Cryptography Fundamentals — Hashing, HMAC, and Encryption in PHP
Understand the three cryptographic primitives every PHP developer needs to tell apart — one-way hashing, keyed HMAC authentication, and two-way encryption — know why authenticated encryption is required rather than optional, use PHP's CSPRNG functions correctly, and understand PHP's two crypto toolkits (libsodium and OpenSSL) plus the key-management discipline that makes any of it actually safe.
Cryptography shows up all over a PHP application: password storage, API request signing, encrypted database columns, signed cookies, webhook verification. The recurring mistake isn't using the wrong algorithm — it's reaching for the wrong category of primitive entirely: hashing a value that needs to be recovered later, encrypting a value without also protecting its integrity, or generating a 'random' token with a function that was never meant to be unpredictable. This lesson is a map of the territory — it names each primitive, says exactly what problem it solves and does not solve, and shows how they compose into the patterns you'll actually use. It deliberately stays at overview depth on two topics the course covers in full elsewhere: PHP's random_bytes()/random_int() CSPRNGs are covered in depth in the 'Randomness and Cryptography' lesson, and libsodium's complete API — including secretbox, box, and sign — is covered in depth in the 'Sodium' lesson. Read this lesson first if you haven't seen those, or as a refresher that ties them together before you touch key management and OpenSSL.
Three primitives, three different jobs — do not mix them up
The single most important mental model in applied cryptography is that hashing, HMAC, and encryption solve three different problems, and picking the wrong one for a given job is the source of most real-world crypto bugs. Hashing is one-way: it takes an input of any size and deterministically produces a fixed-size digest, and there is no operation that recovers the input from the digest. Hashing is for integrity checking (does this file match what I expect?) and for password storage (never store the password itself, only proof you can verify it later). Encryption is two-way: it takes plaintext and a key and produces ciphertext, and the matching decrypt operation with the same (or a related) key recovers the original plaintext exactly. Encryption is for confidentiality — hiding data from anyone who doesn't hold the key, while still being able to get the original data back.
HMAC (Hash-based Message Authentication Code) sits between the two conceptually but is really its own third category: it's a keyed hash. Like a plain hash it's one-way and produces a fixed-size output, but unlike a plain hash it requires a secret key to compute or verify, which means it proves both that the message is unmodified (integrity) and that whoever produced it knew the key (authenticity) — a plain hash proves neither of those against a motivated attacker, because anyone can compute a plain hash of anything. HMAC is what you reach for to sign a value — a cookie, a webhook payload, a JWT — so the receiver can trust it hasn't been tampered with and did in fact come from someone holding the shared secret.
• Hashing (hash(), password_hash()): one-way, no key, for integrity checks and password storage. Cannot be 'decrypted' — ever. • HMAC (hash_hmac()): one-way, keyed, for authenticating that a message is both unmodified and genuinely from the key holder. • Encryption (sodium_crypto_secretbox(), openssl_encrypt()): two-way, keyed, for confidentiality — recoverable by design, by anyone with the key. • A plain hash of secret data proves nothing to an attacker who can also compute hashes; only a keyed construction (HMAC) or a secret (encryption key) resists forgery.
Why encryption alone is not enough: authenticated encryption
It's tempting to think 'I encrypted it, so it's safe,' but plain encryption (confidentiality only) leaves a gap: an attacker who cannot read the ciphertext can often still flip bits in it, and depending on the cipher mode, those flipped bits produce predictable, controllable changes in the decrypted plaintext — without the attacker ever needing to know the key. This is called a malleability or bit-flipping attack, and it has broken real systems: an attacker who can't decrypt a ciphertext still tampers with it so that, after decryption, an amount field changes or an authorization flag flips. Encryption without integrity protection answers 'can anyone read this?' but not 'has anyone tampered with this?' — and for almost every real application, you need both answered together.
The fix is authenticated encryption: encryption combined with a MAC (conceptually an HMAC-like tag) computed over the ciphertext, so decryption fails loudly if even a single bit was altered, rather than silently producing corrupted-but-plausible plaintext. Modern APIs bundle this for you so you don't have to compose primitives yourself — sodium_crypto_secretbox() in libsodium is authenticated encryption by default (there is no non-authenticated option in that API), and openssl_encrypt() with an AEAD cipher mode like 'aes-256-gcm' produces both ciphertext and an authentication tag you must store and check. The rule going forward: never use a plain, non-authenticated cipher mode (raw CBC or ECB without a separate MAC) for anything you build today — always reach for an authenticated construction.
• Authenticated encryption = confidentiality (can't be read without the key) + integrity/authenticity (can't be tampered with undetected). • sodium_crypto_secretbox() — authenticated by design; there's no way to accidentally use it unauthenticated. • openssl_encrypt() with 'aes-256-gcm' — an AEAD mode; you must capture and verify the returned authentication tag. • Never hand-roll encrypt-then-hope: raw CBC/ECB mode with no separate integrity check is a known anti-pattern.
Cryptographically secure randomness
Every primitive above depends on unpredictable inputs somewhere — a fresh key, a nonce/IV, a password reset token — and PHP's ordinary rand() or mt_rand() are not safe for any of these: they're fast, deterministic pseudo-random generators meant for simulations and games, not security, and their output can be predicted by an attacker who observes enough of it. PHP provides two cryptographically secure pseudo-random number generator (CSPRNG) functions specifically for security contexts: random_bytes($length) for raw random binary data (keys, salts, nonces, tokens you'll encode yourself) and random_int($min, $max) for a random integer in a range with no modulo bias, unlike naive mt_rand() % $n approaches. These are the only functions in PHP's standard library appropriate for generating anything security-sensitive.
This lesson only names them here as part of the bigger picture — the 'Randomness and Cryptography' lesson covers random_bytes()/random_int() in full depth, including how to turn raw bytes into URL-safe tokens, why modulo bias matters, and how these functions relate to the operating system's own entropy source. If you haven't taken that lesson yet, this is the moment to go do so before writing any token-generation code.
PHP's two crypto toolkits: libsodium and OpenSSL
PHP ships two capable, independent cryptography extensions, and knowing which one to reach for (and why) matters. The sodium extension (bundled with PHP since 7.2, no separate install needed) wraps libsodium, a library explicitly designed to be hard to misuse: it has a small number of high-level functions (sodium_crypto_secretbox, sodium_crypto_box, sodium_crypto_sign, sodium_crypto_generichash), sane defaults baked in, and few footguns — there's no mode selection, no IV-reuse trap, because the API doesn't expose those knobs. For new code, sodium is the recommended default specifically because it minimizes the number of decisions you have to get right.
The openssl extension wraps OpenSSL, a much older and vastly more flexible toolkit that supports many algorithms, modes, and use cases (TLS certificate handling, arbitrary cipher/mode combinations, hashing algorithm variety) — but that flexibility means it's also easier to misconfigure: you must actively choose an authenticated mode (aes-256-gcm) instead of an unauthenticated one, manage IV generation and uniqueness yourself, and get the tag-handling right. You'll typically meet OpenSSL functions when integrating with an existing system that already dictates a specific algorithm and mode, or when you need functionality sodium doesn't expose (e.g. some certificate/TLS-adjacent operations). The full sodium API — secretbox, box, sign, key generation and key exchange — is covered end to end in the dedicated 'Sodium' lesson; this lesson's job is just to place it correctly on the map next to OpenSSL and to hashing/HMAC.
• libsodium (sodium_*): modern, opinionated, hard to misuse, no mode-selection footguns — the default choice for new code. • OpenSSL (openssl_*): older, more flexible, more powerful, but requires you to choose the right mode (AEAD) and handle IVs/tags yourself. • Both are compiled into PHP by default on most distributions — no Composer package required for either. • See the dedicated 'Sodium' lesson for the complete secretbox/box/sign API and key-exchange patterns.
Key management: the part that actually determines your security
Every primitive above is only as safe as the key behind it — a perfect algorithm with a leaked or guessable key provides zero protection. The cardinal rule is that encryption and HMAC keys must never appear as literal strings in your source code, because source code ends up in git history, in code review tools, in backups, and on every developer's laptop, all of which are far more exposed than a single, tightly controlled secret store needs to be. Keys belong in environment variables or a dedicated secret manager (the full discussion of both is the next lesson, 'Secrets Management'), loaded at runtime and never written to disk in the codebase.
Beyond just 'don't hardcode it,' mature key management means: generating keys with a CSPRNG at the correct length for the algorithm (sodium's sodium_crypto_secretbox_keygen() and equivalents do this correctly for you), rotating keys periodically and after any suspected exposure, and — for encrypted data that must survive a rotation — supporting key versioning, where old ciphertext records which key version encrypted them so old data can still be decrypted with a retired key while new data uses the current one. Losing an encryption key permanently loses the data it protects (there is no backdoor by design), so key backup and access control deserve the same operational seriousness as the encrypted data itself.
• Never hardcode a key or secret literal in source — treat any committed key as compromised and rotate it immediately. • Generate keys with the algorithm's own keygen helper or random_bytes() at the correct length, never by 'making up' a string. • Rotate keys on a schedule and immediately after any suspected leak; support key versioning so old ciphertext stays decryptable. • Losing a key loses the data behind it — there's no recovery path by design, so back up keys with the same care as the data.
The three primitives, side by side
hash() is for integrity/fingerprints on non-secret data; never use plain hash() to 'protect' a password. Always verify HMACs and tokens with hash_equals(), which runs in constant time — a plain === comparison leaks timing information an attacker can exploit. sodium_crypto_secretbox_open() returns false (not an exception) on a failed/tampered decryption — always check the return value explicitly with ===.
// 1. Hashing — one-way, for integrity or password storage. No key, never reversible.
$digest = hash('sha256', $fileContents); // integrity check
$passwordHash = password_hash($plain, PASSWORD_DEFAULT); // password storage (see the Password Hashing lesson)
// 2. HMAC — one-way, keyed, for authenticating a message came from the key holder and is unmodified.
$signature = hash_hmac('sha256', $payload, $sharedSecretKey);
// Verify with hash_equals() — never with == or ===, to avoid timing attacks.
$isValid = hash_equals($signature, $receivedSignature);
// 3. Authenticated encryption — two-way, keyed, for confidentiality + integrity together.
$nonce = random_bytes(SODIUM_CRYPTO_SECRETBOX_NONCEBYTES);
$ciphertext = sodium_crypto_secretbox($plaintext, $nonce, $key);
$recovered = sodium_crypto_secretbox_open($ciphertext, $nonce, $key); // false if tamperedCode Examples
This is the core pattern behind signed cookies and webhook payload verification: HMAC proves the value wasn't altered and was produced by someone holding the same key, without needing to encrypt the value at all — HMAC gives authenticity and integrity, not confidentiality.
if (!hash_equals($expected, $signature)) { return null; } Common mistake: Comparing signatures with == or === instead of hash_equals() leaks timing information that can let an attacker forge a valid signature byte by byte.
sodium_crypto_secretbox() bundles authenticated encryption in one call — there's no separate 'authenticate' step to forget. decryptField() returns null on any tampering rather than corrupted plaintext, because the explicit === false check catches the authentication failure that sodium_crypto_secretbox_open() signals by returning false. A fresh, unique nonce per encryption call is required — reusing a nonce with the same key breaks the security guarantees of the algorithm, which is why one is generated with random_bytes() on every call rather than reused or hardcoded.
return $plaintext === false ? null : $plaintext;
declare(strict_types=1);
function signValue(string $value, string $key): string
{
$signature = hash_hmac('sha256', $value, $key);
return $value . '.' . $signature;
}
function verifySignedValue(string $signed, string $key): ?string
{
$parts = explode('.', $signed, 2);
if (count($parts) !== 2) {
return null;
}
[$value, $signature] = $parts;
$expected = hash_hmac('sha256', $value, $key);
if (!hash_equals($expected, $signature)) {
return null; // tampered or wrong key
}
return $value;
}
$key = random_bytes(32); // in real code: loaded from env/secret manager, not generated per-request
$cookie = signValue('user_id=482', $key);
echo $cookie . PHP_EOL;
var_dump(verifySignedValue($cookie, $key)); // 'user_id=482'
var_dump(verifySignedValue($cookie . 'x', $key)); // null — tampereddeclare(strict_types=1);
function encryptField(string $plaintext, string $key): string
{
$nonce = random_bytes(SODIUM_CRYPTO_SECRETBOX_NONCEBYTES);
$ciphertext = sodium_crypto_secretbox($plaintext, $nonce, $key);
// Store nonce alongside ciphertext — it isn't secret, it just must be unique per message.
return base64_encode($nonce . $ciphertext);
}
function decryptField(string $stored, string $key): ?string
{
$raw = base64_decode($stored, true);
if ($raw === false || strlen($raw) < SODIUM_CRYPTO_SECRETBOX_NONCEBYTES) {
return null;
}
$nonce = substr($raw, 0, SODIUM_CRYPTO_SECRETBOX_NONCEBYTES);
$ciphertext = substr($raw, SODIUM_CRYPTO_SECRETBOX_NONCEBYTES);
$plaintext = sodium_crypto_secretbox_open($ciphertext, $nonce, $key);
return $plaintext === false ? null : $plaintext; // false means tampered/wrong key
}
$key = sodium_crypto_secretbox_keygen(); // correct length for this algorithm, generated safely
$stored = encryptField('4111-XXXX-XXXX-1234 (tokenized reference)', $key);
echo $stored . PHP_EOL;
var_dump(decryptField($stored, $key));Which primitive solves which problem
Pick the row that matches the problem — not the algorithm that sounds strongest.
Need... Use... Reversible? Needs a key?
---------------------------------------------------------------------------------
Verify a file/value is unmodified hash() No No
Store a password safely password_hash() No No (uses a salt, not a key)
Prove authenticity + integrity hash_hmac() No Yes (shared secret)
Hide data, get it back later sodium_crypto_secretbox() Yes Yes (encryption key)
Hide data + guarantee untampered authenticated encryption Yes Yes <- always prefer thisImportant Rules
Never use a plain hash to 'protect' secret or sensitive data — a plain hash has no key and proves nothing against a capable attacker.
Always verify HMACs, tokens, and signatures with hash_equals(), never with == or ===.
Always use authenticated encryption (sodium_crypto_secretbox, or OpenSSL with an AEAD mode like aes-256-gcm) — never a bare, unauthenticated cipher mode.
Generate every key, nonce, and token with random_bytes()/random_int() or an algorithm's own keygen helper — never with rand()/mt_rand() or a hand-typed string.
Never hardcode a key in source code; load it from environment variables or a secret manager, and rotate it on a schedule and after any suspected leak.
Where this shows up in a typical PHP application
A Laravel or Symfony app encrypts session cookie values and any 'remember me' tokens with authenticated encryption under the hood, signs CSRF tokens with an HMAC-style construction, and hashes passwords with password_hash() — three different primitives doing three different jobs in the same request lifecycle. Payment webhooks from Stripe or similar providers arrive with an HMAC signature header you must verify with hash_equals() before trusting the payload at all. A field-level encryption library for 'encrypt this column in the database' (an SSN, a bank account number) is doing exactly the authenticated-encryption pattern shown above, with the key pulled from an environment variable or secret manager rather than the codebase.
$ok = ($storedSignature === hash_hmac('sha256', $payload, $key));
$ok = hash_equals($storedSignature, hash_hmac('sha256', $payload, $key));
String comparison with === short-circuits at the first mismatched byte, so the time it takes to fail leaks how many leading bytes were correct — an attacker can exploit that timing difference to forge a valid signature one byte at a time. hash_equals() always takes the same time regardless of where the mismatch is.
$ciphertext = openssl_encrypt($data, 'aes-256-cbc', $key, 0, $iv); // no integrity check at all
$ciphertext = sodium_crypto_secretbox($data, $nonce, $key); // authenticated by default
Plain AES-CBC provides confidentiality only — an attacker who can't read the ciphertext can still flip bits in it, producing predictable changes after decryption, and your code has no way to detect that tampering happened. An authenticated construction rejects tampered ciphertext outright instead of silently decrypting it into corrupted data.
Which statement correctly distinguishes hashing, HMAC, and encryption? A. They're interchangeable ways to protect data; pick whichever is fastest. B. Hashing is one-way and unkeyed; HMAC is one-way and keyed (authenticity); encryption is two-way and keyed (confidentiality, reversible). C. Encryption is one-way; hashing and HMAC are both two-way and reversible with the right key. D. HMAC is a form of encryption that happens to also produce a fixed-size output.
B. Hashing is one-way and unkeyed; HMAC is one-way and keyed (authenticity); encryption is two-way and keyed (confidentiality, reversible). — Hashing has no key and can't be reversed; HMAC adds a key to prove authenticity and integrity but is still one-way; encryption is two-way by design, recoverable by anyone holding the correct key.
Why is plain (non-authenticated) encryption considered insufficient on its own for most applications? A. It's too slow for production use compared to authenticated modes. B. It doesn't support keys longer than 128 bits. C. An attacker who can't read the ciphertext may still be able to tamper with it (bit-flipping), and plain encryption has no way to detect that. D. It cannot be implemented using libsodium.
C. An attacker who can't read the ciphertext may still be able to tamper with it (bit-flipping), and plain encryption has no way to detect that. — Confidentiality alone doesn't guarantee integrity — certain cipher modes let an attacker flip ciphertext bits to produce predictable, undetected changes in the decrypted plaintext, which is exactly what authenticated encryption is designed to prevent by rejecting tampered ciphertext.
Which PHP functions should be used to generate a cryptographic key or security token, and why? A. rand() or mt_rand(), because they're built in and require no extension. B. uniqid(), because it produces a string that looks random. C. random_bytes() / random_int(), because they draw from a cryptographically secure random source, unlike rand()/mt_rand(). D. array_rand() on a large predefined array of characters.
C. random_bytes() / random_int(), because they draw from a cryptographically secure random source, unlike rand()/mt_rand(). — random_bytes() and random_int() are PHP's CSPRNG functions, specifically designed to be unpredictable even to an attacker who has observed prior output — rand(), mt_rand(), and uniqid() are not safe for anything security-sensitive.
What is the main practical difference between libsodium and OpenSSL as used from PHP? A. OpenSSL is newer and only available as a separate Composer package; libsodium ships with PHP core. B. libsodium offers a small, opinionated API that's hard to misuse; OpenSSL is more flexible but requires you to correctly choose modes and manage IVs/tags yourself. C. libsodium can only hash; OpenSSL can only encrypt. D. There is no meaningful difference — they implement identical APIs.
B. libsodium offers a small, opinionated API that's hard to misuse; OpenSSL is more flexible but requires you to correctly choose modes and manage IVs/tags yourself. — libsodium's API deliberately limits the number of decisions a developer has to make correctly (no mode selection, authenticated by default), while OpenSSL's flexibility means you must actively pick an authenticated mode and handle nonces/IVs and tags yourself to get an equally safe result.
Write two functions: signPayload(string $json, string $key): string that returns the JSON payload with an appended HMAC-SHA256 signature (in the form 'json.signature', hex-encoded), and verifyPayload(string $signed, string $key): ?array that returns the decoded payload as an array only if the signature is valid (using hash_equals()), or null otherwise. Test it by tampering with one character of a valid signed payload and confirming verification fails.
function signPayload(string $json, string $key): string { return $json . '.' . hash_hmac('sha256', $json, $key); } function verifyPayload(string $signed, string $key): ?array { $parts = explode('.', $signed, 2); if (count($parts) !== 2) return null; [$json, $sig] = $parts; if (!hash_equals(hash_hmac('sha256', $json, $key), $sig)) return null; $decoded = json_decode($json, true); return is_array($decoded) ? $decoded : null; }
Reuse the signValue()/verifySignedValue() pattern from the examples, but json_decode() the value portion (with associative: true) before returning it from verifyPayload().