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 on your machine.
import { createClient, encryptValue, decryptValue } from '@goenvless/sdk'
const envless = createClient()
const { workspaceId } = await envless.me.get()
const ciphertext = await encryptValue('sk_live_51H...', passphrase, workspaceId)
await envless.variables.create('billing', 'production', {
name: 'STRIPE_SECRET_KEY',
value: ciphertext
})const variable = await envless.variables.get('billing', 'production', 'STRIPE_SECRET_KEY')
const plaintext = await decryptValue(variable.value!, passphrase, workspaceId)Writing a plaintext value is rejected 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.
The recipe
ENCRYPTION_PARAMETERS exposes the exact constants so a non-JavaScript implementation can match them byte for byte.
import { ENCRYPTION_PARAMETERS } from '@goenvless/sdk'| Parameter | Value |
|---|---|
kdfAlgorithm | PBKDF2 |
pbkdf2Iterations | 200000 |
hash | SHA-256 |
aesAlgorithm | AES-GCM |
aesLength | 256 |
ivLength | 12 bytes, random per encryption |
saltLength | 16 bytes |
saltNamespace | envless:workspace: |
ciphertextVersionPrefix | v1: |
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:.
import { isCiphertext } from '@goenvless/sdk'
isCiphertext('v1:8Wm3kQ...') // true
isCiphertext('v2:100000:8Wm3kQ...') // true
isCiphertext('sk_live_51H...') // falseDerive 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.
import { decryptWithKey, deriveWorkspaceKey } from '@goenvless/sdk'
const key = await deriveWorkspaceKey(passphrase, workspaceId)
const variables = await envless.variables.listAll('billing', 'production')
const plaintext = new Map<string, string>()
for (const variable of variables) {
if (variable.value) plaintext.set(variable.name, await decryptWithKey(key, variable.value))
}encryptWithKey(key, plaintext) is the matching direction, and both accept the CryptoKey returned by deriveWorkspaceKey or importWorkspaceKey.
A derived key is bound to 200,000 iterations, so decryptWithKey cannot open an older v2: value that pins a different count: it throws rather than guess. When a batch holds such rows, catch that and fall back to decryptValue for those, which re-derives at the pinned cost.
deriveWorkspaceKey returns a Web Crypto CryptoKey marked extractable, so you can hold it for the life of a script. Do not persist it, do not log 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 the base64url form.
import { importWorkspaceKey } from '@goenvless/sdk'
const key = await importWorkspaceKey(process.env.ENVLESS_KEY!)A key of the wrong length is rejected immediately rather than producing garbage on first use. See Machine and CI access for how to obtain one.
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.
rotateWorkspacePassphrase walks both.
import { rotateWorkspacePassphrase } from '@goenvless/sdk'
const moved = await rotateWorkspacePassphrase({
client: envless,
workspaceUniqueId: workspaceId,
from: oldPassphrase,
to: newPassphrase,
project: 'billing',
environment: 'production'
})
if (moved.failures?.length) {
console.error('Rotation was incomplete, keep the old passphrase:', moved.failures)
process.exit(1)
}
console.log(`${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.reencryptEntries, so both sets are held in memory for the run. Needs variables:write and versions:write.
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 throws instead of re-encrypting your data behind a passphrase that will not hold.
One case lands in failures without any error: a version entry whose own value is empty or plaintext but whose defaultValue is ciphertext. versions.reencryptEntries only accepts entries with a ciphertext value, so that default cannot be rewritten through the API, and it is listed with field: 'defaultValue' instead of being dropped silently.
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 resolves.
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.
const current = await envless.variables.listAll('billing', 'production')
const result = await envless.variables.apply('billing', 'production', {
baseline: current.map(variable => ({
uniqueId: variable.id,
name: variable.name,
updatedAt: variable.updatedAt!
})),
creates: [{ name: 'NEW_FLAG', value: await encryptValue('true', passphrase, workspaceId) }],
deletes: [{ uniqueId: current.find(variable => variable.name === 'OLD_FLAG')!.id }]
})
console.log(`${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
| Encrypted | Variable values, and variable defaults |
| Not encrypted | Variable names, types, visibility flags, required flags |
| Not encrypted | Product, project and environment names and slugs |
| Not encrypted | Member 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.