Secret
Encrypted values in .env files: decrypted only at load time, never stored in plain text.
Introduction
Passwords and API keys in .env files are a classic, and a risk. Even if the file doesn't belong in VCS: it sits in plain text on the server. An accidental backup, a misconfigured log entry, a curious glance over the shoulder, and the credentials are compromised.
jardissupport/secret encrypts sensitive values directly in the .env file. Decryption happens only at load time, controlled by a separate key. The key never sits next to the encrypted data: in a separate file, an ENV variable or a secret manager.
- Two algorithms — AES-256-GCM (OpenSSL) and XSalsa20-Poly1305 (Sodium), both AEAD
- Seamless DotEnv integration — one handler, one line of configuration
- Flexible key providers — from file, ENV variable or custom callable
- Extensible resolver chain — add custom encryption schemes
- Makefile tooling — generate keys and encrypt values via CLI
Installation
composer require jardissupport/secretGitHub: jardisSupport/secret
PHP Extensions:
| Extension | For |
|---|---|
ext-openssl | AES-256-GCM (default) |
ext-sodium | XSalsa20-Poly1305 (alternative) |
Basic Usage
Encrypting a Value (CLI)
# Generate key file (once)
make generate-key-file
# Encrypt value (AES)
make encrypt VALUE="my-secret-password"
# → secret(base64encodedCiphertext...)
# Encrypt value (Sodium)
make encrypt-sodium VALUE="my-api-key"
# → secret(sodium:base64encodedCiphertext...)Adding to .env
DB_PASSWORD=secret(aGVsbG8gd29ybGQ...)
API_KEY=secret(sodium:c29kaXVtIGVuY3J5cHRlZA...)
REDIS_TOKEN=plaintext-is-fine-tooDecrypting at Load Time
use JardisSupport\DotEnv\DotEnv;
use JardisSupport\Secret\Handler\SecretHandler;
use JardisSupport\Secret\KeyProvider\FileKeyProvider;
$dotEnv = new DotEnv();
$dotEnv->addHandler(
new SecretHandler(new FileKeyProvider('support/secret.key')),
prepend: true
);
$dotEnv->loadPublic(__DIR__);
// $_ENV['DB_PASSWORD'] = 'my-secret-password' (decrypted)
// $_ENV['REDIS_TOKEN'] = 'plaintext-is-fine-too' (unchanged)prepend: true is required
The SecretHandler must run before the type-cast handlers. Otherwise secret(...) is treated as a string instead of being decrypted. After that, the plain text passes through the remaining pipeline ("true" → bool, "42" → int).
Algorithms
AES-256-GCM (Default)
| Property | Value |
|---|---|
| Algorithm | AES-256-GCM (authenticated encryption) |
| Extension | ext-openssl |
| Key length | 32 bytes |
| Nonce | 12 bytes (random, per call) |
| Tag | 16 bytes (MAC) |
| Format in .env | secret(base64...) or secret(aes:base64...) |
use JardisSupport\Secret\Resolver\AesSecretResolver;
// Encrypt
$ciphertext = AesSecretResolver::encrypt('my-password', $key);
// Decrypt
$resolver = new AesSecretResolver($key);
$plaintext = $resolver->resolve($ciphertext);XSalsa20-Poly1305 (Sodium)
| Property | Value |
|---|---|
| Algorithm | XSalsa20-Poly1305 (libsodium) |
| Extension | ext-sodium |
| Key length | 32 bytes |
| Nonce | 24 bytes (random, per call) |
| Format in .env | secret(sodium:base64...) |
use JardisSupport\Secret\Resolver\SodiumSecretResolver;
// Encrypt
$ciphertext = SodiumSecretResolver::encrypt('my-api-key', $key);
// Decrypt
$resolver = new SodiumSecretResolver($key);
$plaintext = $resolver->resolve('sodium:' . $ciphertext);Both algorithms use authenticated encryption (AEAD). Tampered ciphertexts are detected and thrown, not silently decrypted.
Key Providers
The key must be 32 bytes long. Three ways to provide it:
FileKeyProvider
Reads the key from a file, ideal for server deployments:
use JardisSupport\Secret\KeyProvider\FileKeyProvider;
$keyProvider = new FileKeyProvider('/run/secrets/app-key');The file can contain the key as raw bytes or Base64-encoded. Automatically detected.
EnvKeyProvider
Reads the key from an environment variable, ideal for CI/CD and cloud:
use JardisSupport\Secret\KeyProvider\EnvKeyProvider;
$keyProvider = new EnvKeyProvider('APP_SECRET_KEY');Custom Callable
Any callable that returns a 32-byte string:
$keyProvider = fn() => file_get_contents('/vault/keys/app');All providers are lazy: the key is only loaded on the first resolve() call.
Resolver Chain
The SecretResolverChain tries resolvers in order. The first one that recognizes the ciphertext (supports() → true) decrypts it.
Prefix Detection
| Ciphertext | Sodium | AES |
|---|---|---|
sodium:base64... | ✓ | — |
aes:base64... | — | ✓ |
base64... (no prefix) | — | ✓ (catch-all) |
vault:... (unknown prefix) | — | — |
AES is the catch-all: it handles everything without a recognized prefix. That is why Sodium must be placed before AES in the chain.
Adding a Custom Resolver
use JardisSupport\Secret\Handler\SecretResolverChain;
$chain = (new SecretResolverChain())
->addResolver(new VaultSecretResolver($vaultClient)) // custom resolver
->addResolver(new SodiumSecretResolver($key))
->addResolver(new AesSecretResolver($key)); // catch-all lastA custom resolver implements SecretResolverInterface:
use JardisSupport\Contract\Secret\SecretResolverInterface;
final class VaultSecretResolver implements SecretResolverInterface
{
public function supports(string $encryptedValue): bool
{
return str_starts_with($encryptedValue, 'vault:');
}
public function resolve(string $encryptedValue): string
{
$path = substr($encryptedValue, 6);
return $this->vaultClient->read($path);
}
}Error Handling
| Exception | Cause |
|---|---|
InvalidKeyException | Key missing, empty, not 32 bytes, file not readable |
DecryptionFailedException | Invalid Base64, data too short, MAC error (tampered) |
EncryptionFailedException | OpenSSL error during encrypt() |
SecretResolutionException | No resolver in the chain recognizes the value |
All inherit from SecretException → SecretResolutionException → RuntimeException.
Architecture
SecretHandler ← Convenience entry point (recommended)
└── Secret ← DotEnv cast handler (__invoke)
└── SecretResolverChain ← Chain of Responsibility
├── SodiumSecretResolver ← sodium: prefix
└── AesSecretResolver ← aes: prefix / catch-all
↑
KeyProvider (lazy)
├── FileKeyProvider ← Key from file
└── EnvKeyProvider ← Key from ENV variableDirectory Structure
src/
├── Secret.php ← DotEnv cast handler
├── Handler/
│ ├── SecretHandler.php ← Convenience wrapper
│ └── SecretResolverChain.php ← Chain of Responsibility
├── Resolver/
│ ├── AesSecretResolver.php ← AES-256-GCM
│ └── SodiumSecretResolver.php ← XSalsa20-Poly1305
├── KeyProvider/
│ ├── FileKeyProvider.php ← Key from file
│ └── EnvKeyProvider.php ← Key from ENV
└── Exception/
├── SecretException.php
├── InvalidKeyException.php
├── DecryptionFailedException.php
└── EncryptionFailedException.phpComplete Example
# .env — secrets encrypted, rest in plain text
APP_NAME=MyApp
APP_ENV=production
DB_HOST=db.example.com
DB_PORT=3306
DB_PASSWORD=secret(aGVsbG8gd29ybGQ...)
API_KEY=secret(sodium:c29kaXVtIGVuY3J5cHRlZA...)
REDIS_HOST=redis.example.com
DEBUG=falseuse JardisSupport\DotEnv\DotEnv;
use JardisSupport\Secret\Handler\SecretHandler;
use JardisSupport\Secret\KeyProvider\FileKeyProvider;
$dotEnv = new DotEnv();
$dotEnv->addHandler(
new SecretHandler(new FileKeyProvider('/run/secrets/app.key')),
prepend: true
);
$config = $dotEnv->loadPrivate(__DIR__);
// $config = [
// 'APP_NAME' => 'MyApp',
// 'APP_ENV' => 'production',
// 'DB_HOST' => 'db.example.com',
// 'DB_PORT' => 3306,
// 'DB_PASSWORD' => 'my-secret-password', // decrypted
// 'API_KEY' => 'sk-abc123...', // decrypted
// 'REDIS_HOST' => 'redis.example.com',
// 'DEBUG' => false, // type-cast after decryption
// ]