Sodium: Modern, Misuse-Resistant Cryptography in PHP

Use PHP's built-in Sodium extension (libsodium) to perform secret-key encryption, public-key encryption, digital signatures, password hashing, and secure random generation, understanding why its APIs are designed to be hard to misuse and when to reach for it instead of writing cryptographic code from scratch.

The previous lesson introduced the vocabulary of encryption -- keys, nonces, authenticated encryption -- and one hard rule: never design your own cryptography. Sodium is PHP's built-in answer to 'so what do I actually use instead?' It wraps libsodium, a cryptography library specifically engineered around misuse resistance: it deliberately offers a small number of high-level functions (secretbox, box, sign, pwhash) rather than a large toolbox of interchangeable low-level primitives, because the low-level toolbox approach is exactly what lets developers combine correct pieces into an insecure whole. Sodium has shipped as a PHP core extension since PHP 7.2, so it's available almost everywhere without extra installation. This lesson covers secret-key encryption, public-key encryption, signatures, password hashing, and the operational discipline (nonce handling, key storage) that makes Sodium safe to use.

Secret-Key Encryption: sodium_crypto_secretbox

sodium_crypto_secretbox() performs authenticated symmetric encryption: one shared secret key both encrypts and decrypts, and the output includes a built-in authentication tag, so tampering with the ciphertext causes decryption to fail loudly (sodium_crypto_secretbox_open() returns false) instead of silently producing corrupted plaintext. This is the right tool whenever both the encrypting and decrypting parties already share a secret key -- encrypting data at rest in your own database, or encrypting a value your own server will later decrypt.

Every call requires a nonce -- a value that must never be reused with the same key. Sodium's nonces are large enough (24 bytes for secretbox) that generating one fresh with random_bytes(SODIUM_CRYPTO_SECRETBOX_NONCEBYTES) for every single encryption is safe in practice; the chance of an accidental collision is astronomically small. The nonce is not secret -- it's conventionally stored or transmitted alongside the ciphertext (commonly prepended to it), because the recipient needs it to decrypt, but it must still never be reused for a second encryption under the same key.

The secret key itself (SODIUM_CRYPTO_SECRETBOX_KEYBYTES, 32 bytes) should be generated with sodium_crypto_secretbox_keygen() -- a dedicated helper rather than raw random_bytes() -- and stored outside application code and version control, typically in an environment variable or secrets manager, injected at runtime.

• secretbox = authenticated symmetric encryption: one key encrypts and decrypts. • Every encryption needs a fresh, unique nonce; reuse under the same key is catastrophic. • The nonce isn't secret -- store/send it alongside the ciphertext, typically prepended. • sodium_crypto_secretbox_keygen() generates a correctly-sized key -- prefer it over raw random_bytes(). • Tampered ciphertext fails to decrypt (returns false) instead of silently corrupting -- built-in authentication.

Public-Key Encryption and Digital Signatures

sodium_crypto_box() is public-key (asymmetric) encryption: the sender encrypts using their own private key plus the recipient's public key, and the recipient decrypts using their own private key plus the sender's public key. This solves the key-distribution problem secretbox doesn't -- two parties who have never shared a secret can still exchange encrypted messages, as long as they've exchanged public keys (which are safe to publish openly). Like secretbox, box output is authenticated: decryption also proves the message really came from the claimed sender's private key.

sodium_crypto_sign() provides digital signatures: a signer with a private signing key produces a signature over a message that anyone holding the corresponding public key can verify with sodium_crypto_sign_verify_detached(), proving both that the message came from that key holder and that it wasn't altered afterward. Signing is about authenticity and integrity, not confidentiality -- a signed message is not encrypted, and its content is fully readable by anyone; sign and encrypt are complementary tools that solve different problems and are often combined (sign-then-encrypt) when both properties are needed.

Sodium also exposes secret streams (sodium_crypto_secretstream_*) for encrypting a long stream of data (a large file, a network connection) as a sequence of authenticated chunks sharing one key and a per-stream initial state, which avoids the awkwardness of picking one giant nonce for an entire multi-gigabyte file and correctly handles chunk ordering and truncation detection -- an attacker can't silently drop a chunk from the end of the stream without detection.

• crypto_box: asymmetric authenticated encryption using sender-private + recipient-public key pairs. • Solves key distribution: two parties can encrypt to each other after exchanging only public keys. • crypto_sign: digital signatures -- proves authorship and integrity, provides no confidentiality. • Sign and encrypt solve different problems; combine them (sign-then-encrypt) when both are needed. • crypto_secretstream: chunked authenticated encryption for large data, with truncation detection.

Password Hashing and Key Derivation: sodium_crypto_pwhash

sodium_crypto_pwhash() implements Argon2id and is used two ways: sodium_crypto_pwhash_str() produces a self-contained, salted password hash string suitable for storing and later verifying with sodium_crypto_pwhash_str_verify() -- functionally parallel to PHP's own password_hash()/password_verify(), and either is a reasonable choice for password storage. The other use of crypto_pwhash is key derivation: deriving a fixed-length symmetric encryption key from a low-entropy human password plus a stored salt, which is a different job from password verification -- you get back raw key bytes suitable for secretbox, not a stored hash string.

Both uses take OPSLIMIT and MEMLIMIT parameters that control how much CPU time and memory the hashing consumes -- deliberately making it slow and memory-hungry is the point, since it makes brute-force guessing against stolen hashes or derived keys expensive. Sodium ships named presets (SODIUM_CRYPTO_PWHASH_OPSLIMIT_INTERACTIVE for a login-speed operation, ...MODERATE, ...SENSITIVE for higher-value data that can tolerate a slower derivation) instead of requiring you to pick raw numbers.

A password-derived key must always be combined with a stored, per-use random salt -- without a salt, identical passwords across different users (or different keys for the same user) derive identical key material, defeating the purpose of a per-user or per-purpose secret. The salt itself isn't secret and can be stored alongside the resulting ciphertext or hash.

• sodium_crypto_pwhash_str()/...verify(): Argon2id password hashing, parallel to password_hash()/password_verify(). • sodium_crypto_pwhash(): raw key derivation from a password + salt, for deriving encryption keys, not just verification. • OPSLIMIT/MEMLIMIT presets (INTERACTIVE/MODERATE/SENSITIVE) tune the CPU/memory cost deliberately upward. • Always pair password-derived key material with a stored, unique salt.

Key Storage, Misuse Resistance, and When Not to Write Your Own Crypto

Sodium's API surface is deliberately narrow and high-level compared to something like OpenSSL's raw primitive functions: there's no 'pick your own cipher mode' or 'choose your own padding scheme' decision to get wrong, because secretbox and box already bundle a specific, vetted combination of primitives (XSalsa20-Poly1305 for secretbox, X25519 plus XSalsa20-Poly1305 for box) behind one function call. This 'misuse resistance by API design' is Sodium's central design philosophy: correct usage should be the easy, obvious path, and dangerous flexibility (like manually choosing ECB mode, or an unauthenticated cipher mode) simply isn't exposed.

Key storage discipline matters as much as the algorithm: private/secret keys must never be committed to version control, logged, embedded in client-side JavaScript, or passed as URL query parameters; the standard pattern is loading them from environment variables or a dedicated secrets manager at process startup, with tightly restricted file permissions if they're ever written to disk (sodium_crypto_secretbox_keygen() output, for instance, is fine to store in an env var but should never appear in an error message or stack trace).

The overriding rule from the previous lesson applies with extra force here: never invent your own encryption scheme, never implement a well-known algorithm 'from the paper' by hand, and never combine low-level primitives yourself when a vetted library already solves your exact problem. Real-world cryptographic failures are overwhelmingly implementation mistakes -- reused nonces, missing authentication, home-grown key derivation, mishandled padding -- not flaws in the underlying math; Sodium exists specifically to make those implementation mistakes structurally hard to make.

• Sodium bundles vetted primitive combinations behind a small set of high-level functions -- less room to combine correct pieces incorrectly. • Never commit, log, or expose secret keys client-side; load them from env vars or a secrets manager. • Misuse resistance is a deliberate API design goal of libsodium, not an accident. • Never hand-roll a cryptographic scheme -- when a vetted library solves your exact problem, use it.

Core Sodium Function Shapes

Always pair a nonce 1:1 with an encryption; never reuse a nonce with the same key. sodium_crypto_secretbox_open() returning false means either tampering or a wrong key/nonce -- treat it as decryption failure, not an exception to catch and ignore. SODIUM_CRYPTO_*_NONCEBYTES/KEYBYTES constants give the exact required lengths for each function family.

$key = sodium_crypto_secretbox_keygen();
$nonce = random_bytes(SODIUM_CRYPTO_SECRETBOX_NONCEBYTES);
$ciphertext = sodium_crypto_secretbox($message, $nonce, $key);
$plaintext = sodium_crypto_secretbox_open($ciphertext, $nonce, $key); // false on tamper/failure

$hash = sodium_crypto_pwhash_str(
    $password,
    SODIUM_CRYPTO_PWHASH_OPSLIMIT_INTERACTIVE,
    SODIUM_CRYPTO_PWHASH_MEMLIMIT_INTERACTIVE
);
$ok = sodium_crypto_pwhash_str_verify($hash, $password);

Code Examples

A fresh nonce is generated for every encrypt() call and prepended to the ciphertext, which is the standard, safe pattern for storing/transmitting a secretbox message as a single blob. Flipping a single bit of the tampered ciphertext causes decryption to fail outright, demonstrating the built-in authentication tag doing its job. Expected result: 4111-1111-1111-1111 (illustrative only) bool(false)

$nonce = random_bytes(SODIUM_CRYPTO_SECRETBOX_NONCEBYTES); // fresh every call

Alice and Bob never share a secret key directly -- each only ever needed the other's public key, which is safe to publish. Because decryption uses Bob's private key combined with Alice's claimed public key, a successful decrypt also proves the message really was encrypted by someone holding Alice's private key. This is the same key-agreement idea (X25519 Diffie-Hellman under the hood) used by modern secure messaging protocols -- crypto_box just gives you a ready-made, authenticated version of it in one function call.

sodium_crypto_box_open($ciphertext, $nonce, $bobToAliceKeyPair);

The release manifest itself is sent in plaintext -- signing provides no confidentiality -- but the detached signature lets any verifier holding the public key confirm both that this exact byte sequence was signed by the secret key holder, and that changing even one character (bumping the version string) invalidates the signature.

sodium_crypto_sign_verify_detached($signature, $release, $publicKey);

Common mistake: Assuming a signed message is also confidential -- crypto_sign proves who sent it and that it's unmodified, but anyone can read the signed content itself unless it's separately encrypted.

The XOR function 'looks like' encryption (the output is unreadable bytes) but provides essentially no real security: a repeating-key XOR cipher is broken by classical cryptanalysis techniques that predate computers, and it offers zero tamper detection. sodium_crypto_secretbox() uses vetted, authenticated primitives and takes about the same number of lines to call correctly.

Common mistake: Writing any custom byte-scrambling scheme (XOR, custom substitution, home-grown 'obfuscation') and calling it encryption -- if it isn't a well-known, peer-reviewed algorithm used through a vetted library, it should not be relied on to protect real data.

<?php
declare(strict_types=1);

final class SecretBoxCipher
{
    public function __construct(private readonly string $key) {} // 32-byte key

    /** Returns nonce+ciphertext concatenated, safe to store/transmit as one blob. */
    public function encrypt(string $plaintext): string
    {
        $nonce = random_bytes(SODIUM_CRYPTO_SECRETBOX_NONCEBYTES); // fresh every call
        $ciphertext = sodium_crypto_secretbox($plaintext, $nonce, $this->key);
        return $nonce . $ciphertext; // nonce isn't secret -- prepend it for the recipient
    }

    public function decrypt(string $blob): string
    {
        $nonce = substr($blob, 0, SODIUM_CRYPTO_SECRETBOX_NONCEBYTES);
        $ciphertext = substr($blob, SODIUM_CRYPTO_SECRETBOX_NONCEBYTES);

        $plaintext = sodium_crypto_secretbox_open($ciphertext, $nonce, $this->key);
        if ($plaintext === false) {
            throw new RuntimeException('Decryption failed: ciphertext tampered or wrong key');
        }
        return $plaintext;
    }
}

$key = sodium_crypto_secretbox_keygen(); // load from a secrets manager in real use
$cipher = new SecretBoxCipher($key);

$blob = $cipher->encrypt('4111-1111-1111-1111 (illustrative on
…
<?php
declare(strict_types=1);

// Each party generates their own keypair independently.
$aliceKeypair = sodium_crypto_box_keypair();
$bobKeypair = sodium_crypto_box_keypair();

$alicePrivate = sodium_crypto_box_secretkey($aliceKeypair);
$alicePublic = sodium_crypto_box_publickey($aliceKeypair);
$bobPrivate = sodium_crypto_box_secretkey($bobKeypair);
$bobPublic = sodium_crypto_box_publickey($bobKeypair);

// Alice encrypts to Bob using her private key + Bob's PUBLIC key.
$nonce = random_bytes(SODIUM_CRYPTO_BOX_NONCEBYTES);
$aliceToBobKeyPair = sodium_crypto_box_keypair_from_secretkey_and_publickey($alicePrivate, $bobPublic);
$ciphertext = sodium_crypto_box('Meet at the usual place, 9pm.', $nonce, $aliceToBobKeyPair);

// Bob decrypts using his private key + Alice's PUBLIC key -- and this also authenticates the sender.
$bobToAliceKeyPair = sodium_crypto_box_keypair_from_secretkey_and_publickey($bobPrivate, $alicePublic);
$plaintext = sodium_crypto_box_open($ciphertext, $nonce, $bobToAliceKeyPair);

if ($plaintext === false) {
    throw new RuntimeException('Message failed to authenticate -- not really from Alice, or tampered');
}
echo $plaintext . "\n";
<?php
declare(strict_types=1);

$keypair = sodium_crypto_sign_keypair();
$secretKey = sodium_crypto_sign_secretkey($keypair);
$publicKey = sodium_crypto_sign_publickey($keypair);

$release = json_encode([
    'package' => 'billing-service',
    'version' => '4.2.0',
    'checksum' => 'sha256:9f2a...',
], JSON_THROW_ON_ERROR);

$signature = sodium_crypto_sign_detached($release, $secretKey);

// Anyone with the PUBLIC key can verify -- no secret needed to check authenticity.
$isValid = sodium_crypto_sign_verify_detached($signature, $release, $publicKey);
var_dump($isValid); // bool(true)

// Any alteration to the message invalidates the signature.
$forged = str_replace('4.2.0', '4.2.1', $release);
var_dump(sodium_crypto_sign_verify_detached($signature, $forged, $publicKey)); // bool(false)
<?php
declare(strict_types=1);

// WRONG: a hand-rolled XOR 'cipher' -- no authentication, and a repeating key is trivially breakable
// with basic frequency analysis once any ciphertext is observed.
function insecureXorEncrypt(string $plaintext, string $key): string
{
    $out = '';
    for ($i = 0; $i < strlen($plaintext); $i++) {
        $out .= $plaintext[$i] ^ $key[$i % strlen($key)];
    }
    return $out;
}

// RIGHT: authenticated encryption from a vetted library, with a unique nonce per message.
function secureEncrypt(string $plaintext, string $key): string
{
    $nonce = random_bytes(SODIUM_CRYPTO_SECRETBOX_NONCEBYTES);
    return $nonce . sodium_crypto_secretbox($plaintext, $nonce, $key);
}

Sodium Function Families and What Each Solves

Pick the function family that matches what you actually have (a shared key? only public keys? just a password?) rather than assembling primitives yourself.

Problem                          Sodium function family
--------------------------------------------------------
Both sides share a secret key --> crypto_secretbox   (confidentiality + integrity)
Sides only share public keys  --> crypto_box         (confidentiality + integrity + auth)
Prove authorship, no secrecy  --> crypto_sign         (integrity + authenticity only)
Derive a key from a password  --> crypto_pwhash       (slow, memory-hard KDF)
Encrypt a long data stream    --> crypto_secretstream (chunked, ordered, tamper-evident)

Important Rules

Never reuse a nonce with the same key -- generate a fresh one (random_bytes of the required length) per encryption.

Treat a false return from *_open()/*_verify() as decryption/verification failure, not an error to work around.

Generate keys with the library's own keygen helper (sodium_crypto_secretbox_keygen(), etc.), not raw random_bytes() of the wrong length.

Never store, log, or expose secret/private keys client-side; load them from env vars or a secrets manager.

Use crypto_box when only public keys have been exchanged; use crypto_secretbox only when a shared secret key already exists.

Never implement your own cipher, padding scheme, or 'obfuscation' -- use Sodium's high-level functions.

Why This Matters in Production

Sodium shipping as a PHP core extension since 7.2 means there's rarely a good excuse to reach for a weaker or hand-rolled alternative -- it's already there. Production systems use secretbox for encrypting sensitive fields at rest (PII, tokens) when the application itself holds the key, crypto_box for end-to-end style flows between distinct services or users, crypto_sign for verifying webhook payloads and software update packages haven't been tampered with in transit, and crypto_pwhash for both password storage and deriving encryption keys from user-supplied passphrases (like an end-to-end encrypted notes feature). The consistent theme across all of them is that the hard cryptographic decisions -- which cipher, which mode, how to combine primitives -- are already made correctly inside the library, leaving the application code to get right only nonce freshness and key storage.

$nonce = str_repeat('0', SODIUM_CRYPTO_SECRETBOX_NONCEBYTES); // fixed/reused nonce for every message

$nonce = random_bytes(SODIUM_CRYPTO_SECRETBOX_NONCEBYTES); // freshly generated for every encryption

Reusing a nonce with the same key breaks the security guarantees of the underlying stream cipher -- with enough reused-nonce ciphertexts, an attacker can recover the XOR of the plaintexts, and in some cases full plaintext recovery.

function myEncrypt($data, $key) { return str_rot13($data); } // 'encryption' that's actually trivially reversible sodium_crypto_secretbox($data, random_bytes(SODIUM_CRYPTO_SECRETBOX_NONCEBYTES), $key)

Anything that isn't a well-known algorithm implemented via a vetted library (rot13, XOR, custom substitution) provides no real cryptographic protection, however unreadable the output looks at a glance.

[multiple-choice] Why must a nonce never be reused with the same secretbox key? A. Reusing a nonce only slows down encryption performance B. Reusing a nonce with the same key can catastrophically break the encryption's security guarantees, in some cases leaking plaintext C. PHP will throw a fatal error if a nonce is reused D. Nonces are only relevant for public-key encryption, not secretbox

B. Reusing a nonce with the same key can catastrophically break the encryption's security guarantees, in some cases leaking plaintext — The security of stream-cipher-based constructions like secretbox depends on every nonce/key combination being used at most once; reuse can let an attacker recover information about the plaintexts, up to full recovery in some scenarios.

[multiple-choice] What does sodium_crypto_sign (digital signatures) guarantee that sodium_crypto_secretbox does not, and vice versa? A. Signatures guarantee confidentiality; secretbox guarantees authenticity B. Signatures prove authorship and integrity without hiding the content; secretbox hides content and also authenticates it, but requires a shared secret key C. They are functionally identical and interchangeable D. Signatures require a shared secret key; secretbox uses public/private key pairs

B. Signatures prove authorship and integrity without hiding the content; secretbox hides content and also authenticates it, but requires a shared secret key — crypto_sign proves who signed a message and that it wasn't altered, but the message itself remains readable by anyone. crypto_secretbox additionally hides the content, but only works when both parties already share a secret key.

[multiple-choice] Why is a hand-rolled XOR or ROT13 'cipher' not a substitute for sodium_crypto_secretbox, even though its output is unreadable at a glance? A. It runs slower than sodium_crypto_secretbox B. It requires more lines of code to implement C. It provides no real cryptographic security (both are trivially reversible with well-known techniques) and no tamper detection, unlike a vetted authenticated encryption primitive D. PHP does not allow bitwise XOR on strings

C. It provides no real cryptographic security (both are trivially reversible with well-known techniques) and no tamper detection, unlike a vetted authenticated encryption primitive — Both XOR-with-repeating-key and ROT13 are trivially broken by classical, decades-old techniques and provide zero authentication, so tampered ciphertext is silently 'decrypted' into garbage rather than being detected -- neither offers real confidentiality or integrity guarantees.

Write a function encryptFieldForStorage(string $plaintext, string $key): string and its inverse decryptFieldFromStorage(string $blob, string $key): string for encrypting a sensitive database column value at rest, using sodium_crypto_secretbox with a fresh nonce per call, storing the nonce alongside the ciphertext in a single returned string.

function encryptFieldForStorage(string $plaintext, string $key): string { $nonce = random_bytes(SODIUM_CRYPTO_SECRETBOX_NONCEBYTES); return $nonce . sodium_crypto_secretbox($plaintext, $nonce, $key); } function decryptFieldFromStorage(string $blob, string $key): string { $nonce = substr($blob, 0, SODIUM_CRYPTO_SECRETBOX_NONCEBYTES); $ciphertext = substr($blob, SODIUM_CRYPTO_SECRETBOX_NONCEBYTES); $plaintext = sodium_crypto_secretbox_open($ciphertext, $nonce, $key); if ($plaintext === false) { throw new RuntimeException('Decryption failed'); } return $plaintext; }

Prepend the nonce to the ciphertext on encrypt, and slice it back off by SODIUM_CRYPTO_SECRETBOX_NONCEBYTES on decrypt; treat a false return from secretbox_open as a hard failure.