Skip to content

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

bash
composer require jardissupport/secret

GitHub: jardisSupport/secret

PHP Extensions:

ExtensionFor
ext-opensslAES-256-GCM (default)
ext-sodiumXSalsa20-Poly1305 (alternative)

Basic Usage

Encrypting a Value (CLI)

bash
# 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

ini
DB_PASSWORD=secret(aGVsbG8gd29ybGQ...)
API_KEY=secret(sodium:c29kaXVtIGVuY3J5cHRlZA...)
REDIS_TOKEN=plaintext-is-fine-too

Decrypting at Load Time

php
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)

PropertyValue
AlgorithmAES-256-GCM (authenticated encryption)
Extensionext-openssl
Key length32 bytes
Nonce12 bytes (random, per call)
Tag16 bytes (MAC)
Format in .envsecret(base64...) or secret(aes:base64...)
php
use JardisSupport\Secret\Resolver\AesSecretResolver;

// Encrypt
$ciphertext = AesSecretResolver::encrypt('my-password', $key);

// Decrypt
$resolver = new AesSecretResolver($key);
$plaintext = $resolver->resolve($ciphertext);

XSalsa20-Poly1305 (Sodium)

PropertyValue
AlgorithmXSalsa20-Poly1305 (libsodium)
Extensionext-sodium
Key length32 bytes
Nonce24 bytes (random, per call)
Format in .envsecret(sodium:base64...)
php
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:

php
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:

php
use JardisSupport\Secret\KeyProvider\EnvKeyProvider;

$keyProvider = new EnvKeyProvider('APP_SECRET_KEY');

Custom Callable

Any callable that returns a 32-byte string:

php
$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

CiphertextSodiumAES
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

php
use JardisSupport\Secret\Handler\SecretResolverChain;

$chain = (new SecretResolverChain())
    ->addResolver(new VaultSecretResolver($vaultClient))  // custom resolver
    ->addResolver(new SodiumSecretResolver($key))
    ->addResolver(new AesSecretResolver($key));           // catch-all last

A custom resolver implements SecretResolverInterface:

php
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

ExceptionCause
InvalidKeyExceptionKey missing, empty, not 32 bytes, file not readable
DecryptionFailedExceptionInvalid Base64, data too short, MAC error (tampered)
EncryptionFailedExceptionOpenSSL error during encrypt()
SecretResolutionExceptionNo resolver in the chain recognizes the value

All inherit from SecretExceptionSecretResolutionExceptionRuntimeException.

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 variable

Directory 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.php

Complete Example

ini
# .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=false
php
use 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
// ]