HTTP API

Encrypting Values

How to encrypt a variable value client-side before sending it to the API.

The Envless API server never sees plaintext and cannot encrypt for you. Before sending any variable value in a write request (PATCH, POST), you must encrypt it locally with your workspace key and pass the resulting ciphertext. This is the same e2e model the CLI and runtime use.

Use the @goenvless/env package

Install the runtime package and call encrypt() / decrypt() from its /crypto entrypoint. No recipe to copy, and nothing in it that ties you to a Node process.

npm install @goenvless/env
ts
import { encrypt, decrypt } from '@goenvless/env/crypto'

// Encrypt before sending to the API
const value = await encrypt(
    'postgres://user:pass@host/db',
    process.env.ENVLESS_PASSPHRASE!,
    workspaceId
)

await fetch('https://api.envless.cloud/projects/my-app/environments/production/variables/DATABASE_URL', {
    method: 'PATCH',
    headers: {
        Authorization: `Bearer ${process.env.ENVLESS_TOKEN}`,
        'Content-Type': 'application/json'
    },
    body: JSON.stringify({ value })
})

// Decrypt a value returned by the API
const plaintext = await decrypt(value, process.env.ENVLESS_PASSPHRASE!, workspaceId)

Both functions use the Web Crypto API directly: no native dependencies, no WASM, no polyfill.

/crypto is the entrypoint built for exactly this job: the recipe with no loader behind it. Base64 goes through btoa / atob, never Buffer, so the bundle carries no node: import and no Node global at all. The build enforces that: it refuses to emit crypto.js if either shows up. So the same two calls run unchanged on Node, Bun and Deno, in a browser, in a worker, and on any edge runtime. Alongside encrypt() and decrypt() it gives you encryptWithKey, decryptWithKey, workspaceKey and importWorkspaceKey for code that already holds a CryptoKey, plus passphraseStrength and ENCRYPTION_PARAMETERS.

The root @goenvless/env entry re-exports the same encrypt() and decrypt(), but it ships the loader alongside them, so it statically imports node:fs, node:path and node:os, and it exposes none of the CryptoKey helpers. A bundler tree-shakes those built-ins away for a browser build, since neither function touches the filesystem, but loading the module straight from a CDN fails on the bare specifiers. Reach for the root only from a Node script that also wants env and load.

decryptWithKey() uses the key you hand it and nothing else, so a v2: value pinning an iteration count other than 200000 throws instead of failing silently. Read those with decrypt() and the passphrase, which re-derives at the pinned cost.

To pull and decrypt a whole environment where there is no filesystem, use @goenvless/env/edge. Its load(options) takes { token, project, environment, product?, passphrase? | key?, workspaceId?, endpoint?, fetch? } and returns a plain Record<string, string>. It does not populate process.env and it caches nothing.

/crypto and /edge are two of the nine entrypoints @goenvless/env ships, and the runtime reference documents all nine. The envless passphrase set command used below comes from a different package, @goenvless/cli, which is the binary and exports nothing importable at all.

Exports

Everything @goenvless/env/crypto exports, and nothing else is in it:

ExportSignaturePurpose
encrypt(plaintext: string, passphrase: string, workspaceUniqueId: string) => Promise<string>Produce a v1:... ciphertext suitable for POST / PATCH
decrypt(ciphertext: string, passphrase: string, workspaceUniqueId: string) => Promise<string>Reverse of encrypt. Reads v1:... and any v2:<iterations>:..., re-deriving the key at the cost the envelope pins
encryptWithKey(key: CryptoKey, plaintext: string) => Promise<string>Seal with a key you already hold, so the KDF runs once for a batch instead of once per value
decryptWithKey(key: CryptoKey, ciphertext: string) => Promise<string>Open with that same key. No passphrase behind it, so it cannot re-derive for a pinned cost
workspaceKey(passphrase: string, workspaceUniqueId: string) => Promise<CryptoKey>Derive the workspace key once, at 200000 iterations
importWorkspaceKey(material: string) => Promise<CryptoKey>Turn the base64url raw 256-bit key (ENVLESS_KEY) into a CryptoKey. Anything that does not decode to exactly 32 bytes is refused
passphraseStrength(passphrase: string) => { valid: boolean, error: string | null }Check a passphrase against the same floor the dashboard and CLI enforce, before you encrypt anything with it
ENCRYPTION_PARAMETERSconstants objectThe cryptographic constants, exposed so non-JS implementations can match the recipe byte-for-byte

Of that list, the root @goenvless/env entry carries only encrypt, decrypt, passphraseStrength and ENCRYPTION_PARAMETERS. It also exports env and load, the loader surface, which has nothing to do with encryption.

encrypt(plaintext, passphrase, workspaceUniqueId)

ParamTypeDescription
plaintextstringThe raw secret value to encrypt
passphrasestringWorkspace passphrase (same one used by envless passphrase set)
workspaceUniqueIdstringWorkspace UUID, returned by GET /workspace as id

Returns: Promise<string>, the ciphertext in v1:<base64(iv || ciphertext)> format.

decrypt(ciphertext, passphrase, workspaceUniqueId)

ParamTypeDescription
ciphertextstringA v1:... or v2:<iterations>:... string returned by the API
passphrasestringWorkspace passphrase
workspaceUniqueIdstringWorkspace UUID

Returns: Promise<string>, the original plaintext.

Throws: Unsupported variable format if the prefix is neither v1: nor v2:, if a v2: prefix carries no : after the count, or if a v2: count is not an integer between 100000 and 1000000. Variable payload too short if the decoded payload is no longer than the 12-byte IV. An OperationError from Web Crypto (The operation failed for an operation-specific reason) if the passphrase / workspaceId don't match the ones used to encrypt.

decrypt() reads the iteration count off a v2:<iterations>: envelope and re-derives the key at exactly that cost, so a value written at any accepted cost round trips without extra work. A v1: value carries no count and is read at 200000. A count outside 100000 to 1000000 is refused while the envelope is parsed, so no key is ever derived at that cost.

Working from a CryptoKey

Every encrypt() / decrypt() call runs the 200000-iteration KDF again, which is fine for one value and wasteful for a page of them. Derive the key once instead, then seal and open with it:

ts
import { workspaceKey, encryptWithKey, decryptWithKey } from '@goenvless/env/crypto'

const key = await workspaceKey(process.env.ENVLESS_PASSPHRASE!, workspaceId)

const value = await encryptWithKey(key, 'postgres://user:pass@host/db')
const plaintext = await decryptWithKey(key, value)

importWorkspaceKey(material) is the other way in, for a runner that holds the raw base64url workspace key (ENVLESS_KEY, exported from Settings → CI / Machine Access) instead of a passphrase. It skips PBKDF2 entirely, and it throws on anything that does not decode to exactly 32 bytes.

encryptWithKey() writes the same v1: envelope encrypt() does. decryptWithKey() has no passphrase behind it, so it cannot re-derive: a v2: value pinning any count other than 200000 is refused rather than opened with the wrong key.

ENCRYPTION_PARAMETERS

Nine fields, exported under that name with identical values by @goenvless/env (both the root entry and /crypto), by @goenvless/sdk, the separate programmatic admin client, and by its twins, envless-sdk, in Python as a dict with the same keys and in Ruby as a frozen Hash with Symbol keys:

ts
const ENCRYPTION_PARAMETERS = {
    pbkdf2Iterations: 200000,
    hash: 'SHA-256',
    aesAlgorithm: 'AES-GCM',
    kdfAlgorithm: 'PBKDF2',
    aesLength: 256,
    ivLength: 12,
    saltLength: 16,
    saltNamespace: 'envless:workspace:',
    ciphertextVersionPrefix: 'v1:'
}

Use them when implementing the recipe in another language so your output decrypts cleanly with decrypt() here (and in the CLI, runtime, and dashboard).

Common pitfalls

SymptomCause
Decryption failed on the dashboard, with The value could not be decrypted. Is this the right workspace key?Wrong passphrase, or a different salt namespace
Wrong passphrase. Variables in this workspace cannot be decrypted with that key. in the CLIWrong workspaceUniqueId used for the salt
Unsupported variable format from decrypt()Prefix is neither v1: nor v2:, or the v2: count is missing, not an integer, or outside 100000 to 1000000
Validation passes locally but other tools can't read itUsed a different IV length or skipped the IV in the ciphertext concatenation
Server returns 400 with variable.ciphertext_invalidThe value does not start with v1: or v2:, so you sent plaintext by mistake. The server checks the prefix only, never the base64 body

Never commit the passphrase to your repo. Pass it through an env var or your secret manager. Treat it with the same caution as the API key itself. Anyone with the passphrase + key can decrypt every variable in the workspace.

The recipe (for non-JS implementations)

Use the package above for JavaScript / TypeScript. If you can't, here's the exact format. The tabs below show line-for-line equivalents in every popular language.

Envless ciphertext follows one format end-to-end:

plaintext
v1:<base64(iv || ciphertext)>

Self-describing v2 envelope. Newer values may carry the iteration count inline as v2:<iterations>:<base64(iv || ciphertext)> (for example v2:200000:...), between 100000 and 1000000 inclusive. A v1: value has no iteration field; its count is implicitly 200000. When you decrypt, read the iteration count from the v2 prefix if present and fall back to 200000 for v1. decrypt() in @goenvless/env already does this for you, deriving at whatever the envelope pins, so you only need the rule when implementing it elsewhere. When you encrypt, emitting v1: (200000 iterations) is what every recipe below produces and is accepted everywhere.

StepDetail
1. Derive the saltSHA-256("envless:workspace:" + workspaceUniqueId), take the first 16 bytes
2. Derive the keyPBKDF2(passphrase, salt, 200000 iterations, SHA-256) → AES-GCM-256 key
3. EncryptAES-GCM with a 12-byte random IV per value
4. EncodeConcatenate iv ++ ciphertext, base64-encode, prefix with v1:

Algorithm constants:

ConstantValue
Key derivationPBKDF2
Iterations200,000
HashSHA-256
Symmetric cipherAES-GCM-256
IV length12 bytes
Salt length16 bytes
Salt namespaceenvless:workspace:
Ciphertext version prefixv1:

workspaceUniqueId is the workspace UUID, returned by GET /workspace as id. The passphrase is whatever you set with envless passphrase set (or in the dashboard).

Reference implementations

Pick your language. Every snippet below produces the same v1:... ciphertext that decrypt() in @goenvless/env (and the CLI, runtime, and dashboard) accepts identically.

Works in Node 20+ and Bun. @goenvless/env performs exactly these steps, except that it encodes base64 with btoa rather than Buffer, which is what keeps @goenvless/env/crypto free of Node globals. Web Crypto is available on Deno and in browsers too, but Buffer is not, so swap toBase64 for a btoa based one there.

ts
const PBKDF2_ITERATIONS = 200000
const HASH = 'SHA-256'
const AES_LENGTH = 256
const IV_LENGTH = 12
const SALT_LENGTH = 16
const SALT_NAMESPACE = 'envless:workspace:'
const CIPHERTEXT_VERSION_PREFIX = 'v1:'

const encoder = new TextEncoder()

const toBase64 = (bytes: Uint8Array): string =>
    Buffer.from(bytes).toString('base64')

async function workspaceSalt(workspaceUniqueId: string): Promise<Uint8Array> {
    const digest = await crypto.subtle.digest(
        HASH,
        encoder.encode(`${SALT_NAMESPACE}${workspaceUniqueId}`)
    )
    return new Uint8Array(digest).slice(0, SALT_LENGTH)
}

async function deriveKey(passphrase: string, salt: Uint8Array): Promise<CryptoKey> {
    const baseKey = await crypto.subtle.importKey(
        'raw',
        encoder.encode(passphrase),
        { name: 'PBKDF2' },
        false,
        ['deriveKey']
    )

    return crypto.subtle.deriveKey(
        { name: 'PBKDF2', salt, iterations: PBKDF2_ITERATIONS, hash: HASH },
        baseKey,
        { name: 'AES-GCM', length: AES_LENGTH },
        true,
        ['encrypt', 'decrypt']
    )
}

export async function encrypt(plaintext: string, passphrase: string, workspaceUniqueId: string): Promise<string> {
    const salt = await workspaceSalt(workspaceUniqueId)
    const key = await deriveKey(passphrase, salt)

    const iv = crypto.getRandomValues(new Uint8Array(IV_LENGTH))
    const ciphertext = new Uint8Array(
        await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, key, encoder.encode(plaintext))
    )

    const combined = new Uint8Array(iv.length + ciphertext.length)
    combined.set(iv, 0)
    combined.set(ciphertext, iv.length)

    return `${CIPHERTEXT_VERSION_PREFIX}${toBase64(combined)}`
}