NewWe open-sourced 50+ Laravel packages
Custom AI apps, agents and automation — Roundly ConsultingRoundly
All packages
Crypto for Laravel

Authenticated encryption

Aead\Aes256Gcm implements AEAD_AES_256_GCM (RFC 5116 §5.2, NIST SP 800-38D) on ext-openssl: a 32-byte key, a 12-byte nonce and a 16-byte authentication tag appended to the ciphertext. Encryption here is authenticated — open() and decrypt() return a plaintext only when the tag proves that the ciphertext, the nonce and the associated data are exactly what was sealed under that key. Anything else throws, and nothing is decrypted:

use RoundlyConsulting\Crypto\Aead\Aes256Gcm;
use RoundlyConsulting\Crypto\Aead\DecryptionFailedException;
use RoundlyConsulting\Crypto\Facades\Crypto;

// A key is 32 random bytes. Keep it like any other secret, never next to the data.
$key = Crypto::randomBytes(Aes256Gcm::KEY_BYTES);

$aead = Crypto::aes256Gcm(); // or new Aes256Gcm

// A fresh random nonce, carried in front: nonce ‖ ciphertext ‖ tag.
$sealed = $aead->seal($key, $plaintext, associatedData: 'invoices:42');

try {
    $plaintext = $aead->open($key, $sealed, associatedData: 'invoices:42');
} catch (DecryptionFailedException) {
    // wrong key or context, or a changed ciphertext — one message for every cause
}

The associated data is authenticated but not encrypted — put in it whatever the ciphertext must stay bound to (a purpose, a record’s identity), and a ciphertext copied anywhere else fails to open. The class is final readonly, takes no constructor arguments and holds no key material: every key is passed per call and marked #[\SensitiveParameter]. The facade, the injected manager and the class itself run the same code; Crypto::aes256Gcm() returns a new instance each time — it is stateless, so caching it gains nothing:

use RoundlyConsulting\Crypto\Aead\Aes256Gcm;
use RoundlyConsulting\Crypto\CryptoManager;
use RoundlyConsulting\Crypto\Facades\Crypto;

Crypto::aes256Gcm()->seal($key, $plaintext, $aad);                    // facade
app(CryptoManager::class)->aes256Gcm()->seal($key, $plaintext, $aad); // injected manager
(new Aes256Gcm)->seal($key, $plaintext, $aad);                        // the class itself

When to use it

You needUse
To encrypt a value with the app key, with nothing else to bind it toLaravel’s own Crypt / encrypt() — keyed by APP_KEY, rotating through APP_PREVIOUS_KEYS, with no associated-data input.
A ciphertext that must only open in its own context — this record, this tenant, this purposeAes256Gcm with associated data.
A key other than APP_KEY — one key per purpose, or a key held by another serviceAes256Gcm.
A compact binary format any RFC 5116 / AES-256-GCM implementation can readAes256Gcm::encrypt() or seal().
To prove a message is authentic while it stays readable — webhooks, signed URLsHash\Hmac, not encryption — see HMAC & hashing.
A lookup value that never needs decryptingHash\Digest.

Sizes and methods

ConstantValueMeaning
Aes256Gcm::KEY_BYTES32Key length (AES-256).
Aes256Gcm::NONCE_BYTES12Nonce length (96 bits, the GCM default).
Aes256Gcm::TAG_BYTES16Tag length (128 bits, appended to the ciphertext).

These are the only sizes the class supports — no AES-128 or AES-192, no shorter tags, no other nonce lengths. A key or nonce of any other length throws InvalidAeadParameterException.

MethodReturns
seal(string $key, string $plaintext, string $associatedData = '')nonce ‖ ciphertext ‖ tag, under a fresh random nonce.
open(string $key, string $sealed, string $associatedData = '')The plaintext of a seal() output.
encrypt(string $key, string $nonce, string $plaintext, string $associatedData = '')ciphertext ‖ tag — the nonce is not included.
decrypt(string $key, string $nonce, string $ciphertext, string $associatedData = '')The plaintext of an encrypt() output.
  • seal() and open() are the everyday pair: the nonce is generated and carried for you.
  • encrypt() and decrypt() are the RFC 5116 interface: you supply and store the nonce yourself.
  • seal() returns NONCE_BYTES + strlen($plaintext) + TAG_BYTES bytes (28 bytes of overhead); encrypt() returns strlen($plaintext) + TAG_BYTES. An empty plaintext is allowed and seals to 28 bytes.
  • GCM does not pad, so the ciphertext is as long as the plaintext — encryption hides the content, not the length.
  • Two seals of the same plaintext under the same key differ, because each one draws a new nonce.
  • The whole message is processed in memory; there is no streaming API.

Raw bytes in, raw bytes out

Keys, nonces and ciphertexts are binary strings. Encode them before you store them in a text column (or JSON, or a URL), and decode them before opening. A binary column ($table->binary()) takes them as they are:

$stored = Crypto::base64Encode($aead->seal($key, $plaintext, associatedData: 'invoices:42'));

$plaintext = $aead->open($key, Crypto::base64Decode($stored), associatedData: 'invoices:42');

Associated data binds the ciphertext to its context

The associated data is not stored — nothing in the output carries it. Pass the same bytes to seal() and to open(): recompute them from the context when you open (above, invoices:42 from the invoice being read), and keep their format stable — a different string fails to open, even one that means the same. Use it for whatever the ciphertext must never be moved away from: the purpose, the record it belongs to (table, primary key, column), the tenant or owner, a format or schema version:

$aadFor = fn (int $id): string => implode('|', ['invoices', $id, 'iban']);

$sealed = $aead->seal($key, $iban, $aadFor(42));

$aead->open($key, $sealed, $aadFor(42));   // the IBAN

// The same bytes moved to invoice 43's row don't decrypt there. This throws
// DecryptionFailedException:
$aead->open($key, $sealed, $aadFor(43));

Without associated data, an attacker with write access to the database could swap two rows’ ciphertexts and the app would decrypt each one in the wrong place. With it, the swap fails to open.

  • The default is '' (no context); open() must then use '' too.
  • Associated data is not secret — don’t put anything in it you wouldn’t store in clear text.
  • Build it from stable values you can recompute at decrypt time — a primary key, not a timestamp that changes.
  • Join the parts with a separator that cannot appear inside a part, so ['a|b', 'c'] and ['a', 'b|c'] never produce the same string.

Keys

A key is exactly 32 random bytes — never a password or any human-chosen string. The package is zero-config, so it never reads a key on its own: your code loads the key and passes it in:

use RoundlyConsulting\Crypto\Aead\Aes256Gcm;
use RoundlyConsulting\Crypto\Facades\Crypto;

// Generate once, e.g. in a tinker session or a setup command
$key = Crypto::randomBytes(Aes256Gcm::KEY_BYTES);
echo Crypto::base64UrlEncode($key);       // put this in your secret store / .env

// Load at runtime, from your own config key
$key = Crypto::base64UrlDecode(config('services.vault.key'));
  • One key per purpose. Don’t share an encryption key with signing, HMAC or another feature.
  • Rotation. The class takes one key per call and has no key ring. To rotate, keep the old key so existing values still open(), seal() new values with the new key, and record which key sealed each value — for example in a key-id column next to the ciphertext.

To get several keys from one master secret, PHP’s native hash_hkdf() works:

$invoiceKey = hash_hkdf('sha256', $masterKey, Aes256Gcm::KEY_BYTES, 'invoices');
$tokenKey   = hash_hkdf('sha256', $masterKey, Aes256Gcm::KEY_BYTES, 'api-tokens');

Nonces

  • seal() handles the nonce: it draws a fresh 96-bit nonce from the CSPRNG (Random\Bytes) and puts it in front of the output, and open() reads it back. You never see it.
  • Never reuse a nonce under the same key. With GCM, two messages under one key and one nonce reveal the XOR of the two plaintexts and let an attacker forge tags — which is why seal() exists.
  • With random nonces, keep one key below 2³² seals (NIST SP 800-38D §8.3); rotate or derive per-purpose keys before you reach it.

encrypt() and decrypt() take your nonce. Use them only when a protocol or another system dictates the nonce or stores it separately — uniqueness is then up to you:

use RoundlyConsulting\Crypto\Aead\Aes256Gcm;
use RoundlyConsulting\Crypto\Facades\Crypto;

$aead       = Crypto::aes256Gcm();
$nonce      = Crypto::randomBytes(Aes256Gcm::NONCE_BYTES);     // or a counter you never repeat
$ciphertext = $aead->encrypt($key, $nonce, $plaintext, $associatedData); // ciphertext ‖ tag

$plaintext  = $aead->decrypt($key, $nonce, $ciphertext, $associatedData);

seal() is exactly $nonce . encrypt($key, $nonce, …), so open($key, $nonce.$ciphertext, $aad) opens an encrypt() output too.

Interoperability

encrypt() produces standard AES-256-GCM — the ciphertext followed by a 128-bit tag, with the associated data authenticated. Any conforming implementation reads it given the key, the nonce and the associated data: PHP’s own openssl_decrypt(), Node’s crypto, Go’s crypto/cipher, Python’s cryptography and the Web Crypto API (which expects ciphertext ‖ tag too). A seal() output is the same thing with the 12-byte nonce in front:

// The tag is the last 16 bytes; native OpenSSL opens it.
$plaintext = openssl_decrypt(
    substr($ciphertext, 0, -16), 'aes-256-gcm', $key, OPENSSL_RAW_DATA,
    $nonce, substr($ciphertext, -16), $associatedData,
);

Failures

ExceptionThrown whenMeaning
Aead\DecryptionFailedExceptionopen() or decrypt() cannot authenticate: a wrong key, nonce or associated data, a changed ciphertext or tag, or input too short to hold a tag (decrypt(): under 16 bytes; open(): under 28 bytes).A data problem — treat the value as not yours, or tampered with.
Aead\InvalidAeadParameterExceptionA key is not 32 bytes (keyLength()), a nonce is not 12 bytes (nonceLength()), or OpenSSL refuses the cipher (cipherUnavailable(), unreachable on a working OpenSSL).A programming problem, never a property of the data.
  • DecryptionFailedException always carries the same message — The ciphertext could not be authenticated; nothing was decrypted. — whatever the cause. It never says which part failed and never includes the key or the plaintext.
  • InvalidAeadParameterException names only the lengths, for example An AES-256-GCM key must be 32 bytes, [16] given.
  • open() checks the sealed length before the key: anything shorter than 28 bytes (NONCE_BYTES + TAG_BYTES) throws DecryptionFailedException, even when the key length is also wrong.
  • Both extend CryptoException — see Exceptions.

Catch the data failure; let the programming failure surface:

use RoundlyConsulting\Crypto\Aead\DecryptionFailedException;
use RoundlyConsulting\Crypto\Aead\InvalidAeadParameterException;

try {
    $secret = $aead->open($key, $sealed, $aad);
} catch (DecryptionFailedException) {
    abort(404);                         // not yours / tampered: don't explain why
}
// InvalidAeadParameterException is left to bubble up: it is a bug in the caller.

How it’s proven

The implementation is pinned against the GCM specification’s AES-256 known-answer vectors (McGrew & Viega, Appendix B, test cases 13–16, checked in both directions) and every AES-256 / 96-bit-nonce / 128-bit-tag case of Project Wycheproof’s AES-GCM corpus — 39 valid cases (exact ciphertext and tag both ways) and 27 invalid ones, each of which must throw DecryptionFailedException.

Show your open-source love

This package is free and MIT-licensed. If it saves you time, a one-off donation or a Patreon membership keeps it maintained, tested and documented.

More ways to support, including crypto

By donating, you agree to our donation terms.

Want this built into your product?

We integrate our packages into custom Laravel and AI builds. Tell us what you're working on and we'll reply within 48 hours.