CLI Authentication

The leed CLI does not use an API key. It holds a session — the same kind of session your browser holds, issued to your account, expiring on its own schedule — and it stores that session in a file on your machine. The installer creates it the first time; this page covers everything after that.

Three separate credentials are involved, and they expire at different times for different reasons. Being “logged in” and being able to git fetch are not the same state, which is the single most common source of confusion here — see three credentials, three lifetimes.

Signing in

leed login

The CLI walks a device-authorization flow, which means your terminal never sees your password and never opens an input box:

  1. It prints the environment, a verification code formatted AB12-CD34, and the approval URL — always, before it tries anything else.
  2. Where the machine has a display, it also opens your browser at that URL with the code already filled in.
  3. You confirm the code matches what your terminal shows, and approve.
  4. The CLI notices within a few seconds and writes the session to disk.

Codes are drawn from an alphabet with no I, L, O, 0 or 1, so there is no character you can misread. The code is valid for five minutes, and the CLI polls every five seconds until it is approved or expires. If it expires, run the command again.

The device authorization screen, with the code from your terminal filled in and the Authorize button ready

On a machine with no browser to open — a remote shell, a container, a Linux box with neither an X nor a Wayland display — the CLI does not try; use the URL it already printed. Open it anywhere you are already signed in to the CMS; the approval does not have to happen on the same machine as the terminal.

sequenceDiagram
    autonumber
    actor You
    participant CLI as leed CLI
    participant CMS as Leed CMS
    participant Browser

    CLI->>CMS: POST /api/auth/device/authorize
    CMS-->>CLI: deviceCode, userCode, verificationUri, expiresIn 300, interval 5
    CLI-->>You: Your verification code is: AB12-CD34<br/>Approve it here: <url>
    opt the machine has a display
        CLI->>Browser: open the approval page with the code prefilled
    end
    loop every 5 seconds, for up to 5 minutes
        CLI->>CMS: POST /api/auth/device/token
        CMS-->>CLI: 428 authorization_pending
    end
    You->>Browser: check the code and approve
    Browser->>CMS: POST /api/auth/device/approve
    CLI->>CMS: POST /api/auth/device/token
    CMS-->>CLI: accessToken, expiry, userId, email, name, company
    CLI-->>You: Authenticated as … — session written to auth.json

The browser half of that exchange is documented from the CMS side in authorizing devices and clients, which is also where you go to see and revoke what you have approved.

leed auth login --email you@example.com

This sends a sign-in link to your inbox and then tells you to run the device flow anyway:

Check your email and click the sign-in link.
After clicking the link, run: leed auth login --browser --env production

Omit the address (leed auth login --email) and the CLI asks for it.

Choosing an environment

Credentials are stored per environment, and every authenticated command resolves one before it does anything. Most people never think about this because the default is right; anyone working against a staging site needs to.

SourceExampleWins overNotes
--env flagleed auth login --env stagingeverythingAccepts production, staging or dev. On auth login, logout and default, and on leed asset upload, it is hidden from --help. The leed settings, page, deployments and contacts commands, and leed asset list, get, set and export, carry the same flag and show it
ENV_NAME environment variableENV_NAME=staging leed auth statusconfig file, defaultRead directly by the same option
leed.config.json"environment": "staging"the defaultSearched in the current directory, its parent and its grandparent
Defaultproduction—leed site data commands refuse this fallback rather than guessing

That last row matters. leed site push, commit and list, and the leed import commands, will not silently assume production. Run one outside a site directory with no ENV_NAME set and you get:

Could not determine the target environment. Run from inside a leed-managed site
directory (leed.config.json) or set ENV_NAME=dev|staging|production.

Choosing a company

Within an environment, sessions are also stored per company, so one machine can stay signed in to several companies at once. Each session is bound to its company at login, and a command picks one like this:

  • --company <companyId|domain> on leed auth login and logout, when you pass it. leed auth default takes the company as a positional argument instead.
  • Inside a site repository, the companyId in that repository’s leed.config.json — never another company’s session.
  • Outside one, the environment’s default company: the first company you sign in to, changed with leed auth default <companyId|domain>. Run leed auth default with no argument to see the current one.

A domain is only accepted for a company you already hold a session for. To sign in to a company for the first time, pass its companyId. A login that names no company lets the approval page choose: it shows a company picker, and the session is bound to the company you pick. A company that has been deleted is never offered, and a login that names one is refused at approval.

Where credentials live

~/.config/leed/auth.json

The directory is created 0700 and the file 0600 — owner-only, both. Set XDG_CONFIG_HOME and the whole leed/ directory moves with it.

The file is a map keyed by environment, then by company, so one file can hold a production session and a staging session — and sessions for several companies in each — side by side without any of them overwriting another. The raw-content repository token for each company is filed in the same record:

{
  "version": 2,
  "credentials": {
    "production": {
      "<companyId>": {
        "accessToken": "<redacted>",
        "refreshToken": "",
        "tokenExpiresAt": "2026-09-09T12:00:00.000Z",
        "email": "you@example.com",
        "userId": "usr_…",
        "companyId": "<companyId>",
        "domain": "example.com",
        "rawContentToken": "<redacted>",
        "rawContentTokenExpiresAt": "2026-12-01T00:00:00.000Z"
      }
    },
    "staging": { /* … */ }
  },
  "defaults": { "production": "<companyId>" },
  "legacy": {}
}

refreshToken is reserved and always empty today; refreshing works off the access token itself.

A credentials file that cannot be parsed is reported as an error — “The credential file … is unreadable.” — rather than read as “not logged in”, so the next login can never silently overwrite it. Fix or remove the file, then run leed auth login. A credentials.json written by an older CLI is migrated into auth.json automatically and left untouched; a session whose company cannot be resolved is kept as an unassigned legacy session and used only when no company is known at all.

Staying signed in

A CLI session lasts 90 days from the last time you used it. You do not renew it yourself: every leed command that calls the CMS checks the stored session through one shared path, and that check is what keeps it alive.

Renewal slides. Until a session is halfway through its lifetime — 45 days, for a fresh one — the check hands back the same token and nothing on disk changes. Past half-life the next check rotates it: the CMS issues a new token good for another 90 days, and the CLI writes it into auth.json. A session you use at least once in any 90-day stretch therefore never expires. Browser sessions are separate and still last seven days.

Parallel commands share one session. The check runs under a lock on auth.json — a lock file created next to it — so when several leed processes start at once, one asks the CMS while the rest wait, re-read the file, and use whatever token it now holds. A token that has just been replaced keeps working for 60 seconds, so a command that read the old token a moment before another process rotated it, or one interrupted between the CMS rotating its token and the new token reaching disk, recovers on its next call instead of asking you to log in. The leed site pre-action and the leed import commands run this check before the command starts and only warn if it fails, because plenty of what follows may not need the token at all.

One case the window does not cover: a long-running command, such as a bulk download, holds its token in memory. If another process rotates the session while it runs — at most once every 45 days — that command’s requests start failing a minute later. Run it again; it picks up the new token.

A hard revalidation runs in the auth guard of leed site push and the other commands that act on CMS data, and it is not the same thing. It asks the CMS about the session even when the local expiry looks healthy, and if that check fails, an interactive run drops straight into a device login mid-command.

That second one looks paranoid until you consider what it prevents. A session can be revoked server-side — you signed out everywhere, an administrator removed your access — while the copy on your disk still looks perfectly valid. Git would accept the push using its own separate credentials, and then the CMS notification would be refused. Your commits would be on the remote with no deployment created and nothing building, and that notification cannot be replayed by pushing again. Checking first is cheaper than that outcome. This is why leed site push can ask you to log in halfway through.

A refresh works for up to 24 hours past expiry. Inside that grace window a stale session is quietly renewed; past it the server answers token_expired_beyond_grace and you sign in again.

The renewal endpoint, /api/auth/cli/refresh, is rate-limited to 30 requests a minute per token and 120 a minute per IP address. Each process asks it once and reuses the answer for a minute, but many processes at once, or several agents behind one IP address, can still reach the limit. A 429 says nothing about your session, so the CLI reports it as a network failure — network_error under --json — rather than telling you to log in again. Wait a minute and retry.

Ending a session ends it at once. leed auth logout, leed auth reset, and revoking the session from another device in sessions, connected accounts and API tokens each end it immediately — including a previous token that is still inside its 60-second window.

Checking your status

leed auth status

Outside a site directory it lists every session you hold, grouped by environment, one line per company, with the environment’s default company marked ★ default. Inside one, it also reports the two GitLab tokens that back that site, read live from the CMS with a five-second timeout:

  Credentials: /home/you/.config/leed/auth.json
  production environment:
    ✓ example.com (<companyId>): you@example.com (expires Sep 9, 2026) ★ default
  staging environment:
    ✓ example.com (<companyId>): you@example.com (expires Sep 8, 2026) ★ default

  GitLab tokens for example.com (staging):
  ✓ package registry:  expires Nov 4, 2026
  ⚠ raw-content:       expires in 5 days (Sep 7, 2026)

The credentials path is printed only when a non-production environment is in play — that is exactly when knowing which file to look at is useful.

Inside a site directory, leed auth status first renews that site’s session the same way any other command would, so the expiry it shows is current — about 90 days out for a session you are using. If the CMS cannot be reached, that renewal can hold the listing up for about ten seconds before it prints.

SymbolMeaningThreshold
✓ValidMore than 7 days remaining
⚠Expiring7 days or fewer remaining
✗ExpiredPast its expiry
?UnknownNo expiry recorded, or one this CLI cannot parse

Three failures replace the token rows rather than adding to them. “Session expired. Run: leed auth login --env staging” means the CMS rejected your session. “No developer access for this organization” means you are signed in and simply not allowed — a new login will not help, only an administrator can. Anything else prints “Could not reach …” with the transport error.

If both tokens come back empty you get “(no tokens stored yet — run ‘leed auth rotate’ or rerun the installer)”.

Three credentials, three lifetimes

This is the section that resolves most confusion on this page. “Logged in” describes only the first row.

CredentialWhere it livesLifetimeRotated bySymptom when stale
CMS session token~/.config/leed/auth.json, per environment and company90 days from last use, renewed automatically past half-life; 24-hour graceleed auth login, the automatic renewal“Session expired”; commands that talk to the CMS refuse
GitLab npm registry token~/.bunfig.toml, under [install.scopes]Months; leed auth status warns at 7 daysleed auth rotate, leed auth reset, the installerCLI updates and bun install fail with a registry authorization error
GitLab raw-content token~/.config/leed/auth.json, filed under the site’s companyMonths; warned at 7 daysleed auth rotate, leed site initgit fetch and leed site push fail to authenticate, while leed auth status still shows you signed in

The two GitLab tokens are written into files described in leed.config.json, files and environment, and the git side of the raw-content token — why a fetch can fail while you are demonstrably signed in — is covered in git workflow.

Rotating and resetting

Two commands, and the difference is whether your CMS session survives.

leed auth rotate

Run from inside your site directory. It mints a fresh pair of GitLab tokens for that site, rewrites the @leed entry in ~/.bunfig.toml, and stores the new raw-content token in ~/.config/leed/auth.json. It does not touch your CMS session — the command says so when it finishes: “Your CMS login session was not touched. No re-authentication needed.” The raw-content token is stored per company, so one rotation covers every checkout of that company on this machine; run it again in a site of each other company you develop for.

Reach for it when leed auth status shows a ⚠ or ✗ on either token row, when git fetch starts failing to authenticate, or as routine hygiene.

leed auth reset

The nuclear option, for a lost laptop or a credential you believe has leaked. It rotates the GitLab tokens first — while the session is still valid, because revoking first would make the rotation fail — then revokes your CMS session and deletes the stored credentials for this site’s company. The raw-content token lives in that same record, so it goes too: afterwards run leed auth login and then leed auth rotate before you push or pull content again; the command tells you so. Add --all-sessions to revoke your sessions on every other device too, and --all-companies to sign out of every other company stored for the environment.

Flag-level detail for all six auth commands is on leed auth.

Signing out

leed auth logout

A best-effort server-side sign-out followed by deletion of the local session for one company — the site repository’s, the one named with --company, or the environment’s default. --all-companies signs out of every company stored for the environment. If the CMS is unreachable the local session is still cleared, so a machine you no longer control cannot keep using a cached token offline. The company’s raw-content token is kept: other checkouts, CI runners and teammates share it, and only leed auth reset discards it. With nothing stored it simply prints “Not logged in to production.” and returns.

A sign-out the CMS receives ends the session immediately, together with any token it replaced less than a minute earlier, so a parallel command still holding the previous token cannot keep it alive.

CLI sessions appear alongside your browser sessions, tagged CLI or Installer, in sessions, connected accounts and API tokens — which is where you revoke one from the CMS side. A 403 anywhere on this page means your account lacks the developer override rather than a broken session; that is granted per team member, as described in billing and developer access.

leed auth is one of the CLI’s command groups; the whole tree, with every flag, is indexed at CLI command reference, and the messages any of these commands can produce are decoded in CLI troubleshooting and exit codes.

ESC