Skip to content

Authentication

This page covers dashboard authentication. For protecting the proxy routes that clients call, see API Keys.

Dashboard authentication modes

codex-lb supports three dashboard auth modes via environment variables:

  • CODEX_LB_DASHBOARD_AUTH_MODE=standard — built-in dashboard password with optional TOTP from the Settings page. This is the default.
  • CODEX_LB_DASHBOARD_AUTH_MODE=trusted_header — trust a reverse-proxy auth header such as Authelia's Remote-User, but only from CODEX_LB_FIREWALL_TRUSTED_PROXY_CIDRS. Built-in password/TOTP remain available as an optional fallback, and password/TOTP management still requires a fallback password session.
  • CODEX_LB_DASHBOARD_AUTH_MODE=disabled — fully bypass dashboard auth. Use only behind network restrictions or external auth. Built-in password/TOTP management is disabled in this mode.

trusted_header mode also requires:

CODEX_LB_FIREWALL_TRUST_PROXY_HEADERS=true
CODEX_LB_FIREWALL_TRUSTED_PROXY_CIDRS=172.18.0.0/16
CODEX_LB_DASHBOARD_AUTH_PROXY_HEADER=Remote-User

If the trusted header is missing and no fallback password is configured, the dashboard fails closed and shows a reverse-proxy-required message instead of loading the UI.

Accounts behind the proxy

Every identity the proxy sends gets its own dashboard account. The first time a new value arrives in the header, the dashboard creates an account for it (username derived from the value: alice@example.com becomes alice.example.com; a proxy user called admin becomes admin-2, because admin is reserved for the local password account -- and stays reserved even after that account is renamed away from it), links the identity to it, and records user_created and identity_linked in the audit log. From then on every request from that identity is served as that account, with that account's role, and everything it changes is attributed to it. Accounts created before this release keep the roles they have; nothing is re-evaluated.

Unknown identities become Admin by default so an existing reverse-proxy install keeps working unchanged. To tighten this, change the trusted-header provider's default role through the API: GET /api/auth-providers lists the providers (security:write), and PATCH /api/auth-providers/{id} with {"unknownIdentityRoleId": "<role id>"} picks the role new identities receive — the viewer preset for a read-only default, or null to refuse anyone who has not been added first (role ids come from GET /api/dashboard-roles). Changing the default affects only identities that arrive afterwards. The same PATCH accepts linkByEmail (off by default: when on, an identity whose value is an e-mail address held by an existing account is linked to that account instead of getting a new one) and idpMfaEnforced (recorded for the upcoming step-up rules).

A refused identity — the default role is null, or the account was disabled — is answered with 401 identity_not_provisioned (or account_disabled) and the dashboard shows "Your account is not ready yet" at /auth/pending; once an administrator adds the person, Try again signs them in. If a local admin password exists, that screen also offers the local login, and a browser already signed in as admin keeps working behind the proxy even when the header names someone the dashboard refuses. To add someone before their first visit, use Invite → Add without a password in Settings → Access (or POST /api/dashboard-users with ssoOnly: true and expectedIdentity: {"provider": "trusted_header", "subject": "<header value>"}): no link is handed over, the account never expires while it waits, and it activates the moment the proxy first sends that exact value (one waiting account per identity). The local break-glass password account remains the fallback: an account holding users:manage (typically a proxy admin) creates it from the setup screen as admin. That account can be renamed later, but the name admin is reserved and can never be given to anyone else -- so if you rename it, note the new name next to wherever you keep the recovery runbook.

Ready-to-run Docker commands for both non-default modes are in Docker deployment — auth mode examples. For Helm, pass the same values through extraEnv. Narrowing local password sign-in once the proxy is in place, keeping an emergency account that can always get back in, and the host commands that recover a locked-out install are covered in Company Sign-In and Recovery.

Signing in

Dashboard sign-in is per account. The first POST /api/dashboard-auth/password/setup (the bootstrap screen) creates the admin account, which can be renamed afterwards from the People tab (the bootstrap screen itself has no username field and a one-account install never needs one); an install upgraded from the shared-password era already has that account with the same password and TOTP secret, so nothing changes for the person signing in. POST /api/dashboard-auth/password/login takes {username?, password}: on a single-account install the username may be omitted and the login form hides the field (login.username_field is hidden in the session response); as soon as a second account holds a password the username becomes mandatory and the API answers 422 username_required without it. The sign-in screen follows the same hint: it shows only the password box on a single-account install (with a "Sign in with a different account" link that reveals the username field) and adds the username field, prefilled with the last username used in that browser, once a second account exists. Invite links open at /invite/<token>, where the invited person picks a password and is signed in; an expired, used or revoked link shows one generic message. Wrong usernames and wrong passwords return the same 401 invalid_credentials (and take the same time), and failed attempts are limited per client (8 per minute); accounts are never locked out.

Sessions are bound to the account: disabling an account or changing its password ends every one of its sessions on the next request, and POST /api/dashboard-auth/logout-all signs the account out of every browser at once (changing the password keeps the browser that changed it signed in). GET /api/dashboard-auth/me returns the signed-in account. TOTP secrets belong to the account too. Two settings can require them: "Require TOTP on login" binds every account with a password, and "Require two-factor for administrators" (totpRequiredForAdminRole, in the People tab's sign-in requirements) binds only admin-level accounts — the Admin preset and any custom role that holds a privileged permission (security:write, users:manage, roles:manage, accounts:export, conversations:read, audit:read); Operators and Viewers are not affected by it. An account a requirement binds that has not enrolled yet can only use the session, TOTP setup/verify, logout, logout-all, me and password-change routes and gets 403 totp_enrollment_required everywhere else until it completes /totp/setup. Turning either requirement on needs your own two-factor to be set up first (400 invalid_totp_config otherwise) and nothing else: any account that has not enrolled, including the one the install bootstrapped, is simply held at the enrolment gate until it does. The settings response tells you how many accounts (usersWithoutTotpCount) and how many administrators (adminsWithoutTotpCount) will have to set up two-factor before they can continue. Removing the dashboard password on a single-account install turns both requirements off again. Removing the dashboard password (DELETE /api/dashboard-auth/password) is only possible while the account is the only one on the install and no invite is pending; disabled accounts count as accounts (delete them first, or the call answers 409 other_users_exist), because a passwordless install is managed by a local principal that holds no account and could never enable or delete them again. It makes the install passwordless again exactly as before, including the regenerated bootstrap token.

Upgrading to this release changes the session cookie format, so everyone signs in once more after the upgrade; cookies from the previous release are rejected rather than trusted.

Confirming sensitive changes

Changes that decide who can sign in — guest password, the TOTP requirement, session lifetime and the other security settings, firewall rules, sign-in provider settings, accounts, invites and roles — and exporting an upstream account's credentials require that you confirmed your identity within the last five minutes. The dashboard asks in a small dialog and then carries out the change; the API answers 403 step_up_required (with details.methods listing what to present) until POST /api/dashboard-auth/step-up succeeds. What you present is what your account holds: a password account re-enters its password, plus its authenticator code once two-factor is set up; an account that signs in through the reverse proxy and has no password re-enters its authenticator code. Signing in already counts when it covered every factor you hold, so a fresh sign-in is not followed by a second prompt. An account with neither a password nor two-factor cannot confirm anything and gets 403 step_up_unavailable — set up two-factor authentication under Settings → Access → My sign-in (a reverse-proxy account can do this without a password) or set a local password; the reverse proxy's own MFA does not stand in for this. Reads and all other changes are unaffected, and the local install without a password has no account to confirm, so it is not asked. Wrong answers are 401 invalid_credentials and count against the same 8-per-minute limit as sign-in attempts; every confirmation is recorded as step_up_verified in the audit log.

Accounts and invites

Adding a person is done from the dashboard-users API (users:manage, held by Admin). POST /api/dashboard-users with a username and a role creates the account in an invited state and returns an invite link token exactly once; hand the link to the person. The link is valid for 24 hours and works once: the person opens GET /api/dashboard-auth/invite/{token} to see who invited them and for which role, then sets their own password with POST /api/dashboard-auth/invite/accept and is signed in. Nobody but the person ever knows the password. Resending an invite (POST /api/dashboard-users/{id}/invite) rotates the token and kills the old link; revoking it (DELETE .../invite) removes the invited account again, and expired invited accounts are cleaned up the next time the pending-invite list is read, so a personal install that invited someone and never followed up returns to its single-account state by itself.

Who can assign which role follows one rule: you can only grant (or act on) a role whose permissions are a subset of your own. In practice only an Admin can create another Admin, and a custom role holding users:manage can invite Viewers but not Admins or Operators (403 insufficient_delegation). Accounts cannot change their own role or disable themselves, at least one active Admin must always remain (409 last_admin_protected, enforced inside the write so two admins disabling each other at once leaves exactly one), and the account the install bootstrapped is an ordinary account: it can be demoted, disabled, deleted, renamed and have its two-factor reset under exactly the same rules as anybody else. Renaming is PATCH /api/dashboard-users/{id} with {"username": "..."} (409 username_taken if somebody holds it). The name admin itself stays reserved, so the rename is one-way: the bootstrap account can leave that name, and no account -- including it -- can take it afterwards, which keeps the name in the recovery runbooks meaning what those runbooks say it means. Managing accounts requires being signed in as an account: a passwordless local install answers 409 admin_account_required until a password is set.

In the dashboard all of this lives in Settings → Access. A single-account install sees one card with the existing password, two-factor, guest-access and session controls and a single line offering to invite a teammate (or asking for a password first). As soon as a second account or a pending invite exists the card gains a People tab — the accounts table with role, status, last sign-in and sign-in method, row actions (change role, disable, reset two-factor, log out everywhere, delete; copy a new link or revoke for invited rows), an Invite button, Pending invites when there are any, and a read-only View roles sheet — next to a My sign-in tab holding the controls above. /settings#access opens the card on your own controls and /settings#access-people on the People tab (the header account menu uses both); teams that outgrow the card get the same table full-page at /settings/access, which any users:manage holder can open by URL.

Disabling an account (PATCH /api/dashboard-users/{id} with status: disabled) ends its sessions and turns off every API key it owns, recording owner_disabled as the reason; re-enabling the account does not turn the keys back on — POST /api/dashboard-users/{id}/reactivate-keys restores exactly those keys and leaves manually deactivated ones alone (revoking a key by hand always wins, and a key whose owner is disabled cannot be re-enabled from the API-key page). Deleting an account keeps its keys, inactive and without an owner. Admins can also reset a lost TOTP secret (reset-totp) or sign an account out everywhere (revoke-sessions); everyone can edit their own display name and e-mail through PATCH /api/dashboard-auth/me. GET /api/dashboard-roles and GET /api/dashboard-roles/permissions describe the built-in roles and the permission vocabulary. The exact rules are normative in the dashboard-users spec and the dashboard-roles spec.

Roles and permissions

The dashboard has five built-in roles. An account holds one of the assignable presets — Admin (everything), Operator, Viewer — and the trusted-header, disabled-auth, and local-bootstrap principals act as Admin without an account. An Operator runs the day-to-day: upstream accounts (add, pause, probe, routing, reset credits — these routes need accounts:write), every API key, model sources, automations, the quota planner, sticky sessions and the non-security settings; it cannot manage people, change security settings (guest access, session length, two-factor requirements, API-key auth policy, firewall, proxy endpoints), export account credentials, or read conversations and the audit log — those routes answer 403 permission_required, and the dashboard shows an Operator those controls read-only or not at all. A Viewer gets the same read-only surface a guest gets (overview, reports, usage, request logs without sensitive metadata, accounts with masked identities) plus their own account menu; every mutation answers 403. Member (own API keys and own usage only) becomes assignable when own-scoped views ship; until then an account holding it can sign in but every dashboard read answers 403 permission_required (dashboard:read). Guest (the optional read-only role) is never an account: it holds only dashboard:read and accounts:read, so it can read dashboard overview and usage data, reports, redacted request logs, and account status. It cannot read conversations, conversation archives, the audit log, the API-key inventory, egress-proxy or sticky-session configuration, cannot export account credentials, and cannot change state. Account e-mails are masked for guests (a***@example.com) and upstream account identifiers are hidden. Changing or removing the guest password, turning guest access off, or calling POST /api/dashboard-auth/guest/logout-all logs every guest out immediately. In the dashboard, guests see an administrator-only notice on the APIs page and do not see the API key, upstream-proxy, or sticky-session sections in Settings, because those reads are answered with 403 for read-only sessions.

Internally, authorization is expressed as fine-grained permissions such as accounts:export, security:write, conversations:read, and audit:read, each granted with an all or own scope. The session API reports the coarse read / write values followed by every grant as <permission>:<scope> (for example api_keys:write:all); the role field stays admin or guest, and the account's real role travels in user.role. When a request lacks a specific permission the API answers 403 with error code permission_required and names the missing permission in param:

{"error": {"code": "permission_required", "message": "Dashboard permission 'accounts:export' is required", "param": "accounts:export"}}

Mutations gated only by the generic write check keep returning read_only_access; routes gated by a specific permission (account credential export, security settings, firewall rules, guest password, upstream-proxy endpoint creation) return permission_required instead. The full permission list, the built-in grant table, and the per-route requirements are normative in the admin-auth spec.

Cross-site request protection

State-changing dashboard requests (POST, PUT, PATCH, DELETE under /api/) are checked against the browser's Sec-Fetch-Site and Origin headers before they reach any handler. A request the browser marks as cross-site, or whose Origin does not match the dashboard's own scheme, host, and port, is refused with 403 and error code cross_site_request_rejected; requests without either header (curl, scripts, the CLI) pass through. Bearer-authenticated routes (/api/fleet/, /api/codex/, or any request with Authorization: Bearer) are exempt. Nothing needs to be configured, but a reverse proxy in front of an http:// dashboard must forward the browser's original Host header including its port so the Origin comparison can match: nginx proxy_set_header Host $http_host; (not $host, which drops the port), Apache ProxyPreserveHost On; Traefik and Caddy pass Host through by default. The exact rules are normative in the admin-auth spec.

First-time remote access

Setting the initial dashboard password from a remote machine requires a one-time bootstrap token — see Getting Started.


Specs: admin-auth · identity-providers · dashboard-users · dashboard-roles · api-firewall