Encrypt Secrets in .env and Decrypt Them When Config Loads
The .env file is where every system credential ends up as plaintext, line after line. Anyone who manages to read it once gets the whole set. I changed the sensitive values to ciphertext and let the app decrypt them when config loads, without changing a single line of the code that uses them.
I had a job deploying Laravel to shared hosting, and a single .env file contained the hosting control panel's admin password, three sets of database passwords, and a registrar API key that could actually purchase domains. Imagine a directory listing being exposed one day, or a backup ending up in the wrong place: whoever gets this file gets the entire key ring to the house. So I built a small system to store these values as ciphertext and decrypt them when config loads, without the calling code needing to know anything about it.
I need to be clear up front that this is no invincible defense — anyone with enough access to the machine to run php artisan tinker can still read the actual values, because the key (APP_KEY) is right there in the same file. What this actually does is raise the bar from "see it, copy it, use it immediately" to "need permission to run code on the machine before getting the value", which covers nearly all the most common cases: .env leaking through a backup, being read through a path traversal vulnerability, accidentally being committed to a repo, or being visible to someone else while I edit the file.
All the values in this article are dummy values I created from scratch, not values from any actual system. But the structure and code are what I use in practice.
What .env looks like after the change
APP_KEY=base64:Q0hBTkdFTUUtRkFLRS1LRVktRk9SLURFTU8tT05MWQ==
# ค่าที่เข้ารหัสแล้ว — ยาวขึ้นเยอะ แต่แอปอ่านได้ปกติ
DA_API_KEY=eyJpdiI6IjRuS3BXb1UzczlRPT0iLCJ2YWx1ZSI6IjdqU2t2WGh2...
DB_PASSWORD=eyJpdiI6ImhZUmFGMEs1Zzg9PSIsInZhbHVlIjoiT2daNHFMbXQ...
REGISTRAR_API_KEY=eyJpdiI6IlZ0ODJkR3BLZz09IiwidmFsdWUiOiJVdzNu...
# ค่าที่ไม่ต้องเข้ารหัส — ไม่ใช่ความลับ เป็นโครงสร้าง
DB_HOST=127.0.0.1
DB_PORT=3306
DB_USERNAME=app_user
DA_READ_ONLY=true
What I like about this approach is that the two formats can coexist — values that have not been encrypted still work normally. There is no need to convert the entire file at once and then worry about what might break. I can work through them one at a time.
One helper: detect encryption and decrypt automatically
At the core is a single short class. Its job is to take a string from env(), decrypt it to the actual value if it looks like ciphertext, and simply return it unchanged if it is a regular value.
<?php
namespace App\Support;
use Illuminate\Encryption\Encrypter;
use Illuminate\Support\Str;
final class Secret
{
/** คืนค่าที่ถอดรหัสแล้ว — ค่าธรรมดาปล่อยผ่านตามเดิม */
public static function get(?string $value): ?string
{
if ($value === null || $value === '') {
return $value;
}
// Laravel Crypt = base64(JSON{iv,value,mac,tag}) -> ขึ้นต้นด้วย "eyJ" และยาวเกิน 80 เสมอ
if (! Str::startsWith($value, 'eyJ') || strlen($value) < 80) {
return $value;
}
try {
$key = self::key();
if ($key === '') {
return $value; // ไม่มีกุญแจ ก็คืนค่าดิบไปก่อน
}
return (new Encrypter($key, 'AES-256-CBC'))->decryptString($value);
} catch (\Throwable $e) {
return $value; // ถอดไม่ออก = ไม่ใช่ ciphertext จริง
}
}
/** เข้ารหัส plaintext ด้วย APP_KEY — ใช้จาก artisan command */
public static function encrypt(string $plaintext): string
{
$key = self::key();
if ($key === '') {
throw new \RuntimeException('APP_KEY ว่าง — สั่ง php artisan key:generate ก่อน');
}
return (new Encrypter($key, 'AES-256-CBC'))->encryptString($plaintext);
}
private static function key(): string
{
$key = (string) env('APP_KEY', '');
return Str::startsWith($key, 'base64:') ? base64_decode(substr($key, 7)) : $key;
}
}
Detection uses a two-part heuristic: the value starts with eyJ (because Laravel's payload is base64-encoded JSON that always starts with {"iv") and is at least 80 characters long. An actual password chosen by a person is extremely unlikely to meet both conditions, and even if it happens to, the try/catch returns the raw value anyway — I designed it so every failure path returns the original value instead of throwing an exception, because an exception during config loading means a blank page across the entire site, not just one broken feature.
The calling side is a regular config file. Wrap env() once, and that is it:
// config/services.php
'directadmin' => [
'url' => env('DA_URL'),
'user' => env('DA_USER'),
// ถ้าใน .env เป็น ciphertext จะถอดให้ ถ้าเป็นค่าธรรมดาก็ปล่อยผ่าน
'key' => Secret::get(env('DA_API_KEY')),
],
// config/database.php
'mysql' => [
'host' => env('DB_HOST', '127.0.0.1'),
'username' => env('DB_USERNAME'),
'password' => \App\Support\Secret::get(env('DB_PASSWORD', '')),
],
The rest of the application code needs no changes. Calling config('services.directadmin.key') returns the actual value exactly as before.
Why I avoid the Crypt facade
This is where many people get stuck, and it is why I instantiate new Encrypter myself instead of just calling Crypt::decryptString().
Laravel's boot sequence loads the entire config directory first, then registers service providers. The Crypt facade is tied to EncryptionServiceProvider, which has not finished registering while the files in config/ are being included. Calling it at that point produces RuntimeException: A facade root has not been set — and worse, it fails during bootstrap, immediately leaving the entire site blank, not just the page that uses that value.
Cannot be used in config files
The facade has to wait for the service container to bind its instance, which happens after config finishes loading. Calling it then = facade root has not been set = a blank page across the entire site.
Instantiate it without the container
It is just a regular PHP class. Give it a key and a cipher, and it works immediately. It can run at any point during boot, including inside config files.
The same principle applies to anything that needs to run inside a config file — nothing in a config file should depend on a facade or the service container. Use only things that can be instantiated directly, or a chicken-and-egg problem is inevitable.
If I run php artisan config:cache, the output written to bootstrap/cache/config.php contains the decrypted values = plain plaintext throughout the file. That means this cache file needs permissions as strict as .env (600, owned by the web user), and it must never end up in a backup shared with other people. I handle this by including both .env and bootstrap/cache/ on the list of paths whose permissions I check after every deploy.
An artisan command that tests decryption before printing output
I implemented encryption as an artisan command because it needs to run on the target machine (I will explain why in the next section). To me, the most important part of this command is not encryption itself, but decrypting the result and comparing it before allowing the value to be used.
<?php
namespace App\Console\Commands;
use App\Support\Secret;
use Illuminate\Console\Command;
class EncryptSecret extends Command
{
protected $signature = 'secret:encrypt {plaintext : ค่าที่จะเข้ารหัส (ใส่ quote ถ้ามีช่องว่างหรืออักขระพิเศษ)}';
protected $description = 'เข้ารหัส secret ด้วย APP_KEY สำหรับเก็บลง .env';
public function handle(): int
{
$plain = (string) $this->argument('plaintext');
if ($plain === '') {
$this->error('plaintext ต้องไม่ว่าง');
return self::FAILURE;
}
$cipher = Secret::encrypt($plain);
$this->line($cipher);
// ถอดกลับเทียบทันที — ถ้าไม่ตรงห้ามเอาไปใช้เด็ดขาด
if (Secret::get($cipher) !== $plain) {
$this->error('round-trip decrypt ไม่ผ่าน — ห้ามเอาค่านี้ไปใช้');
return self::FAILURE;
}
$this->comment('# round-trip OK · length ' . strlen($cipher));
return self::SUCCESS;
}
}
This is what it looks like in use:
php artisan secret:encrypt 'Xq7mPd2vRt'
eyJpdiI6IjRuS3BXb1UzczlRPT0iLCJ2YWx1ZSI6IjdqU2t2WGh2TzBhcnFuSUE9PSIsIm1hYyI6ImE0...
# round-trip OK · length 188
Why spend time decrypting something I have just encrypted? Because the case I worry about is a silent problem with APP_KEY in the file, such as a trailing space copied at the end of the line or a missing character in the base64 string. In that situation, encryptString() still works normally and produces perfectly good-looking ciphertext, but once it is pasted into .env, it cannot be decrypted. I might not discover it until the app fails to connect to the DB in the middle of the night. This one-line check means every value printed out has been verified to decrypt successfully on this machine.
What to encrypt and what to leave alone
I do not encrypt every line, because the more I encrypt, the harder debugging becomes, and values that are not secrets gain nothing from being hidden. My criterion is simple: if this value leaks, can someone immediately use it to cause damage? If yes, encrypt it. If not, leave it as it is.
| Variable | Encrypt | Reason |
|---|---|---|
DA_API_KEY |
Yes | Controls the entire hosting machine; can create/delete users |
DB_PASSWORD |
Yes | Grants full write access to the database |
REGISTRAR_API_KEY |
Yes | Can transfer/renew domains, spending real money |
MAIL_PASSWORD |
Yes | Can send mail on behalf of my domain |
PAYMENT_SECRET |
Yes | Can confirm transactions |
APP_KEY |
No | It is the key itself; it cannot encrypt itself |
DB_HOST / DB_PORT / DB_USERNAME |
No | Structural information, not secrets |
APP_URL / path / locale |
No | Already public |
Flags such as DA_READ_ONLY=true |
No | Encryption makes them harder to read and debug, with no added benefit |
| client ID / public keys | No | Identifiers, not authentication credentials |
On my dev machine at home, I still keep everything as plaintext. The risk is low, and I do not have to decrypt each value while tracking down bugs — the pass-through behavior in Secret::get() lets both formats use the same code without any separate branches.
The big trap: APP_KEY differs across environments
This section is the heart of the article. If you read only one section, read this one.
The encryption key is APP_KEY in each machine's .env file, and dev and production always have different values (as they should). The consequence is straightforward: a value encrypted on dev cannot be decrypted when pasted into production. And because I designed the helper to "return the raw value if decryption fails," it will not even raise an error — the app will use that long ciphertext as the password and produce a "login failed" message that gives no indication of the actual cause.
If someone accidentally runs php artisan key:generate on production (very common during the first deploy or when copying .env.example), APP_KEY is overwritten with a new value, and every encrypted value becomes undecryptable at once — DB connections fail, API calls fail, and mail stops going out, all in the same second. There is no way to undo it because the old key is gone. The only fix is to encrypt the original plaintext again with the new key, which means there must always be a plaintext backup off the machine (in a password manager or vault), rather than keeping the only copy in the world in .env.
These are the steps I follow when deploying to production. It is a bit long, but I only have to do it once:
- Back up the target machine's .env before touching anything — the entire file, not just the lines being changed.
- Check that APP_KEY already has a value and is not empty. If it is empty, run key:generate once now. Never run it again after this.
- SSH into the target machine and run php artisan secret:encrypt there, one value at a time. Save the output starting with eyJ.
- Paste the resulting values into that machine's .env, then clear the config cache with php artisan config:clear.
- Verify that decryption actually works before considering the job done. Do not assume it probably works.
- Store the original plaintext in a password manager, then delete all temporary files used along the way, including clearing shell history if any passwords were typed directly into it.
For step 5, verify with this command:
php artisan tinker --execute='echo config("services.directadmin.key"), PHP_EOL;'
# ต้องได้ค่าจริง ไม่ใช่ eyJ...
If the output still starts with eyJ, decryption failed. Do not proceed yet; go back and check whether the encryption command was run on the correct machine.
The command php artisan secret:encrypt 'รหัสจริง' will appear in ~/.bash_history on the target machine, undoing the effort: I went to the trouble of encrypting .env, only to leave plaintext sitting in history. I put a space before the command (if HISTCONTROL=ignorespace is configured), or remove that line from history and run history -c immediately after finishing. Check directly that nothing remains.
What this does and does not provide
What this provides is an .env file that cannot be used immediately if it leaks; the matching APP_KEY is needed as well. In many actual leaks (exposed backups, files read through vulnerabilities, accidental commits), whoever gets the data often gets only part of it — and changing "the password is right there, ready to copy" into "find the key and decrypt it first" buys time to rotate credentials. What it does not provide is protection against an attacker who can already run code on the machine. At that point, the game has long been over. I should not fool myself into thinking this improves security in that respect.
Personally, I think this approach costs very little for what it provides — the actual code is one class and one command, with a few extra lines wrapping env() in config files, and the rest of the app knows nothing about it. Feel free to adapt it. Replacing the heuristic with a custom prefix, such as adding enc: and stripping it before decryption, would make it even more explicit and eliminate the need to guess from base64. If I have missed an explanation or made a typo anywhere, I will come back and fix it later.
Want us to set this up?
Get help with server setup, configuration, migration, or troubleshooting. Save time for the work that matters.
Hit something like this? Stuck somewhere?
Come trade notes on the LinuxThai board



Comments 0
No comments yet — be the first