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, an Artisan command, a queue worker or a provisioning script.
use Envless\Envless;
$envless = new Envless(token: (string) getenv('ENVLESS_PROVISIONING_TOKEN'));
echo $envless->me->get()['workspaceId'], PHP_EOL;The envless binary, which ships as @goenvless/cli, 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.
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.
$envless->workspaces->list() is the single exception to that boundary: it reads the key owner's memberships, so it can name workspaces the key cannot act on. current set to true marks the bound one.
use Envless\Envless;
$envless = new Envless();
foreach ($envless->workspaces->iterate() as $workspace) {
$reach = $workspace['current'] ? 'this key' : 'needs its own key';
echo $workspace['slug'], ' (', $reach, ')', PHP_EOL;
}$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 resource on the exception names exactly what was needed:
use Envless\Envless;
use Envless\Exception\ApiException;
$envless = new Envless();
$passphrase = (string) getenv('ENVLESS_PASSPHRASE');
$workspaceId = $envless->me->get()['workspaceId'];
$flag = Envless::encryptValue('true', $passphrase, $workspaceId);
try {
$envless->variables->create('billing', 'production', [
'name' => 'FLAG',
'value' => $flag,
]);
} catch (ApiException $error) {
if (!$error->isScopeMissing()) {
throw $error;
}
echo 'This key needs ', $error->resource, PHP_EOL;
}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.
use Envless\Constants\ScopeLevels;
use Envless\Constants\ScopeResources;
use Envless\Envless;
$envless = new Envless();
$key = $envless->keys->create([
'name' => 'ci-deploy',
'scopes' => [
ScopeResources::VARIABLES => ScopeLevels::READ,
ScopeResources::VERSIONS => ScopeLevels::READ,
],
]);
echo $key['secret'], PHP_EOL;
foreach ($key['scopes'] as $resource => $level) {
echo $resource, ':', $level, PHP_EOL;
}secret is shown once and is unrecoverable, and scopes is what you were actually granted.
Project 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.
use Envless\Envless;
$envless = new Envless();
$billing = $envless->projects->get('billing');
$key = $envless->keys->create([
'name' => 'billing-only',
'scopes' => ['variables' => 'read'],
'projectIds' => [$billing['id']],
]);
echo $key['secret'], PHP_EOL;An empty projectIds list 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 fail with 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.
Rotation
There is deliberately no rotate method for API keys. Rotation is create, cut over, delete:
use Envless\Envless;
$envless = new Envless();
$current = $envless->keys->get('0191f3c2-8a71-7c3e-9b2d-4f5a6c7d8e90');
$replacement = $envless->keys->create([
'name' => 'ci-deploy-2026-10',
'scopes' => $current['scopes'],
]);
echo $replacement['secret'], PHP_EOL;
$envless->keys->delete($current['id']);Deleting a key takes effect immediately with no grace period. Deploy the replacement secret to your runner and confirm it works before you delete the old one.
Environment variables
| Variable | Read by | Used for |
|---|---|---|
ENVLESS_TOKEN | new Envless(), Envless::init() and Envless::getClient() | The API key used when no token: is passed |
ENVLESS_API_ENDPOINT | The same three | Overrides the base URL, for a self-hosted or local instance |
ENVLESS_DISABLE_UPDATE_NOTICE | Every client construction | Any non empty value silences the check for a newer version on Packagist |
Those three are the whole list, and an explicit argument always wins over the variable. Each one is read from $_ENV, then $_SERVER, then getenv(), so a value your framework's dotenv loader put in $_ENV counts too. This is one place the PHP SDK differs from TypeScript: there, new Envless() reads no environment at all.
The update check only runs on the command line, once per process and only when standard output is a terminal, so a web request never makes it. Passing disableUpdateNotice: true silences it without an environment variable.
A token is trimmed of surrounding whitespace, the way an HTTP header would be, and a token with a control character or anything outside visible ASCII is refused with Envless\Exception\InvalidArgumentException before any request is made. The error never echoes the token, and neither do var_dump() and print_r() of a client, which show [redacted] in its place.