Skip to main content
This page covers who can sign in, what they can do once they are in, and the record of what they did. You can add a teammate with a role or invite them by email, scope a CI token down to one app, turn on two-factor auth, passkeys, OAuth or sign-in codes, and review who changed what in the audit log. Start with Common tasks. The sections after it explain how each mechanism behaves.
  • Backend: internal/api/{auth,users,roles,abilities,iam,iam_handlers,invites,tokens,twofactor,oauth,oauth_settings,device_auth,audit,audit_retention}.go
  • CLI: cmd/levelrail-cli/{users,iam,invites,tokens,auth}*.go
  • Dashboard: web/src/routes/settings/{users,iam-policies,security,tokens,oauth,cli-access,audit-log}.tsx
  • OAuth sign-in decision logic: completeOAuthSignin in internal/api/oauth.go

Common tasks

1

Bootstrap the first admin

Do this once, before anyone can sign in. Without these variables the control plane prints a one-time setup token instead (see Bootstrap).
2

Create a teammate with a curated role

No hand-picked abilities needed.
3

Invite a teammate by email

The invitee sets their own password.
4

Scope a CI token down to one app

Use an IAM policy, which is tighter than any flat role can express.
That token can now deploy checkout even without a global deploy ability. To block a resource a broader role would otherwise allow, write an Effect: "Deny" statement instead.
Check the result before relying on it: levelrail-cli iam simulate --principal token:tok_xyz --action deploy --resource app:checkout asks the platform’s real evaluator and changes nothing.
5

Turn on two-factor auth

Run this as the signed-in account.
6

Log the CLI in with the device-code flow

Use this from a machine with no direct HTTPS access to the control plane. It works over plain HTTP.
7

Review who changed what

Why two permission models instead of one

Most self-hosted platforms pick one shape and live with its downside.
  • Flat roles (admin/member/viewer) are easy to reason about but cannot express “this CI token may deploy exactly one app and nothing else.”
  • A full IAM policy engine can express that, but it is overkill for “give Priya everything, give the contractor read-only.”
This platform keeps both, layered: Evaluation: an explicit Deny in an attached policy always wins, even over root. Everything else falls back to the flat abilities exactly as if no policy existed.

How it actually works

Principals: a session, or a token

Every gated route resolves the request to exactly one principal before deciding anything: a session cookie (mapped to a store.User row) or a bearer token (with its own stored Abilities). Sessions Sessions are an in-memory map keyed by an opaque token (sessionStore), with a TTL of 24h by default (overridable with APP_SESSION_TTL, a Go duration string). A restart clears every live session (an accepted tradeoff for a single-node control plane). Session cookie security The session cookie’s Secure flag follows the request: it is set when the request arrived over TLS, or through the embedded Caddy ingress (a loopback peer sending X-Forwarded-Proto: https). X-Forwarded-Proto from any other peer is ignored, so a remote client cannot spoof it. A fresh install reached at http://<server-ip>:8080 therefore works out of the box, and the dashboard shows a persistent “connection is not encrypted” banner until you move to HTTPS. Once you set an https:// dashboard URL (Domains page, levelrail-cli settings dashboard-url set --url https://..., or PUT /api/v1/settings/dashboard-url), sign-in over plain HTTP is refused with a 403 pointing at that URL. This covers password login, 2FA verification, and invite acceptance. An https:// URL can only be saved from a request that already arrived over HTTPS (or from the server itself), so saving it cannot lock you out.
If the https URL breaks later, set APP_ALLOW_INSECURE_LOGIN=true on the control plane and restart it to recover.

Abilities and roles

Tokens can additionally carry signin:approve, which is never implied by any other ability. See The signin:approve ability. A built-in role sets a principal’s Abilities to one of three fixed sets in one action: GET /api/v1/roles lists the roles; a user or token row does not remember “I was created via the operator role.” Custom roles are managed with levelrail-cli roles (see Access control). The dashboard and CLI show a role for display by matching a principal’s abilities against the presets; anything that doesn’t match exactly shows as “custom,” which is expected, not an error state.

IAM policies: Allow/Deny over a resource

A policy document uses AWS IAM’s shape, hand-typed or generated:
Evaluation order (authorizeResource, internal/api/iam.go) Checked on every mutating or sensitive route scoped to a specific app or database (get, update, delete, deploy, rollback, restart, clone, exec, secrets, tags, domain config, backups, and so on):
  1. An explicit Deny matching the ability and resource, in any attached policy, always wins, even for a root principal.
  2. Otherwise, the principal’s own flat abilities decide, unchanged from having no policies attached.
  3. Otherwise, an explicit Allow in an attached policy can still grant access the flat abilities don’t, scoped to that one resource.
Attachment. A policy attaches to a user or a token (principal_type), by that principal’s id. A malformed stored document can never grant or deny anything; it is treated as if it doesn’t mention the pair being checked. Lists. A Deny policy on app:<name> hides that app from GET /api/v1/apps, apps-summary, apps-metrics, deployments, certificates (including the auto-generated sslip.io hostname), GET /api/v1/network/topology (apps and databases) and the container names in GET /api/v1/system/containers, and returns 403 on every /apps/{name}/... route. References in a request body or query. The same check applies to a resource a request names outside its path: a database attached by name, a target environment or node, a promotion target, a compose or deploy-spec service name that already belongs to another app, a git source’s additional services, a pipeline step that deploys another app, a standalone domain pointing at an app, or an ?app= filter. A refused reference answers exactly like an unknown one, so the response never confirms that a hidden resource exists. Server-computed fan-outs (environment clone, protect-all, cutover reports, restore drills) skip resources the caller cannot see instead of failing. internal/api/body_ref_authz_guard_test.go fails the build when a handler reads such a field without one of these checks. Test before you trust. iam simulate answers “can this principal do this on this resource” with the real evaluator, iam effective shows everything a principal can do grouped by ability, iam analyze runs static checks over every policy, and iam templates creates ready-made policies. A newly attached Deny takes effect on the very next request. test/e2e/iam_policy_enforcement_test.go covers this end to end: it drives a real admin session through the real HTTP API to mint a user and a token, attach Deny and Allow policies, and confirm the resulting 403 and 200 responses.

Bootstrap: exactly one path to the first admin

On startup, BootstrapAdmin creates a user from APP_ADMIN_USERNAME and APP_ADMIN_PASSWORD only if zero users exist. It is a no-op on later restarts, so a password change doesn’t get silently reverted. Without those env vars and with no user yet, the control plane generates a one-time setup token, writes it to <data dir>/setup-token (mode 0600), and logs it once.
  • GET /api/v1/auth/setup-status (public) returns {"needs_setup": true} until the first admin exists, and the login page switches to its setup form on its own.
  • POST /api/v1/auth/register requires the token in setup_token (constant-time compare) and deletes the file on success, so whoever reaches the dashboard first can’t claim the instance without shell access to the server.
  • install.sh prints the token and a http://<ip>:8080/login?setup=<token> link; sudo levelrail setup-token prints it again.
POST /api/v1/auth/register is gated at the database layer to succeed exactly once (a unique constraint, not an application check), so concurrent first-registration attempts cannot create two “first” admins. Either path grants AbilityRoot because no one else exists yet.
Development only: APP_DEV_MODE=1 seeds a fixed dev/dev admin for local development. Never use it in a real deployment.
Subsequent users. Every user after the first is created by an existing root user, either directly (POST /api/v1/auth/users) or via an accepted invite.
A root user cannot edit their own abilities or delete their own account, and the last remaining user can never be deleted. Both return a hard 400, not a warning.

Two-factor authentication (TOTP)

Requires a master key configured on the control plane (internal/secrets). Without one, every 2FA route returns 501.
  1. POST /api/v1/auth/2fa/setup: mint a fresh TOTP secret and store it unconfirmed. Calling it again before confirming overwrites the pending secret.
  2. POST /api/v1/auth/2fa/confirm: validate one live code, flip TOTPEnabled, and return 10 recovery codes in plaintext. This is the one and only time they are shown.
  3. On login, POST /api/v1/auth/login returns mfa_required: true plus a short-lived mfa_token (5 minutes) instead of a session. Exchange it with a live code (or a recovery code) via POST /api/v1/auth/2fa/verify for the session cookie.
Disabling and recovery codes. POST /api/v1/auth/2fa/disable re-verifies the second factor itself, not the account password, because a password-only re-check would undermine 2FA. Regenerating recovery codes invalidates the entire previous set. Single-use codes. A TOTP code is accepted once. The control plane remembers the newest time step it accepted for each user (users.totp_last_step, migration 0350), so a code that was just used or replayed inside its roughly 90 second validity window is refused with invalid code. The check is atomic, so two simultaneous logins presenting the same code cannot both pass. The step is claimed at login (/2fa/verify), not at /2fa/confirm, so the code you enrol with can still be used once to sign in. Recovery codes are single-use too. Rate limiting. Both the login-time verify step and every setup/confirm/disable call are rate limited (exponential backoff with a handful of free failures), separately from the password rate limiter.

Passkeys (WebAuthn)

Sign in with Touch ID, Windows Hello, or a security key instead of a password. No master key required: a credential’s public key is ordinary key material, not a secret, so it never goes through internal/secrets. Passkey sign-in is username-first, not usernameless: the operator types their username, the server looks up that account’s credentials, and the browser’s passkey prompt proves possession. A successful passkey sign-in completes the session the same way a password does, without an additional TOTP prompt even if the account has 2FA enabled (the same shape OAuth sign-in has). Every registration and login challenge is single-use and expires in 5 minutes. The relying party ID and origin are derived from the request’s own Host header, since there is no single fixed domain to configure on a self-hosted platform.

OAuth sign-in

Four providers are supported: google, github, microsoft (Azure AD, common multi-tenant endpoint), and oidc (generic OpenID Connect, requires an issuer URL). Settings are per-provider rows (GET/PUT /api/v1/settings/oauth[/{provider}]), gated at AbilityRoot to change. Enabling a provider requires a client ID and a client secret (OIDC also requires an issuer URL). The secret is write-only over the API; GET only reveals has_client_secret.

Team invites

POST /api/v1/invites mints a token for one named email, with either --role or --abilities.
  • Privilege check: The caller cannot invite someone with an ability they don’t hold themselves. AbilityWrite gates the route, but that per-ability cap is the actual boundary.
  • Accept link: The response always includes the plaintext accept link, whether or not SMTP is configured, so a control plane with no email is still fully usable by copy and paste.
  • Acceptance: Default TTL is 7 days (APP_INVITE_TTL). POST /api/v1/invites/accept creates a normal local-password user through the same path as POST /api/v1/auth/users, so an invited account is indistinguishable from an admin-created one.
  • Visibility: A non-root caller only sees invites they created. A root caller sees every pending invite.
Set the dashboard URL before inviting people by email. The link is absolute only when the control plane knows its own origin: the primary domain if one is set, otherwise the dashboard URL (Domains page, or levelrail-cli settings dashboard-url set). With neither, the link is a bare path (/accept-invite?token=...) that only works if pasted after the dashboard’s address. The request’s Host header is never used to build the link. The password reset email follows the same rule.
GET /api/v1/users follows the same visibility idea: a root caller lists every account, any other caller (a viewer, an operator, a scoped token) gets only their own record, or an empty list for a token. Emails and ability sets of colleagues are not visible to read-tier callers.

API tokens

Scoped, revocable bearer credentials for the CLI, CI, and MCP integrations. Minted with the same ability vocabulary users carry, plus an optional expiry (expires_in_days, 0 means never). The plaintext is returned exactly once at creation; every later read (GET /api/v1/auth/tokens) shows only metadata. The security policy can cap the lifetime of new tokens and disable tokens that go unused, after a notice: see API token hygiene. Session-only management. Creating and revoking tokens is deliberately session-only, never bearer-token authenticated: a token can never mint or revoke another token. This is why the CLI’s tokens create/revoke and auth login prompt for username and password instead of accepting --token. Listing metadata (GET /api/v1/auth/tokens) also accepts a bearer token that holds root. Device-code flow. POST /api/v1/auth/device/start and /token (levelrail-cli auth login --device) sidestep the plain-HTTP session-cookie gap.
  • The CLI polls with a random device code.
  • An operator approves it from the dashboard’s CLI Access page (/settings/cli-access) using their existing session.
  • The resulting token inherits exactly that operator’s abilities. The CLI never picks the permissions.
A session link is a one-time login URL, not a credential you type. Mint one from an authenticated context and hand the URL to whatever needs to sign in next: CI, an AI agent driving the dashboard through browser automation, or a fresh incognito window. Opening it signs that browser in immediately. POST /api/v1/auth/session-links mints one. It is gated root, because the link is root-equivalent: anyone who gets hold of the token before it’s used can sign in as whoever minted it.
Security model
  • Short-lived: The token expires 2 minutes after minting (default, APP_AUTH_ENGINE_SESSION_LINK_TTL), whether or not it’s used.
  • Single-use: Consuming it is an atomic claim; if two requests race on the same token, at most one succeeds and the other gets the same “invalid or expired” error a stale token would.
  • Can’t escalate: The session carries exactly the minting identity’s own abilities, snapshotted at mint time. Minting already requires root.
  • A link minted on behalf of an API token (rather than a logged-in user) produces a session pinned to that token’s ability snapshot. Revoking the token afterward does not end a session already established from it, the same way revoking a token never ends sessions a user already has open.

Sign in with a code

The sign-in page offers Sign in with a code instead. You type your username or email and get a one-time code you type back in, no password.
1

Request

POST /api/v1/auth/login-code/request always answers 202 with the same message and takes at least APP_LOGIN_CODE_MIN_RESPONSE (default 300ms), whether the account exists, may use codes, or not. An account that cannot get a code still gets a decoy challenge, so redeeming behaves the same way too.
2

Bind to the browser

The response sets an httpOnly, SameSite=Strict cookie bound to that browser. The code only works together with that cookie, so a code read over someone’s shoulder is useless in another browser.
3

Read the code

The code goes to the account’s own open dashboard sessions (a banner on every page, the attention center, and Settings > Security), each showing the requester’s IP address, browser and time, and to the account’s email when email is set up. Run levelrail-cli auth code to print it from a terminal.
4

Redeem

POST /api/v1/auth/login-code/redeem with the code signs the browser in. If the account has two-factor authentication on, the code replaces only the password: the response asks for the TOTP or a recovery code next, exactly like a password sign-in.
Properties
  • 8 characters from a 32 symbol alphabet with no lookalikes (Crockford base32), from crypto/rand. Dashes, spaces, lower case and O, I, L typed for 0, 1, 1 are accepted.
  • Single use, stored only as an HMAC keyed with a per-challenge salt and, when the control plane has its encryption key, a server secret, so a copy of the database alone cannot test guesses. Compared in constant time. The plaintext lives in control plane memory until used or expired; after a restart a waiting code can no longer be shown, so ask for a new one.
  • The requester’s browser is shown as a short label such as “Firefox on Linux”, built from a fixed list of names. The raw User-Agent text never reaches an email or the dashboard.
  • The request, redeem and poll routes accept only Content-Type: application/json and refuse a cross-site Origin or Sec-Fetch-Site.
  • Codes never appear in logs, the audit log, the attention feed or any list endpoint. The only route that returns one is POST /api/v1/auth/sign-in-requests/codes/{id}/reveal, for the account’s own session or a token owned by that user that holds signin:approve, and each reveal is audited.
Limits and defaults A zero or negative value falls back to its default instead of switching the protection off.
  • A request spends from the rate budgets only when all of them have room, so requests refused by one limit never drain another.
  • A browser already signed in to the account, or trusted for it, draws from its own account budget, so a stranger typing your name cannot use it up.
  • Past APP_LOGIN_CODE_MAX_LIVE, a new stranger request expires the oldest, so nobody can hold the slots and keep the owner out. Only the first live code is emailed.
  • A request from a browser already signed in to, or trusted for, the account skips the live-code cap and is always emailed, within the hourly email budget.
  • The attention center shows one item per account with a count, not one per request.
The signin:approve ability. A code is worth a session, so showing one, and deciding a new-browser approval, needs either a signed-in session of that account or a token that holds signin:approve. That ability is never implied: root, write:sensitive and every device login (levelrail-cli auth login --device) leave it out, so a leaked CLI token cannot turn itself into a dashboard session. Users and roles can never hold it. Mint such a token only from a signed-in session: Settings > API tokens > Create token > Approve my sign-ins, or:
Such a token must expire within APP_SIGNIN_APPROVE_TOKEN_MAX_DAYS (default 30) days and cannot be an agent token. It is revoked when the account’s password is changed or reset, when it signs out its other sessions, and by recover-admin (audited as new_device.approver_tokens_revoked). Listing waiting requests and managing trusted browsers still accept write:sensitive. Who may use codes. The auth.code_login setting decides: Settings > Security, levelrail-cli auth code-login, or PUT /api/v1/settings/auth/code-login (root). It is off for admin (root) accounts and on for everyone else until an admin saves it; APP_AUTH_CODE_LOGIN_ADMINS and APP_AUTH_CODE_LOGIN_OTHERS change those defaults.

New-browser approval

When a password sign-in is correct but comes from a browser without a trusted-device cookie, and the account already has a live session somewhere else, the sign-in pauses. The new browser shows a waiting screen; every other session of that account sees a banner and an attention item with the requester’s IP address, browser and time, and can Approve or Deny. A token owned by the user that holds signin:approve can decide too: levelrail-cli auth code lists the request, then levelrail-cli auth approve <id> --match <number> or auth deny <id> decides it. Number matching. The waiting browser shows a two digit number. Approving means typing that number (in the dashboard, or --match NN in the CLI); a wrong number denies the sign-in, so approving a prompt you did not expect does not let anyone in. The number never appears in a list, the attention feed or the CLI output, only in the waiting browser. Behavior
  • Only one approval waits per account: a new paused sign-in replaces the previous one (audited as new_device.superseded).
  • Once approved, the waiting browser gets its session exactly once (POST /api/v1/auth/login-approval/poll, bound to that browser’s cookie). If the paused sign-in’s session cannot be revoked, the login fails with an error and no approval is created.
  • Accounts with two-factor authentication on are not paused: password plus TOTP still signs in without a separate approval. Passing TOTP does not trust the browser on its own; tick Remember this browser for N days on the two-factor step (off by default) to trust it.
  • An account with no other live session signs in as before, so the last way in is never blocked.
  • Setting auth.approval_scope to all_methods (Security center > Policy, levelrail-cli security policy set --approval-scope all_methods, or APP_AUTH_APPROVAL_SCOPE) pauses passkey and OAuth/OIDC sign-ins from a new browser too. A held OAuth sign-in returns to the sign-in page to wait. Each account can also require approval for itself with Require approval for new browsers on my account. See Security center.
  • A sign-in from a browser and network the account has not used before emails the owner a single-use this wasn’t me link that signs that session out, drops trusted browsers and flags the account for a new password. See New sign-in alerts.
Trusted browsers. An approved browser, or one that signed in with a code, gets a trusted-device cookie: a random token stored only as a hash. Signing in again from that browser extends the same entry instead of adding one, and its next password sign-in goes straight through. Revoke one under Settings > Security > Trusted browsers or with levelrail-cli auth devices revoke <id>. Every trusted browser of an account is revoked when its password is changed or reset, when it signs out its other sessions, when recover-admin resets it, and when the user is deleted. The same events, plus turning two-factor authentication off or removing a passkey, also cancel the account’s waiting approvals (approved ones not yet collected included) and lock its live sign-in codes. Expired entries are removed by the periodic sweep.
Break-glass: if you are stuck waiting for an approval no one can give, sign in with a code sent to your email, approve from the CLI with a signin:approve token, sign out the other session, or wait for it to expire. As a last resort set APP_AUTH_NEW_DEVICE_APPROVAL=false on the control plane and restart it, which turns the pause off for everyone and for every sign-in method, whatever auth.approval_scope and the per-account switches say. recover-admin on the server still works.
Every step writes one audit row with a stable action, never the code: login_code.requested, login_code.delivered (email at send time, dashboard or CLI at each reveal), login_code.redeemed, login_code.failed, login_code.locked, login_code.expired, new_device.requested, new_device.approved, new_device.denied, new_device.expired, new_device.superseded, new_device.trust_revoked and new_device.approver_tokens_revoked.The security center adds its own security.* events, listed in Security center. Requests from someone who is not signed in are recorded with the actor Sign-in and the target user id when the account exists.

Audit log

Every request gated above AbilityRead (write, deploy, root-tier, and read:sensitive) gets one row: Recording is best-effort and runs after the real request completes. A failed audit write is logged and dropped, never turned into a failed request. Query. GET /api/v1/audit-log is cursor-paginated (?before, an RFC3339 timestamp) and filterable by ?path, ?method, ?client_kind, and ?agent (the agent label of the token, see AI assistant). Use ?format=csv to return rows as a downloadable attachment for compliance export. Cells that start with =, +, -, @ or a tab get a leading ' in the CSV, so a token named =HYPERLINK(...) is not evaluated as a formula when the export is opened in a spreadsheet. Retention. Defaults to 90 days (APP_AUDIT_LOG_RETENTION_DAYS). The system sweeps automatically on an interval (APP_AUDIT_LOG_SWEEP_INTERVAL) and is purgeable on demand via POST /api/v1/audit-log/purge.

API reference

Custom role and environment grant routes are documented in Access control.

CLI

  • There is no per-team or per-project access boundary. IAM policies scope to individual resources (app:name, database:name) or a wildcard. The Organizations settings page groups projects for display and navigation only.
  • There is no SSO/SAML and no SCIM provisioning. OAuth covers Google, GitHub, Microsoft and generic OIDC, and nothing else today.
  • Sessions are in memory. A control plane restart signs everyone out, and waiting sign-in codes can no longer be shown.

Next steps

Access control

Custom roles, IAM policies and environment grants.

Environments

Global environments, kinds and protected moves.

Security overview

How secrets, sessions, TLS and access control fit together.

AI assistant integration

Scope an API token for an MCP client or an AI agent.

CLI reference

API tokens and device-code authentication workflows.

Managing databases

Backup target credentials use envelope encryption, gated at write:sensitive.
Last modified on October 12, 2026