In Laravel, what does a Crypt payload contain under each app.cipher option, and when does decryptString() throw DecryptException?
answer
- base64 of a small JSON object
- iv, value, mac, tag
- HMAC-SHA256 for CBC, tag for GCM
- MAC checked before decrypting
- Illuminate\Contracts\Encryption\DecryptException
basics
~20 sA Crypt payload is base64-encoded JSON with iv, value, mac and tag. CBC ciphers fill mac with an HMAC-SHA256; GCM ciphers use tag. DecryptException is thrown for a malformed payload, an invalid MAC or tag, or failed decryption.
solid answer
~40 sThe encrypter returns `base64_encode(json_encode(['iv', 'value', 'mac', 'tag']))`: a random IV, the OpenSSL ciphertext, and an integrity check. `app.cipher` accepts `aes-128-cbc`, `aes-256-cbc`, `aes-128-gcm` and `aes-256-gcm` (the skeleton hard-codes `AES-256-CBC`). For CBC, `mac` is an HMAC-SHA256 over IV plus ciphertext and `tag` is empty; for GCM, `mac` is empty and `tag` holds the 16-byte authentication tag. On decryption Laravel validates the envelope, then the MAC or tag, and only then decrypts. It throws `Illuminate\Contracts\Encryption\DecryptException` with "The payload is invalid." for a malformed envelope, "The MAC is invalid." when no configured key matches, and "Could not decrypt the data." when OpenSSL fails. Catch it where you decrypt stored or client-supplied values; it means tampering, truncation or a key that is not listed.
code
php · 13 lines<?php
use Illuminate\Contracts\Encryption\DecryptException;
use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Facades\Log;
try {
$apiToken = Crypt::decryptString($integration->token_ciphertext);
} catch (DecryptException $e) {
// tampered, truncated, or encrypted under a key that is no longer listed
Log::warning('Integration token could not be decrypted', ['integration' => $integration->id]);
$apiToken = null;
}go deeper
Recall that the payload is base64 JSON and that a failed decryption throws DecryptException rather than returning null.
Walk through iv, value, mac and tag, the four supported ciphers, and the order of checks that rejects a tampered value before decrypting.
Diagnose DecryptException in production by telling truncation, tampering and an unlisted key apart, and treat a cipher change as a data migration.
Weigh whether the application encrypter fits a given data class at all, or whether stored secrets belong behind a dedicated key-management boundary.
## The payload format Everything the Laravel encrypter produces, whether through `Crypt::encrypt()`, `Crypt::encryptString()` or the `encrypt()` helper, has the same shape. The encrypter: 1. draws a random **initialisation vector (IV)** of the length the cipher needs; 2. encrypts the value with OpenSSL, the key from `app.key` and that IV; 3. computes an integrity check; 4. JSON-encodes the pieces and base64-encodes the JSON. The JSON object always carries four string fields: - `iv` — the base64 IV; - `value` — the ciphertext; - `mac` — an HMAC-SHA256 hex digest, or an empty string for GCM ciphers; - `tag` — the base64 GCM authentication tag, or an empty string for CBC ciphers. Because every call draws a fresh IV, encrypting the same plaintext twice gives two different payloads. You cannot look a value up by encrypting it again and comparing. ## The app.cipher options `config/app.php` sets `'cipher' => 'AES-256-CBC'` directly; it is not read from an environment variable. The encrypter accepts four names, case-insensitively: | Cipher | Key length | Integrity check stored in | |---|---|---| | `aes-128-cbc` | 16 bytes | `mac` (HMAC-SHA256 over IV + ciphertext, keyed with the app key) | | `aes-256-cbc` (skeleton default) | 32 bytes | `mac` | | `aes-128-gcm` | 16 bytes | `tag` (authenticated encryption, `mac` left empty) | | `aes-256-gcm` | 32 bytes | `tag` | Any other name, or a key of the wrong length for the chosen name, makes the constructor throw a `RuntimeException`: "Unsupported cipher or incorrect key length." The same check runs on every key in `APP_PREVIOUS_KEYS`, so all keys must fit the current cipher. Switching the cipher is effectively a key rotation. A CBC payload read under a GCM setting has no tag and is rejected, and a GCM payload read under a CBC setting carries a tag the CBC path refuses ("Unable to use tag because the cipher algorithm does not support AEAD."). ## The order of checks on decryption `decrypt()` and `decryptString()` refuse to decrypt anything they cannot authenticate first: 1. **Envelope check.** The payload must be a string that base64-decodes to JSON with string `iv`, `value` and `mac` fields and an IV of the right length. Otherwise: `DecryptException("The payload is invalid.")`. 2. **Tag sanity.** A GCM cipher needs a 16-byte tag, and a CBC cipher must not have one. 3. **Authentication.** For CBC the MAC is recomputed with the current key and every previous key and compared with `hash_equals()`. If none matches: `DecryptException("The MAC is invalid.")`. For GCM each key is tried in OpenSSL, which verifies the tag itself. 4. **Decryption.** If OpenSSL still returns `false`: `DecryptException("Could not decrypt the data.")`. Only after step 4 does `decrypt()` unserialize the plaintext. A tampered payload therefore never reaches `unserialize()`. ## Handling DecryptException The exception class is `Illuminate\Contracts\Encryption\DecryptException`, a `RuntimeException`. In practice it means one of the following: - the value was **modified** or **truncated**, for example by a column that is too short for the payload; - the value was encrypted under a **key that is no longer listed**, typically after a key change without `APP_PREVIOUS_KEYS`; - the value was never a Laravel payload at all. Catch it at the boundary where you decrypt stored or client-supplied data. Decide per case: show a "please reconnect this integration" message, log it as a possible tampering attempt, or fail the job. A broad `catch (Exception $e)` that returns null hides the difference between tampering and a missing previous key, and the second one is an operations problem you want to see. ## Telling the causes apart in production The exception message is the first clue: | Message | Most likely cause | |---|---| | "The payload is invalid." | truncated column, double base64 encoding, or a value that was never encrypted | | "The MAC is invalid." | a key change without the old key listed, or a genuinely modified value | | "Could not decrypt the data." | a GCM tag that fails for every key, or a CBC payload read under a GCM setting | A sudden wave of "The MAC is invalid." right after a deploy almost always means a server or worker is running with a different `APP_KEY` from the one that wrote the data, not an attack.
- Why does a VARCHAR(255) column often cause DecryptException with Laravel's encrypter?The payload is base64 of a JSON envelope holding the IV, ciphertext and a 64-character hex MAC, so it is much longer than the plaintext. A column that is too short truncates it; the truncated string no longer decodes into a valid envelope or MAC, and decryption throws. Store ciphertext in a `TEXT` column.
- Is aes-256-gcm a drop-in change to app.cipher for an existing Laravel app?No. Existing CBC payloads have no GCM tag, so they fail under the new setting, and `APP_PREVIOUS_KEYS` does not help because the cipher is global, not per key. Changing the cipher needs the same care as a key rotation: decrypt everything under the old setting and re-encrypt it under the new one.
saying these in an interview costs you the question
- Laravel decrypts first and checks the MAC afterwards.
- Encrypting the same value twice yields the same payload.
- A wrong key makes decryptString() return garbage instead of throwing.
- The cipher can be set per key in APP_PREVIOUS_KEYS.
- app.cipher accepts any cipher OpenSSL supports.