Personal access tokens
A token that carries only the scopes you name, and is accepted on every REST route — so CI can deploy without a browser login.
A personal access token is your account, narrowed. It looks like this:
runlot_pat_<id>_<secret>It is accepted as Authorization: Bearer … on every REST route of the API,
and as a git-over-HTTPS credential, so a pipeline can deploy without
runlot login and without a browser.
runlot token create ci --scope deploy --scope project:read --org my-org --project my-app --expires 30dThe value is printed once, at creation. Only a SHA-256 hash is stored, so nobody — including us — can read it back. If you lose it, revoke it and make another.
Creating a token
You can create one from the CLI, or from the dashboard: open Settings in your organization and use the Personal access tokens section. A token belongs to your account rather than to an organization, so the list is the same whichever organization you opened it from.
| Flag | What it does |
|---|---|
--scope <name> | One scope. Repeats — --scope deploy --scope project:read. |
--org <org> | The token is refused in every other organization. |
--project <project> | Narrows it to one project. Needs --org. |
--expires <value> | 30d, 12h, 2w, 6m, or a bare number of days. Default 90 days, maximum 365. |
--no-expiry | No deadline. Contradicts --expires. |
--read-only | The old spelling of --scope repo:read. Cannot be combined with --scope. |
Hours round up to a whole day, because the deadline is stored in days and rounding down would hand you a token that is already expired.
runlot token list
runlot token revoke <tokenId>runlot token list prints the scopes, the restriction, the expiry and the last
use of every token you hold. Revoking takes effect immediately.
The scopes
| Scope | What it opens |
|---|---|
project:read | Projects, deployments, logs, usage, secret names, cron, hostnames, the deployment access policy |
deploy | Deploy, rollback, create a project, write and delete secrets |
db:read | The connection string, the wire credentials list, generations, database operations |
db:write | Issue a wire credential, export, restore, drop the database. Implies db:read |
repo:read | Clone and fetch over HTTPS, the repository browse routes |
repo:write | Push, and create, rename or delete a repository. Implies repo:read |
org:admin | Everything the person can do — members, billing, domains, DNS, granting storage, email and AI |
A write implies its own read and nothing else. db:write implies db:read
because issuing a wire credential and then listing it is one job.
deploy does not imply project:read
That is the tempting one, and it is wrong. project:read opens the worker's
logs, and logs are where a printed secret ends up. A credential sitting in a CI
runner should not also read them unless you said so — which is why the CI token
below asks for both by name.
A route that is not in the mapping table requires org:admin. That is
fail-closed on purpose: a new route is refused for every narrow token, with a
message naming the scope it would need, rather than being quietly reachable by
a token you scoped down.
GET /v1/me is the one authenticated route that asks for no scope at all. It
answers "who am I", which is the first question a caller holding an unfamiliar
token has.
A token acts as you, and can only narrow
The scope check is additional. Your organization role is read exactly as it
is for a browser session, and the scope set is applied on top of it. A deploy
token held by a viewer deploys nothing: the role says no first.
So scopes subtract from what you can do. They never add. An org:admin token
does not make you an admin — it means "this token does not narrow".
Restricting a token to one place
--org and --project pin the token. It is refused everywhere else, and the
refusal does not say which organization it belongs to: you already know, and
anyone who stole it should not learn an organization from a 403.
Deploying from GitHub Actions
1. Issue a token for the job
runlot token create gh-actions \
--scope deploy --scope project:read \
--org my-org --project my-app \
--expires 90dThose two scopes are the minimum for a deploy job. runlot deploy reads the
project back at the end of the run, so a deploy-only token gets a 403 on that
last step, and the 403 names project:read.
Add --scope db:write only if the deploy applies migrations. A job that
just ships a bundle has no business holding a credential that can drop the
database.
Keep --org and --project on it. A leaked token that reaches one project is
a smaller incident than one that reaches every project you have.
2. Store it as a repository secret
In the GitHub repository, open Settings → Secrets and variables → Actions →
New repository secret, name it RUNLOT_TOKEN, and paste the value printed by
runlot token create.
3. Use it in the workflow
name: deploy
on:
push:
branches: [main]
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
- run: npx runlot deploy
env:
RUNLOT_TOKEN: ${{ secrets.RUNLOT_TOKEN }}The CLI reads RUNLOT_TOKEN in preference to the stored login, for every
control plane address. There is no runlot login step, and nothing to clean up
after the job.
When a token is refused
A missing scope is a 403 that names the scope it wanted.
{
"error": {
"code": "insufficient_scope",
"message": "this token does not carry the deploy scope",
"details": { "requiredScope": "deploy" }
}
}A token pinned to a different organization or project answers 403
insufficient_scope too, without naming where it belongs.
An expired token answers 401 and says when it expired — at that point the
caller has already proved they hold the token, and "the token expired on
Tuesday" is the difference between a five-minute fix and an afternoon.
insufficient_scope is about the credential: reissue the token. forbidden is
about the person: ask an organization admin.
Last used
runlot token list and the dashboard show when each token was last used. The
mark is written at most once per minute per token, so it can lag real use by up
to a minute. A pipeline polling an operation every second would otherwise put a
row write on the hot path of a read.
What is stored
Only the SHA-256 hash of the secret. Revoking does not delete the row — delete it and "how long was this token alive" has no answer.