Skip to content
runlot

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.

Terminal
runlot token create ci --scope deploy --scope project:read --org my-org --project my-app --expires 30d

The 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.

FlagWhat 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-expiryNo deadline. Contradicts --expires.
--read-onlyThe 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.

Terminal
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

ScopeWhat it opens
project:readProjects, deployments, logs, usage, secret names, cron, hostnames, the deployment access policy
deployDeploy, rollback, create a project, write and delete secrets
db:readThe connection string, the wire credentials list, generations, database operations
db:writeIssue a wire credential, export, restore, drop the database. Implies db:read
repo:readClone and fetch over HTTPS, the repository browse routes
repo:writePush, and create, rename or delete a repository. Implies repo:read
org:adminEverything 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

Terminal
runlot token create gh-actions \
  --scope deploy --scope project:read \
  --org my-org --project my-app \
  --expires 90d

Those 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

.github/workflows/deploy.yml
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.

On this page