The SDK authenticates with an API key and nothing else. There is no login flow, no device authorisation, no browser round-trip, and no dependency on the CLI. Pass a key, or set ENVLESS_TOKEN, and you are done, which is exactly what makes it usable from CI, a cron job, a Lambda or a provisioning script.
import { createClient } from '@goenvless/sdk'
const envless = createClient({ token: 'ev_sk_...' })The envless binary, which ships as @goenvless/cli and exposes nothing importable, has its own device-based authentication for interactive human use. The two are independent: an API key never needs the CLI, and the CLI's device session is not something the SDK can consume. See CLI, runtime and SDK.
A key is bound to one workspace
A key is bound to a single workspace at creation time, permanently. The workspace comes from the key, never from the request, so there is no header or argument that points a key at a different one.
const identity = await envless.me.get()
identity.workspaceId // the only workspace this key will ever touchenvless.workspaces.list() is the single exception to that boundary: it resolves the key owner's memberships, so it can name workspaces the key cannot act on. current: true marks the bound one.
const all = await envless.workspaces.listAll()
for (const workspace of all) {
console.log(workspace.slug, workspace.current ? '(this key)' : '(needs its own key)')
}envless.workspaces.create() succeeds but the new workspace is not reachable with the key that created it. Mint a key inside it before you can add anything.
Scopes
Every endpoint except me.get() requires a scope. A scope is a resource and a level:
| Resource | read grants | write grants |
|---|---|---|
workspace | Read the workspace and its domain | Rename, toggle Virtual Envs, manage the domain, delete |
products | List and read products | Create, update, delete, duplicate |
product-access | Read per-product grants | Grant and revoke them |
projects | List and read projects | Create, update, delete, duplicate |
environments | List and read environments | Create, update, delete, duplicate, expose |
variables | Read variables and history | Create, update, delete, apply |
versions | List versions and entries | Publish, roll back, re-encrypt entries |
members | List members and invites | Invite, re-role, remove, leave |
roles | Read roles and permissions | Create, update, delete custom roles |
keys | List and read keys | Mint and revoke keys |
webhooks | Read endpoints and deliveries | Create, update, rotate, redeliver |
A missing scope is 403 auth.scope_missing, and error.resource names exactly what was needed:
import { EnvlessApiError } from '@goenvless/sdk'
try {
await envless.variables.create('billing', 'production', { name: 'A', value: ciphertext })
}
catch (error) {
if (error instanceof EnvlessApiError && error.isScopeMissing) {
console.error(`This key needs ${error.resource}`)
}
}Some methods need two scopes because they touch two resources. versions.rollback rewrites variables, so it needs versions:write and variables:write. Each method page lists its own requirement.
Scopes are clamped twice
A key never carries more authority than the person who created it.
Against the minting key
A key cannot mint a key with scopes it does not itself hold. That attempt is 403 auth.scope_ceiling_exceeded.
Against the owner role
What survives is clamped again to the workspace role of the key's owner. A viewer cannot mint a writing key even from a key that holds variables:write.
So read the response back
keys.create() returns the scopes you were actually granted, which may be less than you asked for. Never assume the request body was honoured.
const key = await envless.keys.create({
name: 'ci-deploy',
scopes: { variables: 'read', versions: 'read' }
})
console.log(key.secret) // shown once, unrecoverable
console.log(key.scopes) // what you were actually grantedProject fences
A key can be fenced to specific projects with projectIds. Any request touching a project outside the fence fails with 403 auth.key_project_restricted.
const key = await envless.keys.create({
name: 'billing-only',
scopes: { variables: 'read' },
projectIds: [billing.id]
})An empty projectIds array means the whole workspace. Private products add a second, independent gate: the key owner also needs a grant on the product, or the call is 403 auth.product_restricted.
The fence is not only a filter. Several workspace-level surfaces are closed to a fenced key outright, whatever scope it holds, because none of them can be expressed as a subset of projects:
A fenced key cannot read the key list. keys.list(), keys.get(), keys.update() and keys.delete() all return 403 auth.key_project_restricted. keys.create() is the only key method it keeps, and there projectIds becomes mandatory and must be a non empty subset of the caller's own fence.
The same refusal applies to every webhooks method, to every products write (create, update, delete, duplicate), to both products.access writes (grant, revoke), and to projects.create() and projects.duplicate(), since a newly created project could not be covered by the fence.
What a fenced key still reads at workspace level is me, workspace, workspaces, workspace.domain, members, invites, roles, and the product reads (products.list(), products.get(), products.access.list()). Those carry no project dimension, so there is nothing for the fence to narrow. projects.list() and products.listProjects() are narrowed rather than closed: they return only the fenced projects.
The scope check runs before the fence check, so a fenced key missing keys:read gets 403 auth.scope_missing on keys.list() and a fenced key holding it gets 403 auth.key_project_restricted. Adding the scope does not open the route.
Rotation
There is deliberately no rotate method for API keys. Rotation is create, cut over, delete. Copy projectIds along with scopes: leave it out and the replacement reaches the whole workspace, not just the projects the old key was fenced to.
const replacement = await envless.keys.create({
name: 'ci-deploy-2026-07',
scopes: current.scopes,
projectIds: current.projectIds
})
// deploy replacement.secret to your runner, confirm it works, then
await envless.keys.delete(current.id)Deleting a key takes effect immediately with no grace period. Confirm the replacement works before you delete the old one.
Environment variables
| Variable | Read by | Used for |
|---|---|---|
ENVLESS_TOKEN | createClient() | The API key createClient() falls back to when no token is passed |
ENVLESS_API_ENDPOINT | createClient() | Overrides the base URL, for a self-hosted or local instance |
ENVLESS_DISABLE_UPDATE_NOTICE | Every client construction | Any non empty value silences the one-time check for a newer published version |
Those three are the whole list. The first two are read inside createClient() alone, so new Envless({ token }) skips them and takes its token and base URL from the arguments only. ENVLESS_DISABLE_UPDATE_NOTICE is read by the transport, which every client goes through, so new Envless(...) reads it too. Passing disableUpdateNotice: true does the same thing without an environment variable.
All three reads are guarded on process existing, so in a Worker none of them happen. The update notice is skipped there for a second reason as well: it only ever prints when stdout is a TTY.