Production

Virtual environments (encrypted JSON feed)

Consume an environment directly as a self-describing, encrypted JSON export from a public, branded URL.

Virtual environments let a consumer read an environment without the CLI, SDK, or an API key, by fetching a self-describing encrypted JSON export from a public, branded URL and decrypting it with the workspace passphrase. It's the right fit for tools, scripts, and languages where installing @goenvless/cli or @goenvless/env isn't practical, but you still want the zero-knowledge guarantee.

The feed is ciphertext only. It never carries a plaintext value, the workspace key, or the passphrase, so a public URL is not the same thing as a public value. What it does mean is that the encryption is the only thing left protecting the data: anyone who holds the workspace passphrase or key can decrypt an exposed environment, and so can anyone who guesses it offline. Expose an environment only when the passphrase is strong and closely held. See Choosing a passphrase, and if the current one was chosen by a human rather than generated, rotate it to something from the passphrase generator before you expose anything.

Enable it (owner or admin)

Turn on Virtual Envs for the workspace

A member with the workspace:update permission, which owners and admins hold by default, opens Settings → Virtual Envs and enables the feature. Off by default. Turning it back off is not just a flag flip: the same write unexposes every exposed environment in the workspace, and the response says how many. The CLI does the same write with envless workspace virtual-envs, and envless workspace virtual-envs --off to reverse it.

Expose specific environments

With the feature on, each environment gets an Expose environment toggle, which takes the environments:update permission. Flip it only for the environments you want served as a public encrypted feed. It's opt-in per environment, not all-or-nothing. From the CLI it is envless env expose <env>, and envless env expose <env> --off to make it private again.

An environment inside a private product can never be exposed: the write is refused with 400 and A private product's environment cannot be exposed publicly. Privacy on the product wins over the virtual-env feature. Exposing an environment while the workspace toggle is still off is refused the same way, with Enable Virtual Envs for this workspace first.

Exposing is a visible act, by design. Turning the toggle on emails every owner and admin in the workspace, apart from whoever flipped it, so nobody can quietly publish an environment feed. Ordinary members are not emailed. It also emits an environment.exposed workspace event, and environment.unexposed when it goes back off, which means a webhook endpoint gives you a durable record of every time the state changed and who changed it.

The URL shape

Exposed environments are served under a branded, slug-based path built from your hierarchy (workspace → product → project → environment):

plaintext
GET https://api.envless.cloud/exposed/<workspace-slug>/<product-slug>/<project-slug>/<environment-slug>/versions/latest
GET https://api.envless.cloud/exposed/<workspace-slug>/<product-slug>/<project-slug>/<environment-slug>/versions/<versionId>
GET https://api.envless.cloud/exposed/<workspace-slug>/<product-slug>/<project-slug>/<environment-slug>/versions
PathReturns
…/versions/latestThe newest published version, as an encrypted export
…/versions/<versionId>A specific pinned published version, as an encrypted export. Takes the version uuid, or its label such as v13. On the Free plan only the latest version can be pinned; an older id or label answers 402 plan.upgrade_required with error.resource: "version"
…/versionsA list of versions: metadata only, no ciphertext. data holds environment (slug and display name), versions (id, label, note, variableCount, createdAt), pagination, plus historyLimited, historyLimit and hiddenCount. On the Free plan the list holds only the latest version and hiddenCount says how many older ones exist. Paginated with ?limit= (default 25, capped at 100) and ?offset=

Pin a versionId when you want reproducible builds, and pin the uuid rather than the label: a label is derived from how many versions the environment has, v{count + 1} at publish time, so it describes a position rather than a fixed snapshot. Use latest when you always want the current published values.

On your own domain

A workspace with an active custom domain serves the same feeds from its own hostname. The workspace is read from the hostname, so its slug drops out of the path:

plaintext
GET https://vars.acme.com/exposed/<product-slug>/<project-slug>/<environment-slug>/versions/latest

Both hostnames serve at the same time, and the long form keeps working on your own domain when the workspace slug matches. A custom hostname is there for the feed, /exposed/* is the only surface meant to answer on it, and the rest of the API stays on api.envless.cloud. Add the DNS records with proxying off, the grey cloud on Cloudflare rather than the orange one, or your own zone answers the request and the feed never loads.

Fetch it

The endpoint is public, so there's no Authorization header. It's a plain GET:

bash
curl https://api.envless.cloud/exposed/acme/payments/billing-api/production/versions/latest

These routes use the same response envelope as the rest of the API, so the export is the data object, not the whole body. Read data, not the top level:

json
{
    "success": true,
    "code": 200,
    "message": "OK",
    "error": null,
    "data": { "format": "envless.encrypted-export/v1", "…": "…" },
    "_req": { "reqId": "0198…", "resTime": 12 }
}

A path that does not resolve answers 404 with one identical message, Not found, or this environment is not exposed., whatever the reason: a wrong slug, a deleted project, a private product, an environment with the toggle off, or a workspace with the feature off. Probing the URL space therefore tells an attacker nothing. The two exceptions are an environment that does resolve but has never been published, where latest answers 404 with This environment has no published version yet., and a Free workspace pinning a version older than its latest, which answers 402 plan.upgrade_required rather than 404.

Feeds are rate limited per client address: more than 600 requests in a minute from one address answers 429 with request.rate_limited and a Retry-After header, before anything is looked up. It bounds floods, not access: a client that hits it should wait the number of seconds in Retry-After and try again.

Response schema: envless.encrypted-export/v1

The bundle describes its own crypto: it carries the encrypted values plus the KDF and cipher parameters you need to reproduce the key, PBKDF2 salt included. A consumer therefore needs nothing but the passphrase, and no lookup of the workspace unique id. The flip side is that the response alone is enough to start an offline guessing attack, which is why the passphrase is the whole perimeter here.

json
{
    "format": "envless.encrypted-export/v1",
    "workspace": { "slug": "acme" },
    "product": { "slug": "payments" },
    "project": { "slug": "billing-api" },
    "environment": { "slug": "production" },
    "version": {
        "id": "0192b7a1-4c62-7d19-8f03-2a6b5c4e9d71",
        "label": "v13",
        "note": "Before the Stripe migration",
        "variableCount": 2,
        "createdAt": "2026-07-22T10:00:00.000Z"
    },
    "decryption": {
        "note": "Derive the AES-GCM key from your workspace passphrase with the kdf below, then decrypt each value. Values prefixed `v2:` carry their own iteration count.",
        "kdf": {
            "algorithm": "PBKDF2",
            "hash": "SHA-256",
            "iterations": 200000,
            "keyLength": 256,
            "salt": "8Zt1nQ4rF2kXbY7uPq0dLw=="
        },
        "cipher": {
            "algorithm": "AES-GCM",
            "ivLength": 12
        },
        "ciphertextFormat": "v1:base64(iv|ciphertext)  |  v2:<iterations>:base64(iv|ciphertext)"
    },
    "variables": [
        {
            "name": "DATABASE_URL",
            "type": "string",
            "visibility": "server",
            "required": true,
            "value": "v1:iVBORw0KGgoAAAANSUhEUgAA…"
        },
        {
            "name": "PORT",
            "type": "number",
            "visibility": "server",
            "required": false,
            "value": "v1:R0lGODlhAQABAAAAACw…"
        }
    ]
}
FieldMeaning
formatAlways envless.encrypted-export/v1, check it before parsing
workspace / product / project / environmentSlugs only, the same ones in the URL. No unique ids, no display names
versionWhich published snapshot you are reading, so a build can log or pin it. version.id is the only unique id in the bundle
decryption.kdfPBKDF2 / SHA-256 / 200000 / 256, the key-derivation parameters, plus salt. iterations is the default that applies to v1: values only, a v2: value overrides it. salt is the first 16 bytes of SHA-256("envless:workspace:" + workspaceUniqueId), base64 encoded, not the whole 32-byte digest
decryption.cipherAES-GCM with a 12-byte IV
decryption.ciphertextFormatThe envelope layout, in one line, for a parser that wants it spelled out
variables[].nameThe variable name, in the clear. Names are not secret, values are
variables[].valueThe encrypted envelope: v1:base64(iv‖ciphertext) (or v2:<iterations>:base64(iv‖ciphertext))
variables[].type / visibility / requiredPer-variable metadata, recorded and served, never applied. Every value decrypts to a string, so coercion is yours to do: @goenvless/env reads number as Number(value) and boolean as value === 'true' || value === '1'. Nothing anywhere enforces required, and visibility is not filtered on this route, a server variable is in the feed like any other. Those five fields are the entire per-variable payload, so a variable's stored default value never reaches the feed

Each value is the same ciphertext envelope used everywhere in Envless: the 12-byte IV is prepended to the AES-GCM output (ciphertext + auth tag), the whole thing base64-encoded and version-prefixed. The v1 iteration count is implicitly 200000; v2 states it inline.

Decrypt it

Derive the AES-256 key from the workspace passphrase using the KDF params in the bundle, salt included, then AES-GCM-decrypt each value. The passphrase, or the raw key, is the only thing the consumer brings.

decryption.kdf.iterations is a bundle-level default, not a per-value fact. It is the count for every v1: value, but a v2: value pins its own count inline and that count wins. A bundle can therefore mix costs, and one key derived from the bundle-level number decrypts only the values that happen to match it. Read the count off each envelope and derive one key per distinct count.

import crypto from 'node:crypto';

const passphrase = process.env.ENVLESS_PASSPHRASE;

const res = await fetch(
    'https://api.envless.cloud/exposed/acme/payments/billing-api/production/versions/latest'
);

// 1. the export is the `data` object of the envelope, never the whole body
const body = await res.json();
if (!res.ok || !body.success) throw new Error(body.message);

const bundle = body.data;
if (bundle.format !== 'envless.encrypted-export/v1') throw new Error('unexpected format');

// 2. the salt is served, base64 of 16 bytes. Nothing to look up, nothing to derive.
const salt = Buffer.from(bundle.decryption.kdf.salt, 'base64');

// 3. one key per distinct iteration count, cached. PBKDF2 is expensive on purpose,
//    so never derive it twice for the same count.
const keys = new Map();
const keyFor = (iterations) => {
    if (!keys.has(iterations)) {
        keys.set(iterations, crypto.pbkdf2Sync(passphrase, salt, iterations, 32, 'sha256'));
    }
    return keys.get(iterations);
};

// 4. read the cost off the envelope. v1 has none, so it uses the bundle default.
//    v2 states its own between 100000 and 1000000, and that is the one that applies.
function parseEnvelope(envelope) {
    if (envelope.startsWith('v2:')) {
        const rest = envelope.slice(3);
        const separator = rest.indexOf(':');
        if (separator === -1) throw new Error('unsupported variable format');

        const iterations = Number(rest.slice(0, separator));
        if (!Number.isInteger(iterations)) throw new Error('unsupported variable format');
        if (iterations < 100000 || iterations > 1000000) throw new Error('unsupported variable format');

        return { payload: rest.slice(separator + 1), iterations };
    }

    if (envelope.startsWith('v1:')) {
        return { payload: envelope.slice(3), iterations: bundle.decryption.kdf.iterations };
    }

    throw new Error('unsupported variable format');
}

// 5. decrypt each envelope: iv (12) ‖ ciphertext ‖ auth tag (16)
function decryptValue(envelope) {
    const { payload: encoded, iterations } = parseEnvelope(envelope);
    const payload = Buffer.from(encoded, 'base64');
    const iv = payload.subarray(0, 12);
    const tag = payload.subarray(payload.length - 16);
    const ct = payload.subarray(12, payload.length - 16);
    const d = crypto.createDecipheriv('aes-256-gcm', keyFor(iterations), iv);
    d.setAuthTag(tag);
    return Buffer.concat([d.update(ct), d.final()]).toString('utf8');
}

const env = Object.fromEntries(
    bundle.variables.map((v) => [v.name, decryptValue(v.value)])
);

Decrypting the feed yourself hands you a plain record, and nothing filters it. @goenvless/env and the envless CLI both refuse to write a decrypted value into a process environment when its name would change how code executes, twenty-six of them: NODE_OPTIONS, NODE_PATH, NODE_REPL_EXTERNAL_MODULE, ELECTRON_RUN_AS_NODE, LD_PRELOAD, LD_LIBRARY_PATH, LD_AUDIT, DYLD_INSERT_LIBRARIES, DYLD_LIBRARY_PATH, DYLD_FRAMEWORK_PATH, PATH, BUN_INSPECT, BUN_INSPECT_CONNECT_TO, RUBYOPT, RUBYLIB, BUNDLE_GEMFILE, GEM_HOME, GEM_PATH, PYTHONPATH, PYTHONHOME, PYTHONSTARTUP, PYTHONUSERBASE, PHPRC, PHP_INI_SCAN_DIR, the ENVLESS_CLIENT_ENV channel and the ENVLESS_INJECTED manifest. Copying the record straight into process.env skips that check, so apply the same refusal list yourself if the values are headed for a process environment rather than staying in a variable you read.

If you provide ENVLESS_KEY (the raw base64url AES-256 key from the CI / Machine Access page) instead of a passphrase, skip steps 2 and 3 and decrypt directly with that key, imported raw as 32 bytes. No salt, no PBKDF2 pass, and nothing derived from a human-chosen string at read time, which makes it the better credential for an unattended consumer. From @goenvless/env/crypto that is importWorkspaceKey(material) to turn the base64url string into a CryptoKey, then decryptWithKey(key, envelope) per value. importWorkspaceKey rejects anything that does not decode to exactly 32 bytes.

A raw key does not sidestep a pinned cost. ENVLESS_KEY is exported as the key your passphrase derives at 200,000 iterations, so it opens every v1: value and a v2:200000: value, but a v2: value pinned at any other count was encrypted under a key derived at that other count and is not openable with it. decryptWithKey() refuses rather than returning garbage: This value pins <count> PBKDF2 iterations, which the supplied key was not derived with. Decrypt it with the passphrase instead, so the key is derived at the cost the value was written with. Use the passphrase for those values.

Security model

PropertyDetail
URL is not a secretIt's built from public slugs and is therefore guessable. Treat it as public.
Two gates protect itThe deliberate opt-in (workspace toggle + per-environment expose) and the encryption itself. Nothing else. No token and no allowlist; the per-address rate limit only bounds floods.
Ciphertext onlyThe response never contains plaintext, the raw key, or the passphrase, only encrypted values and the params to decrypt them.
Slugs, not idsIn the export bundle, workspace, product, project and environment appear as slugs only, with no unique ids and no display names. version.id is the one unique id there, because pinning a build needs it. The …/versions list route is the single exception: it also returns the environment's display name. The variable names are in the clear on both, since a consumer needs them to build an env.
Salt is serveddecryption.kdf.salt carries the PBKDF2 salt, so the bundle is decryptable from the passphrase alone. The salt was never a secret, it exists to stop one rainbow table covering every workspace, but serving it does mean the response is a complete offline guessing target.
Private products refusedAn environment in a private product cannot be exposed, and a product turned private later stops resolving on this route.
Passphrase strength is the perimeterOnce the feed is public, the passphrase (or key) is the only thing left. It is stretched with PBKDF2 at 200,000 iterations, which slows guessing but does not fix a weak passphrase, so the enforced 16 character floor is a floor and not a target. See Choosing a passphrase.
Passphrase = accessAnyone with the passphrase (or derived key) can decrypt an exposed environment. Rotate the passphrase to cut off access; unexpose the environment to remove the feed entirely, or turn the workspace toggle off, which unexposes every environment in the workspace at once.

A guessable URL plus the passphrase is enough to read every value in the environment, so exposing an environment converts your passphrase from one of several defences into the only one. Before you flip the toggle, make sure the passphrase would survive an offline attack on the ciphertext, and rotate it first if you are not sure. Unexpose the environment the moment it no longer needs a public feed.