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/envpnpm add @goenvless/envyarn add @goenvless/envbun add @goenvless/envimport { 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:
| 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. 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_PARAMETERS | constants object | The 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)
| 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>, the ciphertext in v1:<base64(iv || ciphertext)> format.
decrypt(ciphertext, passphrase, workspaceUniqueId)
| Param | Type | Description |
|---|---|---|
ciphertext | string | A v1:... or v2:<iterations>:... string returned by the API |
passphrase | string | Workspace passphrase |
workspaceUniqueId | string | Workspace 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:
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:
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
| Symptom | Cause |
|---|---|
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 CLI | Wrong 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 it | Used a different IV length or skipped the IV in the ciphertext concatenation |
Server returns 400 with variable.ciphertext_invalid | The 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:
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.
| Step | Detail |
|---|---|
| 1. Derive the salt | SHA-256("envless:workspace:" + workspaceUniqueId), take the first 16 bytes |
| 2. Derive the key | PBKDF2(passphrase, salt, 200000 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 | 200,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/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.
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)}`
}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.
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) =>
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)}`
}The Python SDK already implements this recipe, byte for byte: pip install envless-sdk, then encrypt_value, decrypt_value, derive_workspace_key, encrypt_with_key and decrypt_with_key replace everything below. See Encryption in the Python docs. The standalone version needs only cryptography:
pip install cryptographyimport 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 = 200000
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 = 200000
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/pbkdf2Add 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 = 200000;
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)))
}The PHP SDK already implements this recipe, byte for byte: composer require envless/sdk, then Envless::encryptValue(), Envless::decryptValue(), Envless::deriveWorkspaceKey(), Envless::encryptWithKey() and Envless::decryptWithKey() replace everything below. See Encryption in the PHP docs. The standalone version runs on PHP 7.1+ with the OpenSSL extension, enabled by default in most installs.
<?php
const PBKDF2_ITERATIONS = 200000;
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);
}The Ruby SDK already implements this recipe, byte for byte: bundle add envless-sdk, then Envless.encrypt_value, Envless.decrypt_value, Envless.derive_workspace_key, Envless.encrypt_with_key and Envless.decrypt_with_key replace everything below. See Encryption in the Ruby docs. The standalone version needs only the standard library: openssl ships with Ruby.
require 'openssl'
PBKDF2_ITERATIONS = 200000
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 + [iv + ct + tag].pack('m0')
endJava 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 = 200000;
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);
}
}Command-line encryption for shell scripts, CI snippets, or one-off rotations. openssl handles the salt and the key derivation. It cannot do the seal: openssl enc answers enc: AEAD ciphers not supported, so AES-GCM never reaches the pipe. Hand that step to python3 instead.
pip install cryptography#!/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 (200k iterations, SHA-256)
KEY_HEX=$(openssl kdf -keylen 32 -kdfopt digest:SHA256 \
-kdfopt iter:200000 \
-kdfopt hexsalt:"$SALT_HEX" \
-kdfopt pass:"$PASSPHRASE" \
-binary PBKDF2 \
| xxd -p -c 64)
# 3. random 12-byte IV, AES-256-GCM seal, concat iv || ciphertext, base64, prepend v1:
KEY_HEX="$KEY_HEX" PLAINTEXT="$PLAINTEXT" python3 - <<'PY'
import base64, os
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
key = bytes.fromhex(os.environ['KEY_HEX'])
iv = os.urandom(12)
ciphertext = AESGCM(key).encrypt(iv, os.environ['PLAINTEXT'].encode(), None)
print('v1:' + base64.b64encode(iv + ciphertext).decode())
PY$ ./encrypt.sh 'postgres://...' "$ENVLESS_PASSPHRASE" "$WORKSPACE_ID"
v1:5J9k...==The shell version is the slowest: PBKDF2 with 200k iterations takes roughly 200 milliseconds per call in openssl, and every value pays it again because nothing caches the derived key across processes. Fine for occasional CI use, terrible for bulk rotation. Use a real language for anything more than a few values.