Skip to main content

Overview

Codex-LB supports multi-layered authentication for the admin dashboard:
  • Password authentication - bcrypt-hashed password stored in database
  • TOTP (Time-based One-Time Password) - Optional 2FA using authenticator apps
  • Session management - 12-hour encrypted session cookies
  • Rate limiting - Protection against brute-force attacks
By default, authentication is disabled for easy first-time setup. Once configured, all /api/* endpoints (except auth endpoints) require valid authentication.
API key authentication (for proxy endpoints) and dashboard authentication (for admin endpoints) are separate systems. See API Keys for proxy authentication.

Password Authentication

Initial Setup

On first launch, the dashboard is open (no authentication required). Set up password protection:
  1. Navigate to SettingsSecurity
  2. Click Setup Password
  3. Enter a strong password (minimum 8 characters)
  4. Click Save
From app/modules/dashboard_auth/service.py:210-213:
async def setup_password(self, password: str) -> None:
    setup_ok = await self._repository.try_set_password_hash(_hash_password(password))
    if not setup_ok:
        raise PasswordAlreadyConfiguredError("Password is already configured")
Password is hashed using bcrypt:
# From app/modules/dashboard_auth/service.py:337-338
def _hash_password(password: str) -> str:
    return bcrypt.hashpw(password.encode("utf-8"), bcrypt.gensalt()).decode("utf-8")
Password setup is a one-time operation. Once configured, you must use the password change flow to update it.

Logging In

Once password is configured, the dashboard requires authentication:
  1. Navigate to dashboard URL
  2. Enter password in login form
  3. Click Sign In
  4. Receive 12-hour session cookie
API endpoint:
POST /api/dashboard-auth/password/login
{
  "password": "your-password"
}
Response:
{
  "authenticated": true,
  "password_required": true,
  "totp_required_on_login": false,
  "totp_configured": false
}
Sets cookie: codex_lb_dashboard_session (httponly, secure, samesite=lax, 12h expiry)

Session Management

Sessions are encrypted using Fernet (symmetric encryption):
# From app/modules/dashboard_auth/service.py:95-101
def create(self, *, password_verified: bool, totp_verified: bool) -> str:
    expires_at = int(time()) + _SESSION_TTL_SECONDS  # 12 hours
    payload = json.dumps(
        {"exp": expires_at, "pw": password_verified, "tv": totp_verified},
        separators=(",", ":"),
    )
    return self._get_encryptor().encrypt(payload).decode("ascii")
Session payload:
  • exp: Expiration timestamp
  • pw: Password verified (boolean)
  • tv: TOTP verified (boolean)
From app/modules/dashboard_auth/service.py:18-19:
DASHBOARD_SESSION_COOKIE = "codex_lb_dashboard_session"
_SESSION_TTL_SECONDS = 12 * 60 * 60  # 12 hours

Changing Password

Requirements: Valid authenticated session
POST /api/dashboard-auth/password/change
{
  "current_password": "old-password",
  "new_password": "new-password"
}
From app/modules/dashboard_auth/service.py:222-224:
async def change_password(self, current_password: str, new_password: str) -> None:
    await self.verify_password(current_password)
    await self._repository.set_password_hash(_hash_password(new_password))

Removing Password

To return to unauthenticated mode:
DELETE /api/dashboard-auth/password
{
  "password": "current-password"
}
From app/modules/dashboard_auth/service.py:226-228:
async def remove_password(self, password: str) -> None:
    await self.verify_password(password)
    await self._repository.clear_password_and_totp()  # Also clears TOTP!
Removing password also disables TOTP and clears the secret. This returns the system to completely unauthenticated mode.

TOTP Two-Factor Authentication

Prerequisites

TOTP requires an active password session. You cannot enable TOTP without first configuring password auth.

Setting Up TOTP

  1. Log in with password
  2. Navigate to SettingsSecurityTwo-Factor Authentication
  3. Click Enable TOTP
  4. Scan QR code with authenticator app (Google Authenticator, Authy, 1Password, etc.)
  5. Enter 6-digit code from app
  6. Click Verify
API flow: Step 1: Start setup
POST /api/dashboard-auth/totp/setup/start
Response:
{
  "secret": "JBSWY3DPEHPK3PXP",
  "otpauth_uri": "otpauth://totp/codex-lb:dashboard?secret=JBSWY3DPEHPK3PXP&issuer=codex-lb",
  "qr_svg_data_uri": "data:image/svg+xml;base64,..."
}
From app/modules/dashboard_auth/service.py:246-256:
async def start_totp_setup(self, *, session_id: str | None) -> TotpSetupStartResponse:
    settings = await self._require_active_password_session(session_id)
    if settings.totp_secret_encrypted is not None:
        raise TotpAlreadyConfiguredError("TOTP is already configured...")
    secret = generate_totp_secret()  # 32-char base32 secret
    otpauth_uri = build_otpauth_uri(secret, issuer=_TOTP_ISSUER, account_name=_TOTP_ACCOUNT)
    return TotpSetupStartResponse(
        secret=secret,
        otpauth_uri=otpauth_uri,
        qr_svg_data_uri=_qr_svg_data_uri(otpauth_uri),  # SVG QR code as data URI
    )
Step 2: Confirm setup
POST /api/dashboard-auth/totp/setup/confirm
{
  "secret": "JBSWY3DPEHPK3PXP",
  "code": "123456"
}
Verifies the code and stores encrypted secret:
# From app/modules/dashboard_auth/service.py:258-268
async def confirm_totp_setup(self, *, session_id: str | None, secret: str, code: str) -> None:
    current = await self._require_active_password_session(session_id)
    if current.totp_secret_encrypted is not None:
        raise TotpAlreadyConfiguredError("TOTP is already configured...")
    verification = verify_totp_code(secret, code, window=1)
    if not verification.is_valid:
        raise TotpInvalidCodeError("Invalid TOTP code")
    await self._repository.set_totp_secret(self._encryptor.encrypt(secret))
TOTP uses standard 6-digit codes, 30-second time steps, SHA-1 algorithm, and window=1 (accepts codes from previous/current/next time step for clock skew tolerance).

Logging In with TOTP

Once TOTP is configured and totp_required_on_login is enabled:
  1. Enter password → Creates session with pw=true, tv=false
  2. Dashboard shows TOTP prompt
  3. Enter 6-digit code from authenticator app
  4. Upgrades session to pw=true, tv=true
TOTP verification:
POST /api/dashboard-auth/totp/verify
{
  "code": "123456"
}
From app/modules/dashboard_auth/service.py:270-287:
async def verify_totp(self, *, session_id: str | None, code: str) -> str:
    settings = await self._require_active_password_session(session_id)
    secret_encrypted = settings.totp_secret_encrypted
    if secret_encrypted is None:
        raise TotpNotConfiguredError("TOTP is not configured")
    secret = self._encryptor.decrypt(secret_encrypted)
    verification = verify_totp_code(
        secret,
        code,
        window=1,
        last_verified_step=settings.totp_last_verified_step,  # Replay protection
    )
    if not verification.is_valid or verification.matched_step is None:
        raise TotpInvalidCodeError("Invalid TOTP code")
    updated = await self._repository.try_advance_totp_last_verified_step(verification.matched_step)
    if not updated:
        raise TotpInvalidCodeError("Invalid TOTP code")
    return self._session_store.create(password_verified=True, totp_verified=True)
Replay protection: The totp_last_verified_step field prevents reusing codes. Each time step is a 30-second counter, and the system tracks the most recent verified step.

Disabling TOTP

Requirements: TOTP-verified session
POST /api/dashboard-auth/totp/disable
{
  "code": "123456"  // Current valid TOTP code required
}
From app/modules/dashboard_auth/service.py:289-306:
async def disable_totp(self, *, session_id: str | None, code: str) -> None:
    settings = await self._require_totp_verified_session(session_id)
    secret_encrypted = settings.totp_secret_encrypted
    if secret_encrypted is None:
        raise TotpNotConfiguredError("TOTP is not configured")
    secret = self._encryptor.decrypt(secret_encrypted)
    verification = verify_totp_code(
        secret,
        code,
        window=1,
        last_verified_step=settings.totp_last_verified_step,
    )
    if not verification.is_valid or verification.matched_step is None:
        raise TotpInvalidCodeError("Invalid TOTP code")
    # ... verify and clear TOTP secret
    await self._repository.set_totp_secret(None)

Authentication Guard

Scope

Authentication is enforced on all /api/* routes except:
  • /api/dashboard-auth/* (auth endpoints themselves)
  • /api/codex/usage (uses separate bearer caller identity validation)

Guard Logic

From openspec/specs/admin-auth/spec.md:84-96:
Authentication required condition: the system SHALL evaluate password_hash and totp_required_on_login together to determine whether authentication is required. When password_hash is NULL and totp_required_on_login is false, the guard MUST allow all requests (unauthenticated mode). When either password_hash is set or totp_required_on_login is true, the guard MUST require a valid session.
Session validation steps when requires_auth is true:
  1. Valid session cookie must be present (else 401)
  2. If password_hash is not NULL, session must have password_verified=true
  3. If totp_required_on_login is true, session must have totp_verified=true
From app/modules/dashboard_auth/service.py:187-208:
async def get_session_state(self, session_id: str | None) -> DashboardAuthSessionResponse:
    settings = await self._repository.get_settings()
    password_required = settings.password_hash is not None
    totp_required = password_required and settings.totp_required_on_login
    totp_configured = settings.totp_secret_encrypted is not None
    state = self._session_store.get(session_id) if password_required else None
    password_authenticated = bool(state and state.password_verified)
    
    if not password_required:
        authenticated = True  # Unauthenticated mode
    elif totp_required:
        authenticated = bool(state and state.password_verified and state.totp_verified)
    else:
        authenticated = password_authenticated

    # Surface TOTP prompt only for password-authenticated sessions
    totp_required_on_login = bool(totp_required and password_authenticated)
    return DashboardAuthSessionResponse(
        authenticated=authenticated,
        password_required=password_required,
        totp_required_on_login=totp_required_on_login,
        totp_configured=totp_configured,
    )

Session State Endpoint

Check current authentication state:
GET /api/dashboard-auth/session
Response (unauthenticated mode):
{
  "authenticated": true,
  "password_required": false,
  "totp_required_on_login": false,
  "totp_configured": false
}
Response (password set, not logged in):
{
  "authenticated": false,
  "password_required": true,
  "totp_required_on_login": false,
  "totp_configured": false
}
Response (logged in with password, TOTP pending):
{
  "authenticated": false,
  "password_required": true,
  "totp_required_on_login": true,
  "totp_configured": true
}
Response (fully authenticated with TOTP):
{
  "authenticated": true,
  "password_required": true,
  "totp_required_on_login": false,  // Already verified in this session
  "totp_configured": true
}

Rate Limiting

Both password and TOTP login attempts are rate-limited:
# From app/modules/dashboard_auth/service.py:312-326
_totp_rate_limiter = TotpRateLimiter(max_failures=8, window_seconds=60)
_password_rate_limiter = TotpRateLimiter(max_failures=8, window_seconds=60)
Limits: 8 failures per 60-second window Rate limit logic (from app/modules/dashboard_auth/service.py:143-178):
def check(self, key: str) -> int | None:
    now = int(time())
    failures = self._failures.get(key)
    if failures is None:
        return None
    cutoff = now - self._window_seconds
    while failures and failures[0] <= cutoff:
        failures.popleft()  # Remove old failures outside window
    if not failures:
        self._failures.pop(key, None)
        return None
    if len(failures) >= self._max_failures:
        retry_after = failures[0] + self._window_seconds - now
        return max(1, retry_after)
    return None
On rate limit breach:
  • HTTP Status: 429 Too Many Requests
  • Header: Retry-After: {seconds}
  • Error: {"error": {"code": "rate_limit_exceeded", ...}}
Rate limit state is stored in-memory and resets on server restart. For production deployments with multiple instances, consider implementing distributed rate limiting.

Settings Cache

To avoid per-request database queries, settings are cached: From openspec/specs/admin-auth/spec.md:179-192:
The system SHALL cache DashboardSettings in memory with a TTL of 5 seconds to avoid per-request DB queries in the auth guard. The cache MUST be invalidated immediately when settings are modified via the settings API or password/TOTP management endpoints.
Cache behavior:
  • TTL: 5 seconds
  • Invalidation: Immediate on setting changes
  • Benefit: Reduces DB load on high-traffic dashboards

Logout

POST /api/dashboard-auth/logout
Clears session cookie (server-side no-op since sessions are stateless/encrypted). From app/modules/dashboard_auth/service.py:308-309:
def logout(self, session_id: str | None) -> None:
    self._session_store.delete(session_id)  # Stateless: client-side cookie clear

Security Best Practices

Strong Passwords

  • Minimum 8 characters (enforced)
  • Recommended: 12+ characters with mixed case, numbers, symbols
  • Use a password manager

Enable TOTP

Always enable TOTP for production deployments:
  1. Setup password
  2. Configure TOTP
  3. Enable SettingsRequire TOTP on Login
TOTP adds a second factor even if password is compromised. Highly recommended for production.

Network Security

  • HTTPS: Always run behind HTTPS in production
  • Firewall: Restrict dashboard access to trusted IPs
  • Reverse proxy: Use nginx/Caddy for TLS termination

Session Security

Sessions use Fernet encryption with a key derived from environment:
# From app/core/crypto.py (implied)
# Encryption key derived from ENCRYPTION_KEY or SECRET_KEY environment variable
Environment variables:
# Generate secure key:
python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"

# Set in environment:
export ENCRYPTION_KEY="your-generated-key-here"
If ENCRYPTION_KEY changes, all existing sessions are invalidated. Users must re-login.

Troubleshooting

Locked Out (Forgot Password)

If you forget the password: Option 1: Reset via database
UPDATE dashboard_settings 
SET password_hash = NULL, totp_secret_encrypted = NULL, totp_required_on_login = false 
WHERE id = 1;
This returns to unauthenticated mode. Option 2: Set new password hash
# Generate new hash
import bcrypt
new_hash = bcrypt.hashpw(b"new-password", bcrypt.gensalt()).decode("utf-8")
print(new_hash)
Then update database:
UPDATE dashboard_settings SET password_hash = '$2b$12$...' WHERE id = 1;

TOTP Code Not Working

Causes:
  • Clock skew between server and authenticator app
  • Wrong secret scanned
  • Code already used (replay protection)
Solutions:
  1. Ensure server and device clocks are synced (NTP)
  2. Re-scan QR code in authenticator app
  3. Wait for next 30-second window

Rate Limited

Symptom: 429 Too Many Requests Cause: 8 failed login attempts in 60 seconds Solution: Wait for the Retry-After duration (shown in error response)

Session Expired

Symptom: Dashboard redirects to login after 12 hours Cause: Normal session TTL behavior Solution: Log in again. Consider increasing _SESSION_TTL_SECONDS in code if needed.

Technical Reference

Key source files:
  • app/modules/dashboard_auth/service.py - Auth business logic
  • app/modules/dashboard_auth/repository.py - Database operations
  • app/modules/dashboard_auth/schemas.py - API schemas
  • app/core/auth/totp.py - TOTP implementation
  • app/core/crypto.py - Encryption utilities
  • openspec/specs/admin-auth/spec.md - Detailed specification

Build docs developers (and LLMs) love