Security center
The security center is one page, Security center in the sidebar (/security), that answers three questions: how exposed is this instance right now, what should I fix first, and who is signed in. It reads the control plane's own state (accounts, tokens, sessions, settings, certificates, the exposure audit) and never calls anything outside your servers.
Security score and checklist
GET /api/v1/security/posture returns a score from 0 to 100, a grade, and counts by severity. Every check that fails costs points: 25 for critical, 15 for high, 8 for medium and 3 for low. A check that could not be read shows as unknown and costs nothing, so a missing data source never makes the score look worse or better than it is.
Anyone with read gets the score and counts, plus a short checklist for their own account (two-factor or passkey, recovery codes, a reset flag). Admins (root) also get the full platform checklist with the names of the accounts, tokens, domains and nodes behind each failing check. The result is cached for APP_SECURITY_POSTURE_CACHE_TTL (default 30s); the exposure audit inside it, which may ask remote agents for their containers, refreshes every APP_SECURITY_POSTURE_EXPOSURE_TTL (default 5m). Changing the policy, revoking sessions or a "this wasn't me" report refreshes it at once.
| Check | Severity | Data source | Fix |
|---|---|---|---|
| Admins without two-factor or a passkey | critical | users with root, TOTP status, registered passkeys | Settings > Security |
| Accounts flagged for a new password | critical | "this wasn't me" reports | change the password |
| The Docker API is reachable from the internet | critical | exposure audit findings for ports 2375 and 2376 | Firewall, firewall exposure |
| API tokens that hold root | high | live tokens | Settings > API tokens |
| New browser approval is off | high | APP_AUTH_NEW_DEVICE_APPROVAL | set it back to true |
| Container ports open to the internet | high | exposure audit | Firewall, firewall exposure |
| Off-box control plane backups are off | high | disaster recovery status | Control plane backup |
| Domains without a publicly trusted certificate | high | routed domains, stored certificates, ACME setting | Domains |
| Bursts of failed sign-ins | high | failed sign-in counter, see below | Audit log |
| Accounts with no recovery codes left | medium | TOTP status | Settings > Security |
| API tokens that never expire | medium | live tokens | one click: limit new tokens to 90 days |
| API tokens that can approve sign-ins | medium | live tokens holding signin:approve | Settings > API tokens |
| Admins can sign in with a code | medium | auth.code_login | one click: turn it off |
| HSTS is off while real certificates are in place | medium | HSTS setting and certificates | Domains |
| Node agents older than the minimum version | medium | each node's reported agent version | Nodes |
| No admin has a passkey | low | registered passkeys | Settings > Security |
| API tokens unused for a long time | low | last use, warn_unused_days | Settings > API tokens |
| Old sessions still signed in | low | sessions older than APP_SECURITY_SESSION_MAX_AGE_DAYS (default 7) | Sessions tab |
| Only password sign-ins need new browser approval | low | auth.approval_scope | one click: every method |
| The master key is missing or overdue for rotation | low | rotation history, APP_DOCTOR_MASTER_KEY_ROTATION_WARN_DAYS | secrets rotate-master-key |
| Secrets older than 90 days | low | secret update times | rotate the secret |
A domain counts as served without public TLS when ACME is off, or when its only certificate is from the control plane's internal issuer or has expired. A proxy in front that terminates TLS (tls_terminated_upstream) passes the check.
From the CLI:
levelrail-cli security posture
levelrail-cli security posture --jsonSessions and devices
The Sessions and devices tab, GET /api/v1/security/sessions, and levelrail-cli security sessions list show:
- every live session of the account: browser (a short label like "Firefox on Linux" built from a fixed list, never the raw User-Agent), the network it signed in from (the /24 or /48 of its address, which is the only location this platform knows: no GeoIP database is used), when it signed in, when it was last active, and which one is this browser;
- trusted browsers, which skip new browser approval;
- the account's API tokens with when each was last used.
Sign out ends one session (DELETE /api/v1/security/sessions/{id}, security sessions revoke <id>). Sign out all other sessions (POST /api/v1/security/sessions/revoke-others, security sessions revoke-others) keeps the browser you are using and also resets trusted browsers, waiting sign-ins and signin:approve tokens, like changing the password does.
An admin can pick another account in the tab, or pass --user <user id> (?user_id= on the API), to see and end its sessions. Every revoke is audited (security.session_revoked, security.sessions_revoked), with the target account when it is not the caller's own. Anyone else asking for a session that is not theirs gets a not found answer, so session ids cannot be probed. A token used for these routes must belong to a user and hold write:sensitive or signin:approve.
Sign-in protection
Approval scope
New browser approval (see Identity and access) always applies to password sign-ins. auth.approval_scope decides whether it also applies to passkey and OAuth sign-ins:
| Value | Password | Passkey | OAuth / OIDC |
|---|---|---|---|
password_only (default) | held | not held | not held |
all_methods | held | held | held |
A sign-in is only held when the account already has another live session, so the last way in is never blocked. A held OAuth sign-in returns to the sign-in page, which shows the number to type in the approving session. Set it in the Policy tab, with levelrail-cli security policy set --approval-scope all_methods, or PUT /api/v1/security/policy (root). APP_AUTH_APPROVAL_SCOPE sets the default before anyone saves one. If the policy cannot be read, the sign-in is held, never waved through.
Each account can also turn on Require approval for new browsers on my account in the Policy tab (PUT /api/v1/security/account). That applies approval to every method for that account whatever the platform scope is. Turning it off needs a signed-in dashboard session; a token can only turn it on.
Break-glass. APP_AUTH_NEW_DEVICE_APPROVAL=false on the control plane, then a restart, turns approval off for every method and every account, whatever the scope and account switches say. Use it only when nobody can approve; set it back afterwards. recover-admin on the server still works.
New sign-in alerts
When an account signs in from a browser and network it has not used before, its owner gets an email with the browser, network and time, and channels opted in to sign-in notices (the same switch as CLI login notices) get a short message. The first browser an account ever uses raises nothing, and a sign-in you approved yourself, or a session link, does not alert. At most APP_SIGNIN_ALERTS_PER_ACCOUNT_PER_HOUR (default 5) emails go out per account per hour. Known browsers are remembered as a hash of the browser label and network for APP_KNOWN_BROWSER_RETENTION (default 180 days).
The email carries a this wasn't me link. Opening it changes nothing; the page has one button. Pressing it (POST /api/v1/auth/sign-in-alert/disown) signs that session out, removes every trusted browser of the account, cancels its waiting sign-ins and flags the account for a new password. The flag shows in the checklist and the attention center until the password is changed or reset. The link:
- is an HMAC over a random id with a key derived from the control plane's encryption key, so it cannot be forged or altered;
- works once, recorded in the database before anything is revoked;
- expires after
APP_SIGNIN_ALERT_LINK_TTL(default 6 hours); - is rate limited per address and only accepted as a same-origin JSON request;
- is sent only to the account's own email, never to a channel, since anyone reading a channel could use it.
Failed sign-in signals
Failed password sign-ins are counted per typed account name and per address over APP_SECURITY_FAILED_LOGIN_WINDOW (default 15 minutes). Reaching APP_SECURITY_FAILED_LOGIN_ACCOUNT_THRESHOLD (default 10) for one account or APP_SECURITY_FAILED_LOGIN_IP_THRESHOLD (default 20) from one address writes one audit row (security.failed_logins), posts one notice to opted-in channels, and adds an attention item (to admins, and to the account owner for their own account). A burst raises one alert per window, not one per attempt. The counts live in memory, so a restart starts them again. This is a signal only: the login throttle and account lockout in the auth engine are what slow guessing down. There is no "new country" signal, since the control plane has no GeoIP data.
API token hygiene
Three policy settings, each in the Policy tab, security policy set, or PUT /api/v1/security/policy:
| Setting | Env default | What it does |
|---|---|---|
max_token_lifetime_days | APP_TOKEN_MAX_LIFETIME_DAYS (none) | new tokens must set expires_in_days from 1 to this; existing tokens are only flagged in the checklist |
warn_unused_days | APP_TOKEN_WARN_UNUSED_DAYS (30) | tokens unused this long are listed in the checklist |
disable_unused_days | APP_TOKEN_DISABLE_UNUSED_DAYS (off) | tokens unused this long are disabled after a notice |
The sweep runs every APP_TOKEN_HYGIENE_SWEEP_INTERVAL (default 1h). A token unused for disable_unused_days first gets a notice: an audit row (security.token_unused_notice), a channel message and an attention item for its owner and admins. If it is still unused APP_TOKEN_HYGIENE_NOTICE_GRACE (default 7 days) later, it is revoked (security.token_unused_disabled). Using the token at any point clears the notice. Tokens that belong to no user (minted by the platform itself) are never swept. A value of 0 turns a setting off.
levelrail-cli security policy get
levelrail-cli security policy set --max-token-lifetime-days 90 --disable-unused-days 120Audit events
security.session_revoked, security.sessions_revoked, security.policy_updated, security.device_approval_changed, security.new_browser_sign_in, security.sign_in_disowned, security.token_unused_notice, security.token_unused_disabled and security.failed_logins.