Getting started

Encryption

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

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: AES-GCM from cryptography, and PBKDF2 from Python's own hashlib.

python
import os

from envless import encrypt_value, envless

passphrase = os.environ['ENVLESS_PASSPHRASE']
workspace_id = envless.me.get()['workspaceId']

envless.variables.create(
    'billing',
    'production',
    {'name': 'STRIPE_SECRET_KEY', 'value': encrypt_value('sk_live_51H...', passphrase, workspace_id)},
)
python
import os

from envless import decrypt_value, envless

passphrase = os.environ['ENVLESS_PASSPHRASE']
workspace_id = envless.me.get()['workspaceId']
variable = envless.variables.get('billing', 'production', 'STRIPE_SECRET_KEY')

if variable['value'] is not None:
    print(decrypt_value(variable['value'], passphrase, workspace_id))

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, raises DecryptionError. It is a subclass of ValueError as well as EnvlessError.

The recipe

ENCRYPTION_PARAMETERS exposes the exact constants as a dict, the same values the TypeScript SDK exports, so any other implementation can match them byte for byte.

python
from envless import ENCRYPTION_PARAMETERS

print(ENCRYPTION_PARAMETERS['pbkdf2Iterations'])
ParameterValue
kdfAlgorithmPBKDF2
pbkdf2Iterations200000
hashSHA-256
aesAlgorithmAES-GCM
aesLength256
ivLength12 bytes, random per encryption
saltLength16 bytes
saltNamespaceenvless:workspace:
ciphertextVersionPrefixv1:

The salt is derived, not stored: SHA-256("envless:workspace:" + workspace_id) 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. decrypt_value reads both. encrypt_value always writes v1:. Ciphertext from this package, the TypeScript SDK, the CLI and the dashboard is interchangeable in both directions.

python
from envless import is_ciphertext

print(is_ciphertext('v1:8Wm3kQ...'))
print(is_ciphertext('v2:100000:8Wm3kQ...'))
print(is_ciphertext('sk_live_51H...'))

is_ciphertext is a TypeGuard[str], so after if is_ciphertext(value): a type checker knows value is a str.

Derive the key once for a batch

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

python
import os

from envless import decrypt_with_key, derive_workspace_key, envless

workspace_id = envless.me.get()['workspaceId']
key = derive_workspace_key(os.environ['ENVLESS_PASSPHRASE'], workspace_id)

plaintext = {
    variable['name']: decrypt_with_key(key, variable['value'])
    for variable in envless.variables.list_all('billing', 'production')
    if variable['value'] is not None
}

encrypt_with_key(key, plaintext) is the matching direction, and both accept the WorkspaceKey returned by derive_workspace_key or import_workspace_key, or the raw 32 bytes.

A derived key is bound to 200,000 iterations, so decrypt_with_key cannot open an older v2: value that pins a different count: it raises DecryptionError rather than guess. When a batch holds such rows, catch that and fall back to decrypt_value for those, which re-derives at the pinned cost.

A WorkspaceKey prints as WorkspaceKey(<redacted>), so it never ends up in a log or a traceback by accident, and bytes(key) 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. import_workspace_key takes its base64url form, the ENVLESS_KEY a CI runner holds.

python
import os

from envless import import_workspace_key

key = import_workspace_key(os.environ['ENVLESS_KEY'])

A key of the wrong length raises ValueError immediately rather than producing garbage on first use. See Machine and CI access for how to obtain one.

Checking a passphrase

passphrase_strength applies the rules rotate_workspace_passphrase enforces on a new passphrase, the same ones the TypeScript SDK exports: at least 16 characters, at least three of lowercase, uppercase, digits and symbols (two once it reaches 24 characters), no character four times in a row, no run like 1234 or abcd, and not mostly a well known weak pattern. It returns a dict rather than raising, so it fits a form or an onboarding script. See Choosing a passphrase for why these matter.

python
from envless import passphrase_strength

check = passphrase_strength('correct horse battery staple')

if not check['valid']:
    print(check['error'])

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.

rotate_workspace_passphrase walks both. Its old passphrase argument is from_, with a trailing underscore, because from is a Python keyword.

python
import os
import sys

from envless import envless, get_client, rotate_workspace_passphrase

workspace_id = envless.me.get()['workspaceId']

moved = rotate_workspace_passphrase(
    client=get_client(),
    workspace_unique_id=workspace_id,
    from_=os.environ['ENVLESS_PASSPHRASE'],
    to=os.environ['ENVLESS_NEW_PASSPHRASE'],
    project='billing',
    environment='production',
)

if moved.get('failures'):
    print('Rotation was incomplete, keep the old passphrase:', moved.get('failures'))
    sys.exit(1)

print(f"{moved['variables']} variables, {moved['versionEntries']} version entries")

It reads every live variable and every version entry up front, then re-encrypts the variable values and defaults one variable at a time while rewriting the version entries in batches of 100 through versions.reencrypt_entries, so both sets are held in memory for the run. Needs variables:write and versions:write. client takes an Envless or an AsyncEnvless; with the async client the call returns a coroutine, so you await it. get_client() hands you the real default client, which is what client= wants.

The result is a dict:

KeyHolds
variablesHow many variables were rewritten.
versionEntriesHow many version entries were rewritten.
skippedPresent when some stored values are not ciphertext at all, such as legacy plaintext. Each is left untouched and listed with scope ('variable' or 'versionEntry'), id, field and reason.
failuresPresent when a row could not be decrypted with from_ or could not be written. Each is listed with scope, id and reason. A version entry whose own value is empty or plaintext but whose defaultValue is ciphertext is listed here too, with field set to 'defaultValue', because the API only rewrites entries that carry a ciphertext value.

A wrong from_ does not raise: every row fails to decrypt and lands in failures, so the counts come back zero. An error reading the environment in the first place, such as a missing scope or an unreachable API, does raise. The counts report what was actually written, so a run that decrypts nothing reports zero rather than the number of rows it looked at. to is checked against the workspace passphrase rules before anything is written, and a weak one raises ValueError 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.

python
import os

from envless import encrypt_value, envless

passphrase = os.environ['ENVLESS_PASSPHRASE']
workspace_id = envless.me.get()['workspaceId']
current = envless.variables.list_all('billing', 'production')

result = envless.variables.apply(
    'billing',
    'production',
    {
        'baseline': [
            {
                'uniqueId': variable['id'],
                'name': variable['name'],
                'updatedAt': variable['updatedAt'] or variable['createdAt'],
            }
            for variable in current
        ],
        'creates': [{'name': 'NEW_FLAG', 'value': encrypt_value('true', passphrase, workspace_id)}],
        'deletes': [{'uniqueId': variable['id']} for variable in current if variable['name'] == 'OLD_FLAG'],
    },
)

print(f"{result['added']} added, {result['updated']} updated, {result['removed']} removed")

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.