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.
Two 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 two credentials, two lifetimes.
Signing in
leed loginThe CLI walks a device-authorization flow, which means your terminal never sees your password and never opens an input box:
- It prints the environment, a verification code formatted
AB12-CD34, and the approval URL — always, before it tries anything else. - Where the machine has a display, it also opens your browser at that URL with the code already filled in.
- You confirm the code matches what your terminal shows, and approve.
- 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.
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.
Magic-link fallback
leed auth login --email you@example.comThis 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 productionOmit 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. There is no default environment. Inside a site checkout the site’s own environment is used, so most people never think about this; outside one, you name it.
| Source | Example | Notes |
|---|---|---|
-e, --env flag | leed auth login -e staging | Accepts production, staging or dev. On auth login and logout, and on leed asset upload, it is hidden from --help. leed setup, leed site, leed mcp add, and the settings, page, deployments and contacts commands carry the same flag and show it |
| The site checkout you are in | its entry in ~/.config/leed/sites.json, else its identity.json | Wins over ENV_NAME: an exported variable is not a choice made for this command |
ENV_NAME environment variable | ENV_NAME=staging leed auth status | Used only when no flag and no checkout names one |
The one environment a --company is logged in to | leed settings label list --company example.com | A company logged in to no environment, or to several, is refused rather than guessed |
| Nothing | — | Refused with invalid_arguments and the command to rerun with -e <env> added. Nothing falls back to production |
That last row matters. No command silently assumes production. Run one outside any checkout with nothing naming an environment and you get a refusal that lists the sessions you hold and the exact command to rerun:
No environment named. Pass -e <env>, or run the command inside a site checkout. Logged in to:
example.com (<companyId>) — productionChoosing 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. There is no default company, and a command picks one like this:
--company <companyId|domain>, when you pass it.- Otherwise the site checkout you are in — its company, never another company’s session.
- Outside a checkout, nothing. A command that needs a company refuses with “No company selected for <env>.”, lists the companies you are logged in to, and names
--companyas the fix.leed auth logoutrefuses the same way whenever company sessions are stored.
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. The leed auth default command and the default company it set are gone; an auth.json that still records one is read, and the default is ignored.
Where credentials live
~/.config/leed/auth.jsonThe 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": { /* … */ }
},
"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 statusOutside a site checkout it lists every session you hold, grouped by environment, one line per company. Inside one, it also reports the GitLab token that backs that site’s content repository, 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)
staging environment:
✓ example.com (<companyId>): you@example.com (expires Sep 8, 2026)
GitLab tokens for example.com (staging):
⚠ 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.
| Symbol | Meaning | Threshold |
|---|---|---|
| ✓ | Valid | More than 7 days remaining |
| ⚠ | Expiring | 7 days or fewer remaining |
| ✗ | Expired | Past its expiry |
| ? | Unknown | No expiry recorded, or one this CLI cannot parse |
Three failures replace the token row rather than adding to it. “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 no token has been issued you get “(no tokens stored yet — run ‘leed auth rotate’ or rerun the installer)”.
Two credentials, two lifetimes
This is the section that resolves most confusion on this page. “Logged in” describes only the first row.
| Credential | Where it lives | Lifetime | Rotated by | Symptom when stale |
|---|---|---|---|---|
| CMS session token | ~/.config/leed/auth.json, per environment and company | 90 days from last use, renewed automatically past half-life; 24-hour grace | leed auth login, the automatic renewal | “Session expired”; commands that talk to the CMS refuse |
| GitLab raw-content token | ~/.config/leed/auth.json, filed under the site’s company | Months; leed auth status warns at 7 days | leed auth rotate, leed site init | git fetch and leed site push fail to authenticate, while leed auth status still shows you signed in |
There used to be a third, a package-registry token in ~/.bunfig.toml. The CLI now installs from public npm, so nothing issues or reads one. The file the raw-content token lives in is described in files and environment, and the git side of it — 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 rotateRun from inside your site’s checkout. It mints a fresh GitLab raw-content token for that site and stores it 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 the raw-content row, when git fetch starts failing to authenticate, or as routine hygiene.
leed auth resetThe nuclear option, for a lost laptop or a credential you believe has leaked. It rotates the site’s GitLab token 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 five auth commands, and the leed login alias, is on leed auth.
Signing out
leed auth logoutA best-effort server-side sign-out followed by deletion of the local session for one company — the site checkout’s, or the one named with --company. Outside a checkout it needs -e, unless the --company you name is logged in to exactly one environment, and with company sessions stored it needs --company too: it never signs out of a company you did not name. --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.