Documentation Index
Fetch the complete documentation index at: https://docs.envless.cloud/llms.txt
Use this file to discover all available pages before exploring further.
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/sdk package
For any Node, Bun, Deno, or browser consumer, install the SDK and call encrypt() / decrypt() directly. No recipe to copy.
npm install @goenvless/sdk
import { encrypt, decrypt } from '@goenvless/sdk'
// 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 required on Node 20+ / Bun / Deno / modern browsers.
Exports
| Export | Signature | Purpose |
|---|
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 — accepts any v1:... value returned by the API |
ENCRYPTION_PARAMETERS | EncryptionParameters | The cryptographic constants — exposed so non-JS implementations can match the recipe byte-for-byte |
EncryptionParameters (type) | type of the constants object | Useful when wrapping the API in your own SDK |
encrypt(plaintext, passphrase, workspaceUniqueId)
| Param | Type | Description |
|---|
plaintext | string | The raw secret value to encrypt |
passphrase | string | Workspace passphrase (same one used by envless passphrase set) |
workspaceUniqueId | string | Workspace UUID — returned by GET /workspace as id |
Returns: Promise<string> — ciphertext in v1:<base64(iv || ciphertext)> format.
decrypt(ciphertext, passphrase, workspaceUniqueId)
| Param | Type | Description |
|---|
ciphertext | string | A v1:... string returned by the API |
passphrase | string | Workspace passphrase |
workspaceUniqueId | string | Workspace UUID |
Returns: Promise<string> — original plaintext.
Throws: if the prefix is not v1:, the payload is malformed, or the passphrase / workspaceId don’t match the ones used to encrypt.
ENCRYPTION_PARAMETERS
const ENCRYPTION_PARAMETERS = {
pbkdf2Iterations: 600_000,
hash: 'SHA-256',
aesLength: 256,
ivLength: 12,
saltLength: 16,
saltNamespace: 'envless:workspace:',
ciphertextVersionPrefix: 'v1:'
}
Use these when implementing the recipe in another language so your output decrypts cleanly with decrypt() here (and in the CLI, runtime, and dashboard).
Common pitfalls
| Symptom | Cause |
|---|
decryption failed on the dashboard | Wrong passphrase, wrong salt namespace, or missing v1: prefix |
decryption failed in the CLI | Wrong workspaceUniqueId used for the salt |
| Validation passes locally but other tools can’t read it | Used a different IV length or skipped the IV in the ciphertext concatenation |
Server returns 400 | Value not base64; or you sent plaintext by mistake |
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:
v1:<base64(iv || ciphertext)>
| Step | Detail |
|---|
| 1. Derive the salt | SHA-256("envless:workspace:" + workspaceUniqueId), take the first 16 bytes |
| 2. Derive the key | PBKDF2(passphrase, salt, 600_000 iterations, SHA-256) → AES-GCM-256 key |
| 3. Encrypt | AES-GCM with a 12-byte random IV per value |
| 4. Encode | Concatenate iv ++ ciphertext, base64-encode, prefix with v1: |
Algorithm constants:
| Constant | Value |
|---|
| Key derivation | PBKDF2 |
| Iterations | 600,000 |
| Hash | SHA-256 |
| Symmetric cipher | AES-GCM-256 |
| IV length | 12 bytes |
| Salt length | 16 bytes |
| Salt namespace | envless:workspace: |
| Ciphertext version prefix | v1: |
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/sdk (and the CLI, runtime, and dashboard) accepts identically.
TypeScript
JavaScript
Python
Go
Rust
PHP
Ruby
Java
curl + openssl
Works in Node 20+, Bun, Deno, and browsers — all use the Web Crypto API. This is what @goenvless/sdk ships internally.const PBKDF2_ITERATIONS = 600_000
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)}`
}
Works in Node 20+, Bun, Deno, and browsers — all use the Web Crypto API. This is what @goenvless/sdk ships internally.const PBKDF2_ITERATIONS = 600_000
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) =>
Buffer.from(bytes).toString('base64')
async function workspaceSalt(workspaceUniqueId) {
const digest = await crypto.subtle.digest(
HASH,
encoder.encode(`${SALT_NAMESPACE}${workspaceUniqueId}`)
)
return new Uint8Array(digest).slice(0, SALT_LENGTH)
}
async function deriveKey(passphrase, salt) {
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, passphrase, workspaceUniqueId) {
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)}`
}
Requires cryptography:import base64, hashlib, os
from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
PBKDF2_ITERATIONS = 600_000
SALT_NAMESPACE = "envless:workspace:"
SALT_LENGTH = 16
IV_LENGTH = 12
VERSION = "v1:"
def workspace_salt(workspace_unique_id: str) -> bytes:
digest = hashlib.sha256(f"{SALT_NAMESPACE}{workspace_unique_id}".encode()).digest()
return digest[:SALT_LENGTH]
def derive_key(passphrase: str, salt: bytes) -> bytes:
kdf = PBKDF2HMAC(
algorithm=hashes.SHA256(),
length=32,
salt=salt,
iterations=PBKDF2_ITERATIONS,
)
return kdf.derive(passphrase.encode())
def encrypt(plaintext: str, passphrase: str, workspace_unique_id: str) -> str:
key = derive_key(passphrase, workspace_salt(workspace_unique_id))
iv = os.urandom(IV_LENGTH)
ciphertext = AESGCM(key).encrypt(iv, plaintext.encode(), None)
return VERSION + base64.b64encode(iv + ciphertext).decode()
Standard library plus golang.org/x/crypto/pbkdf2.package envless
import (
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"golang.org/x/crypto/pbkdf2"
)
const (
PBKDF2Iterations = 600_000
SaltNamespace = "envless:workspace:"
SaltLength = 16
IVLength = 12
Version = "v1:"
)
func workspaceSalt(workspaceUniqueID string) []byte {
sum := sha256.Sum256([]byte(SaltNamespace + workspaceUniqueID))
return sum[:SaltLength]
}
func deriveKey(passphrase string, salt []byte) []byte {
return pbkdf2.Key([]byte(passphrase), salt, PBKDF2Iterations, 32, sha256.New)
}
func Encrypt(plaintext, passphrase, workspaceUniqueID string) (string, error) {
key := deriveKey(passphrase, workspaceSalt(workspaceUniqueID))
block, err := aes.NewCipher(key)
if err != nil {
return "", err
}
aead, err := cipher.NewGCM(block)
if err != nil {
return "", err
}
iv := make([]byte, IVLength)
if _, err := rand.Read(iv); err != nil {
return "", err
}
ct := aead.Seal(nil, iv, []byte(plaintext), nil)
combined := append(iv, ct...)
return Version + base64.StdEncoding.EncodeToString(combined), nil
}
Add the dependency:go get golang.org/x/crypto/pbkdf2
Add to Cargo.toml:[dependencies]
aes-gcm = "0.10"
pbkdf2 = { version = "0.12", features = ["simple"] }
sha2 = "0.10"
base64 = "0.22"
rand = "0.8"
use aes_gcm::{aead::{Aead, KeyInit, OsRng, generic_array::GenericArray}, Aes256Gcm};
use base64::{engine::general_purpose, Engine as _};
use pbkdf2::pbkdf2_hmac;
use rand::RngCore;
use sha2::{Digest, Sha256};
const PBKDF2_ITERATIONS: u32 = 600_000;
const SALT_NAMESPACE: &str = "envless:workspace:";
const SALT_LENGTH: usize = 16;
const IV_LENGTH: usize = 12;
const VERSION: &str = "v1:";
fn workspace_salt(workspace_unique_id: &str) -> Vec<u8> {
let mut hasher = Sha256::new();
hasher.update(format!("{}{}", SALT_NAMESPACE, workspace_unique_id).as_bytes());
hasher.finalize()[..SALT_LENGTH].to_vec()
}
fn derive_key(passphrase: &str, salt: &[u8]) -> [u8; 32] {
let mut key = [0u8; 32];
pbkdf2_hmac::<Sha256>(passphrase.as_bytes(), salt, PBKDF2_ITERATIONS, &mut key);
key
}
pub fn encrypt(plaintext: &str, passphrase: &str, workspace_unique_id: &str) -> Result<String, Box<dyn std::error::Error>> {
let salt = workspace_salt(workspace_unique_id);
let key = derive_key(passphrase, &salt);
let cipher = Aes256Gcm::new(GenericArray::from_slice(&key));
let mut iv = [0u8; IV_LENGTH];
OsRng.fill_bytes(&mut iv);
let ciphertext = cipher.encrypt(GenericArray::from_slice(&iv), plaintext.as_bytes())?;
let mut combined = iv.to_vec();
combined.extend_from_slice(&ciphertext);
Ok(format!("{}{}", VERSION, general_purpose::STANDARD.encode(combined)))
}
PHP 7.1+ with the OpenSSL extension (enabled by default in most installs).<?php
const PBKDF2_ITERATIONS = 600000;
const SALT_NAMESPACE = 'envless:workspace:';
const SALT_LENGTH = 16;
const IV_LENGTH = 12;
const VERSION = 'v1:';
function workspace_salt(string $workspaceUniqueId): string {
return substr(hash('sha256', SALT_NAMESPACE . $workspaceUniqueId, true), 0, SALT_LENGTH);
}
function derive_key(string $passphrase, string $salt): string {
return hash_pbkdf2('sha256', $passphrase, $salt, PBKDF2_ITERATIONS, 32, true);
}
function encrypt(string $plaintext, string $passphrase, string $workspaceUniqueId): string {
$key = derive_key($passphrase, workspace_salt($workspaceUniqueId));
$iv = random_bytes(IV_LENGTH);
$tag = '';
$ct = openssl_encrypt($plaintext, 'aes-256-gcm', $key, OPENSSL_RAW_DATA, $iv, $tag);
return VERSION . base64_encode($iv . $ct . $tag);
}
Standard library only — openssl ships with Ruby.require 'openssl'
require 'base64'
PBKDF2_ITERATIONS = 600_000
SALT_NAMESPACE = 'envless:workspace:'
SALT_LENGTH = 16
IV_LENGTH = 12
VERSION = 'v1:'
def workspace_salt(workspace_unique_id)
OpenSSL::Digest::SHA256.digest(SALT_NAMESPACE + workspace_unique_id)[0, SALT_LENGTH]
end
def derive_key(passphrase, salt)
OpenSSL::KDF.pbkdf2_hmac(passphrase, salt: salt, iterations: PBKDF2_ITERATIONS, length: 32, hash: 'SHA256')
end
def encrypt(plaintext, passphrase, workspace_unique_id)
key = derive_key(passphrase, workspace_salt(workspace_unique_id))
iv = OpenSSL::Random.random_bytes(IV_LENGTH)
cipher = OpenSSL::Cipher.new('aes-256-gcm').encrypt
cipher.key = key
cipher.iv = iv
ct = cipher.update(plaintext) + cipher.final
tag = cipher.auth_tag
VERSION + Base64.strict_encode64(iv + ct + tag)
end
Java 8+, no third-party dependencies.import javax.crypto.Cipher;
import javax.crypto.SecretKeyFactory;
import javax.crypto.spec.GCMParameterSpec;
import javax.crypto.spec.PBEKeySpec;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.SecureRandom;
import java.util.Base64;
public class EnvlessEncrypt {
private static final int PBKDF2_ITERATIONS = 600_000;
private static final String SALT_NAMESPACE = "envless:workspace:";
private static final int SALT_LENGTH = 16;
private static final int IV_LENGTH = 12;
private static final String VERSION = "v1:";
private static byte[] workspaceSalt(String workspaceUniqueId) throws Exception {
byte[] digest = MessageDigest.getInstance("SHA-256")
.digest((SALT_NAMESPACE + workspaceUniqueId).getBytes(StandardCharsets.UTF_8));
byte[] salt = new byte[SALT_LENGTH];
System.arraycopy(digest, 0, salt, 0, SALT_LENGTH);
return salt;
}
private static byte[] deriveKey(String passphrase, byte[] salt) throws Exception {
PBEKeySpec spec = new PBEKeySpec(passphrase.toCharArray(), salt, PBKDF2_ITERATIONS, 256);
return SecretKeyFactory.getInstance("PBKDF2WithHmacSHA256").generateSecret(spec).getEncoded();
}
public static String encrypt(String plaintext, String passphrase, String workspaceUniqueId) throws Exception {
byte[] key = deriveKey(passphrase, workspaceSalt(workspaceUniqueId));
byte[] iv = new byte[IV_LENGTH];
new SecureRandom().nextBytes(iv);
Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
cipher.init(Cipher.ENCRYPT_MODE, new SecretKeySpec(key, "AES"), new GCMParameterSpec(128, iv));
byte[] ciphertext = cipher.doFinal(plaintext.getBytes(StandardCharsets.UTF_8));
byte[] combined = new byte[iv.length + ciphertext.length];
System.arraycopy(iv, 0, combined, 0, iv.length);
System.arraycopy(ciphertext, 0, combined, iv.length, ciphertext.length);
return VERSION + Base64.getEncoder().encodeToString(combined);
}
}
Quick command-line encryption with just openssl and a few pipes. Useful for shell scripts, CI snippets, or one-off rotations.#!/usr/bin/env bash
# usage: ./encrypt.sh <plaintext> <passphrase> <workspace-uuid>
set -euo pipefail
PLAINTEXT="$1"
PASSPHRASE="$2"
WORKSPACE_ID="$3"
# 1. salt = sha256("envless:workspace:" + workspaceId)[:16]
SALT_HEX=$(printf 'envless:workspace:%s' "$WORKSPACE_ID" \
| openssl dgst -sha256 -binary \
| head -c 16 \
| xxd -p -c 32)
# 2. derive 32-byte AES key via PBKDF2 (600k iterations, SHA-256)
KEY_HEX=$(printf '%s' "$PASSPHRASE" \
| openssl kdf -keylen 32 -kdfopt digest:SHA256 \
-kdfopt iter:600000 \
-kdfopt hexsalt:"$SALT_HEX" \
-kdfopt pass:"$PASSPHRASE" \
-binary PBKDF2 \
| xxd -p -c 64)
# 3. random 12-byte IV
IV_HEX=$(openssl rand -hex 12)
# 4. AES-256-GCM encrypt (openssl 3.0+ writes the tag at the end)
CIPHERTEXT=$(printf '%s' "$PLAINTEXT" \
| openssl enc -aes-256-gcm -K "$KEY_HEX" -iv "$IV_HEX" -nosalt \
| xxd -p -c 1000000)
# 5. concat iv || ciphertext, base64, prepend v1:
echo "v1:$(printf '%s%s' "$IV_HEX" "$CIPHERTEXT" | xxd -r -p | base64)"
$ ./encrypt.sh 'postgres://...' "$ENVLESS_PASSPHRASE" "$WORKSPACE_ID"
v1:5J9k...==
The shell version is the slowest — PBKDF2 with 600k iterations takes ~1s per call in pure openssl. Fine for occasional CI use, terrible for bulk rotation. Use a real language for anything more than a few values.