Encryption with Sodium
Encryption with Sodium What Is Sodium? In simple terms: Sometimes an application must store data that must stay secret even if the database is stolen — bank account numbers, government ID numbers, API
What Is Sodium?
In simple terms: Sometimes an application must store data that must stay secret even if the database is stolen — bank account numbers, government ID numbers, API credentials of customers — or must prove that a message really came from it. Encryption turns readable data into unreadable ciphertext that only the holder of a key can reverse; signatures prove who created a message. Sodium gives PHP developers safe, easy-to-use functions for both.
Technically: The Sodium extension (bundled with PHP since 7.2) exposes libsodium, a modern, audited cryptography library. It provides authenticated encryption (crypto_secretbox, crypto_aead_*), public-key encryption (crypto_box, sealed boxes), digital signatures (crypto_sign), key exchange, hashing, and password hashing — with secure defaults and few ways to misuse them. Writing your own cryptography, or using low-level primitives incorrectly, is a classic source of vulnerabilities; Sodium avoids that.
Hashing vs Encryption vs Signing
| Goal | Tool | Reversible? |
|---|---|---|
| Store passwords | password_hash() (see Password Hashing) | No — verify only |
| Keep data secret, readable later by the application | Sodium encryption (crypto_secretbox, crypto_aead_xchacha20poly1305_ietf) | Yes, with the key |
| Prove a message is authentic and unchanged | HMAC (shared key) or Sodium signatures (public/private keys) | Not applicable |
Symmetric Encryption With crypto_secretbox
One secret key both encrypts and decrypts. Each message also needs a unique nonce ("number used once"), which does not have to be secret.
<?php
$key = sodium_crypto_secretbox_keygen(); // 32 random bytes — keep secret
$message = 'Account: 1234567890';
// Encrypt
$nonce = random_bytes(SODIUM_CRYPTO_SECRETBOX_NONCEBYTES);
$ciphertext = sodium_crypto_secretbox($message, $nonce, $key);
$stored = base64_encode($nonce . $ciphertext); // store nonce + ciphertext together
echo "Key bytes: " . strlen($key) . ", nonce bytes: " . strlen($nonce) . "\n";
echo "Ciphertext bytes: " . strlen($ciphertext) . " (message " . strlen($message) . " + MAC " . SODIUM_CRYPTO_SECRETBOX_MACBYTES . ")\n";
echo "Stored value length: " . strlen($stored) . "\n";
// Decrypt
$decoded = base64_decode($stored, true);
$nonce = substr($decoded, 0, SODIUM_CRYPTO_SECRETBOX_NONCEBYTES);
$ciphertext = substr($decoded, SODIUM_CRYPTO_SECRETBOX_NONCEBYTES);
var_dump(sodium_crypto_secretbox_open($ciphertext, $nonce, $key));
// Tampering is detected
$tampered = $ciphertext;
$tampered[0] = $tampered[0] ^ "\x01";
var_dump(sodium_crypto_secretbox_open($tampered, $nonce, $key));
Output
Key bytes: 32, nonce bytes: 24
Ciphertext bytes: 35 (message 19 + MAC 16)
Stored value length: 80
string(19) "Account: 1234567890"
bool(false)
How It Works
sodium_crypto_secretbox_keygen()creates a random 32-byte key. Generate it once and store it securely (see "Managing Keys" below).- A fresh random 24-byte nonce is generated for every encryption. Reusing a nonce with the same key breaks security; with 24-byte random nonces, accidental reuse is practically impossible.
sodium_crypto_secretbox()encrypts (XSalsa20) and adds a 16-byte authentication tag (Poly1305). The ciphertext is therefore 16 bytes longer than the message.- The nonce is stored in front of the ciphertext; base64 makes the binary data safe for text columns (59 bytes become 80 characters).
sodium_crypto_secretbox_open()returns the original message — orfalseif the ciphertext, nonce, or key is wrong or the data was modified. Authenticated encryption guarantees that manipulated data is never silently accepted.
Authenticated Encryption With Associated Data (AEAD)
Often, encrypted data belongs to a specific record. Associated data — such as the customer ID — is not encrypted but is bound to the ciphertext, so an encrypted value cannot be copied to another customer's record:
<?php
$key = sodium_crypto_aead_xchacha20poly1305_ietf_keygen();
function encryptField(string $plaintext, string $context, string $key): string
{
$nonce = random_bytes(SODIUM_CRYPTO_AEAD_XCHACHA20POLY1305_IETF_NPUBBYTES);
$ciphertext = sodium_crypto_aead_xchacha20poly1305_ietf_encrypt($plaintext, $context, $nonce, $key);
return base64_encode($nonce . $ciphertext);
}
function decryptField(string $stored, string $context, string $key): ?string
{
$decoded = base64_decode($stored, true);
if ($decoded === false || strlen($decoded) < SODIUM_CRYPTO_AEAD_XCHACHA20POLY1305_IETF_NPUBBYTES) {
return null;
}
$nonce = substr($decoded, 0, SODIUM_CRYPTO_AEAD_XCHACHA20POLY1305_IETF_NPUBBYTES);
$ciphertext = substr($decoded, SODIUM_CRYPTO_AEAD_XCHACHA20POLY1305_IETF_NPUBBYTES);
$plaintext = sodium_crypto_aead_xchacha20poly1305_ietf_decrypt($ciphertext, $context, $nonce, $key);
return $plaintext === false ? null : $plaintext;
}
$stored = encryptField('PAN: ABCDE1234F', 'customer:7', $key);
var_dump(decryptField($stored, 'customer:7', $key));
var_dump(decryptField($stored, 'customer:8', $key));
Output
string(15) "PAN: ABCDE1234F"
NULL
The value decrypts only with the same context (customer:7). If an attacker with database access moves the encrypted value to customer 8's row, decryption fails. XChaCha20-Poly1305 is a modern, fast choice for encrypting fields.
Public-Key Encryption: Sealed Boxes
With public-key encryption, anyone with the public key can encrypt, but only the holder of the secret key can decrypt. A sealed box is ideal when a web server collects sensitive data that only a separate, more protected system should read:
<?php
$recipientKeypair = sodium_crypto_box_keypair(); // kept on the secure back-office system
$recipientPublicKey = sodium_crypto_box_publickey($recipientKeypair); // deployed to the web server
// Web server: encrypt (cannot decrypt afterwards)
$sealed = sodium_crypto_box_seal('Passport no. K1234567', $recipientPublicKey);
echo strlen($sealed) . " bytes\n";
// Back office: decrypt
var_dump(sodium_crypto_box_seal_open($sealed, $recipientKeypair));
Output
69 bytes
string(21) "Passport no. K1234567"
A compromised web server cannot decrypt previously collected data, because it never holds the secret key. Sealed boxes add 48 bytes (an ephemeral public key and an authentication tag).
Digital Signatures
Signatures prove that data was created by the holder of a secret key and has not been changed — useful for license files, signed downloads, or messages between services:
<?php
$signingKeypair = sodium_crypto_sign_keypair();
$secretKey = sodium_crypto_sign_secretkey($signingKeypair);
$publicKey = sodium_crypto_sign_publickey($signingKeypair);
$license = '{"customer":"GoCourse Academy","seats":25,"expires":"2027-10-15"}';
$signature = sodium_crypto_sign_detached($license, $secretKey);
echo "Signature bytes: " . strlen($signature) . "\n";
var_dump(sodium_crypto_sign_verify_detached($signature, $license, $publicKey));
var_dump(sodium_crypto_sign_verify_detached($signature, str_replace('25', '250', $license), $publicKey));
Output
Signature bytes: 64
bool(true)
bool(false)
Only the vendor (with the secret key) can create valid signatures; every installation can verify them with the public key. Changing the seat count from 25 to 250 invalidates the signature.
Managing Keys
Encryption is only as strong as the protection of its keys:
| Practice | Details |
|---|---|
Generate keys with Sodium's *_keygen() functions or random_bytes() | Never derive keys from simple passwords or constants |
| Store keys outside the database and code | Environment variables, a secrets manager (Vault, cloud KMS), or a protected file outside the web root |
| Encode for storage | base64_encode() the binary key; decode with base64_decode($value, true) |
| Plan for rotation | Prefix stored values with a key ID (v2:...) so old data can be decrypted while new data uses the new key |
| Limit access | Only the systems that need to decrypt should hold the key |
| Wipe from memory when done | sodium_memzero($key) overwrites a key variable after use in long-running processes |
Searching Encrypted Data
Encrypted values cannot be searched with WHERE. To find a customer by an encrypted field (such as a phone number), store an additional blind index — a keyed hash of the normalized value:
$phoneIndex = hash_hmac('sha256', '9876543210', $indexKey); // separate key from the encryption key
// SELECT ... WHERE phone_index = ?
Common Mistakes
Inventing Your Own Scheme
Effect: Using openssl_encrypt() without authentication, ECB mode, or fixed IVs leaves data malleable or exposed.
Fix: Use Sodium's high-level authenticated functions.
Reusing Nonces
Effect: Two messages encrypted with the same key and nonce can leak information.
Fix: Generate a random nonce for every encryption, as shown.
Storing the Key Next to the Data
Effect: A database leak also leaks the key.
Fix: Keep keys in separate, protected storage.
Ignoring Decryption Failures
Effect: false is treated as data.
Fix: Check the result strictly and handle tampering as an error.
Encrypting Passwords
Effect: Whoever has the key can recover every password.
Fix: Hash passwords with password_hash().
Best Practices
- Use Sodium's authenticated encryption (
crypto_secretboxor XChaCha20-Poly1305 AEAD) for sensitive data. - Generate a fresh random nonce per message and store it with the ciphertext.
- Bind ciphertext to its record with associated data where appropriate.
- Use sealed boxes for one-way collection and signatures for authenticity.
- Protect, rotate, and separate keys; never encrypt passwords — hash them.
Official reference: https://www.php.net/manual/en/book.sodium.php