Getting started

Encryption

The server never sees plaintext. Encrypt before writing, decrypt after reading, and rotate history along with live values, from PHP.

Envless is end-to-end encrypted. The server stores ciphertext, cannot decrypt it, and cannot encrypt on your behalf. That is not a limitation to work around, it is the product, and it means the SDK does the cryptography locally, in your process, with the AES-GCM and PBKDF2 of PHP's own OpenSSL extension.

php
use Envless\Envless;

$envless = new Envless();
$passphrase = (string) getenv('ENVLESS_PASSPHRASE');
$workspaceId = $envless->me->get()['workspaceId'];

$ciphertext = Envless::encryptValue('sk_live_51H...', $passphrase, $workspaceId);

$envless->variables->create('billing', 'production', [
    'name' => 'STRIPE_SECRET_KEY',
    'value' => $ciphertext,
]);
php
use Envless\Envless;

$envless = new Envless();
$passphrase = (string) getenv('ENVLESS_PASSPHRASE');
$workspaceId = $envless->me->get()['workspaceId'];
$variable = $envless->variables->get('billing', 'production', 'STRIPE_SECRET_KEY');

if ($variable['value'] !== null) {
    echo Envless::decryptValue($variable['value'], $passphrase, $workspaceId), PHP_EOL;
}

Writing a plaintext value is refused with 400 variable.ciphertext_invalid. The same applies to defaultValue: it is encrypted with the same workspace key, and a plaintext default is stored permanently and copied into every version snapshot from then on.

A value that cannot be decrypted, because the passphrase is wrong, the workspace id is wrong, or the text is not an Envless envelope, throws Envless\Exception\DecryptionException.

Plaintext, passphrases and workspace ids are encrypted as UTF-8, and bytes that are not valid UTF-8, such as binary data, throw Envless\Exception\InvalidArgumentException rather than being changed silently, so encode binary data first, for example as base64.

The recipe

Envless\Constants\EncryptionParameters holds the exact constants, the same values the TypeScript SDK exports, so any other implementation can match them byte for byte.

php
use Envless\Constants\EncryptionParameters;

echo EncryptionParameters::PBKDF2_ITERATIONS, PHP_EOL;
ParameterValue
KDF_ALGORITHMPBKDF2
PBKDF2_ITERATIONS200000
HASHSHA-256
AES_ALGORITHMAES-GCM
AES_LENGTH256
IV_LENGTH12 bytes, random per encryption
SALT_LENGTH16 bytes
SALT_NAMESPACEenvless:workspace:
CIPHERTEXT_VERSION_PREFIXv1:

The salt is derived, not stored: SHA-256("envless:workspace:" . $workspaceId) truncated to 16 bytes. That is why the workspace id is a required argument, and why decrypting with the wrong workspace id fails even when the passphrase is right.

A stored value is v1:base64(iv ‖ ciphertext). Older rows can carry v2:<iterations>:base64(iv ‖ ciphertext), which pins a non-default iteration count. decryptValue reads both. encryptValue always writes v1:. Ciphertext from this package, the TypeScript, Python and Ruby SDKs, the CLI and the dashboard is interchangeable in both directions.

php
use Envless\Envless;

var_dump(Envless::isCiphertext('v1:8Wm3kQ...'));
var_dump(Envless::isCiphertext('v2:100000:8Wm3kQ...'));
var_dump(Envless::isCiphertext('sk_live_51H...'));

isCiphertext takes any value and answers false for anything that is not a string carrying an Envless envelope, null included, and PHPStan and Psalm narrow the value to a string when it answers true.

Derive the key once for a batch

PBKDF2 at 200,000 iterations is deliberately slow. encryptValue and decryptValue derive the key on every call, which is fine for one variable and wasteful for two hundred. Derive it once instead.

php
use Envless\Envless;

$envless = new Envless();
$passphrase = (string) getenv('ENVLESS_PASSPHRASE');
$workspaceId = $envless->me->get()['workspaceId'];
$key = Envless::deriveWorkspaceKey($passphrase, $workspaceId);
$plaintext = [];

foreach ($envless->variables->listAll('billing', 'production') as $variable) {
    if (Envless::isCiphertext($variable['value'])) {
        $plaintext[$variable['name']] = Envless::decryptWithKey($key, $variable['value']);
    }
}

echo implode(', ', array_keys($plaintext)), PHP_EOL;

Envless::encryptWithKey($key, $plaintext) is the matching direction, and both take the Envless\Crypto\WorkspaceKey returned by deriveWorkspaceKey or importWorkspaceKey. Raw key bytes become one with WorkspaceKey::fromBytes(), which refuses anything but exactly 32 bytes, so a passphrase passed by mistake never becomes a key.

An Envless\Crypto\WorkspaceKey shows [redacted] in var_dump() and print_r(), refuses to be serialised, and is passed as a sensitive parameter, so it never ends up in a log or a stack trace by accident. bytes() gives you the raw key when you really need it. Do not persist it and do not send it anywhere: it is equivalent to the passphrase for that workspace.

Machine keys

A workspace can also be unlocked with a raw 256-bit key instead of a passphrase, which is how CI runners avoid holding a human secret. importWorkspaceKey takes its base64url form, the ENVLESS_KEY a CI runner holds.

php
use Envless\Envless;

$key = Envless::importWorkspaceKey((string) getenv('ENVLESS_KEY'));

print_r($key);

A key of the wrong length throws Envless\Exception\InvalidArgumentException immediately rather than producing garbage on first use. See Machine and CI access for how to obtain one.

Checking a passphrase

passphraseStrength applies the rules rotateWorkspacePassphrase enforces on a new passphrase, the same ones the TypeScript SDK exports, and returns an Envless\Result\Validation rather than throwing, so it fits a form or an onboarding script:

php
use Envless\Envless;

$check = Envless::passphraseStrength('correct horse battery staple');

if (!$check->valid) {
    echo $check->error, PHP_EOL;
}

Rotating a passphrase

This is the operation people get wrong. Re-encrypting live variables and stopping there leaves version history encrypted with the old key, which silently breaks every later rollback: the rollback succeeds, and the restored values are undecryptable.

Envless::rotateWorkspacePassphrase() walks both.

php
use Envless\Envless;
use Envless\Result\RotationIssue;

$envless = new Envless();
$workspaceId = $envless->me->get()['workspaceId'];

$result = Envless::rotateWorkspacePassphrase(
    client: $envless,
    workspaceUniqueId: $workspaceId,
    from: (string) getenv('ENVLESS_PASSPHRASE'),
    to: (string) getenv('ENVLESS_NEW_PASSPHRASE'),
    project: 'billing',
    environment: 'production',
);

if ($result->failures !== []) {
    $failed = array_map(static fn(RotationIssue $issue): string => $issue->id, $result->failures);

    throw new RuntimeException('Rotation incomplete, keep the old passphrase: ' . implode(', ', $failed));
}

echo $result->variables, ' variables, ', $result->versionEntries, ' version entries', PHP_EOL;

It re-encrypts every live variable value and default, then rewrites the version entries in batches of 100 through versions->reencryptEntries(). Needs variables:write and versions:write. client: takes any Envless\Envless, and product: disambiguates the project like everywhere else.

The result is an Envless\Result\RotationResult. variables and versionEntries count what was actually written, so a run that decrypts nothing reports zero rather than the number of rows it looked at. skipped lists the values that were not ciphertext and were left alone, and failures lists what could not be decrypted or what the API refused, each an Envless\Result\RotationIssue with scope, id, field and reason. A version entry whose own value is empty or plaintext but whose defaultValue is ciphertext lands in failures too, with field set to defaultValue, because the API only rewrites entries that carry a ciphertext value. to: is checked against the workspace passphrase rules before anything is written, and a weak one throws Envless\Exception\InvalidArgumentException instead of re-encrypting your data behind a passphrase that will not hold.

Always inspect failures before you retire the old passphrase. A partial rotation leaves the listed items readable only with the old one, and the call itself still returns normally.

Rotation is per environment, not per workspace. Run it for every environment that holds variables, or the ones you skip stay on the old passphrase and stop decrypting once you retire it.

Syncing a whole environment

Do not loop over create, update and delete. variables->apply() runs the entire change set in one transaction and detects concurrent edits through the baseline you send.

php
use Envless\Envless;

$envless = new Envless();
$passphrase = (string) getenv('ENVLESS_PASSPHRASE');
$workspaceId = $envless->me->get()['workspaceId'];
$baseline = [];
$deletes = [];

foreach ($envless->variables->listAll('billing', 'production') as $variable) {
    $baseline[] = [
        'uniqueId' => $variable['id'],
        'name' => $variable['name'],
        'updatedAt' => $variable['updatedAt'] ?? $variable['createdAt'],
    ];

    if ($variable['name'] === 'OLD_FLAG') {
        $deletes[] = ['uniqueId' => $variable['id']];
    }
}

$result = $envless->variables->apply('billing', 'production', [
    'baseline' => $baseline,
    'creates' => [['name' => 'NEW_FLAG', 'value' => Envless::encryptValue('true', $passphrase, $workspaceId)]],
    'deletes' => $deletes,
]);

echo $result['added'], ' added, ', $result['updated'], ' updated, ', $result['removed'], ' removed', PHP_EOL;

If anybody changed a variable since you read the baseline, the call fails with 409 variable.baseline_drifted and nothing is written. Re-read and retry.

What the server can and cannot see

EncryptedVariable values, and variable defaults
Not encryptedVariable names, types, visibility flags, required flags
Not encryptedProduct, project and environment names and slugs
Not encryptedMember emails, role names, webhook URLs

Names are searchable and appear in webhook payloads and audit history, so treat a variable name as public metadata. Put nothing sensitive in one.