Skip to content

Authentication & security

Login and sessions

  • Team members log in with e-mail and password against the users table (/admin/login).
  • Passwords are hashed with scrypt (lib/auth/password.ts). No external auth library is used.
  • The session is an HMAC-signed, httpOnly cookie (12 hours), signed with SESSION_SECRET. It contains the user ID, and for database users also a session_version.
  • The role is read from the database on each request (getCurrentUser), so role changes take effect immediately.
  • updateUserAction increments users.session_version when a password or role changes. All existing cookies of that user become invalid, including the one of the person who made the change.

Startup check for SESSION_SECRET

When the server starts, instrumentation.ts (register(), Node runtime only) checks the secret with checkSessionSecret (lib/env/session-secret.ts):

  • If SESSION_SECRET is missing or empty, register() throws and the server does not start.
  • If it is shorter than 32 characters (SESSION_SECRET_MIN_LENGTH), only a warning is logged.
  • next build does not run the check. The worker does not need the secret.

SESSION_SECRET is also the key for the address counters of the form rate limit (see below). Changing it invalidates all sessions and resets the address counters.

Env bootstrap admin

ADMIN_PASSWORD_HASH provides a bootstrap login with the e-mail from ADMIN_EMAIL (default admin@status.local). It is accepted only while no database admin exists. While it is in use, every admin page shows a permanent warning. ADMIN_BOOTSTRAP_DISABLED_AFTER (ISO date) disables this login from a given point in time, for new logins and running sessions alike. A date in the past takes effect immediately. An empty or unreadable value disables nothing.

Warning

Create a database admin as soon as the system is installed, and then remove ADMIN_PASSWORD_HASH from the environment.

Recovery: locked out

If ADMIN_BOOTSTRAP_DISABLED_AFTER is set and the date has passed while no database admin (or none with a working login) exists, you are locked out. Two ways back in:

Regular path (env access available):

  1. Remove ADMIN_BOOTSTRAP_DISABLED_AFTER or set it to a future date.
  2. Restart the app and log in with the bootstrap login (ADMIN_PASSWORD_HASH).
  3. Create a database admin under Users.
  4. Set ADMIN_BOOTSTRAP_DISABLED_AFTER again (and remove ADMIN_PASSWORD_HASH) and restart the app.

Emergency path (no env access): Generate a password hash with node scripts/hash-admin-password.mjs and insert an admin directly via SQL. Column names and the admin role value come from users in lib/db/schema.ts:

INSERT INTO users (email, password_hash, name, role)
VALUES ('admin@example.com', '<generated scrypt hash>', 'Admin', 'admin');

Warning

Never use pnpm db:seed in production — it deletes existing data.

Authorization

Authorization has two layers.

1. Role matrix. The roles and their permission sets are defined in lib/domain/users.ts (ROLES_CAN_*). The table describes what the admin UI offers to each role:

Role Summary
admin Everything, including user management, settings, notifications and the audit log
service_manager Operates all components: service status, outages, incidents, maintenance, internal notes and component configuration. Confirms outages in SLA reports.
service_owner Sets the status of their own services, creates incidents and maintenance for them, maintains their outages, incident updates, reviews and maintenance, closes them, enters the exact outage times, confirms their outages in SLA reports, and views SLA reports
sla_manager Manages SLA plans, holiday lists, targets and reports (start, generate). Maintains and confirms outages of all services and maintains incident updates, reviews and maintenance. Read-only otherwise.

2. Component ownership. Operational server actions (set status, report incident, plan maintenance, close incidents and maintenance) are allowed for admin, service_manager, or a user who is responsible for all affected components (components.responsible_user_id). For outages, incident updates, reviews and maintenance updates, sla_manager also counts as fully privileged. The helpers userMayOperateOnComponents and userMayManageOutageComponents in lib/auth/components.ts implement this. An empty component set never counts as "owned".

Info

The admin UI mirrors the server actions (since issue #361): the create forms for incidents and maintenance are gated by ROLES_CAN_START_INCIDENTS_AND_MAINTENANCES for admin, service_manager and service_owner; service owners only see their own services in the service selection (filterGroupsToOwnedComponents). Internal notes remain reserved for admin and service_manager (ROLES_CAN_WRITE_INCIDENTS).

Info

Guards run inside each page and each server action, not only in layouts. Layouts render in parallel with pages and are not a reliable security boundary on their own.

Public endpoints without authentication

  • Status page, history, service detail, badge, RSS, legal pages.
  • Subscription flows use double opt-in (e-mail confirmation).
  • Unsubscribe and data-access flows require a token link and then a POST confirmation. Opening the link alone never changes data, which protects against link prefetchers.
  • The three request forms (subscribe, unsubscribe link, data access) are throttled and answer neutrally, see below.

API tokens

Bearer tokens (Authorization: Bearer …) are used for /api/outage/*, /api/incidents and /api/maintenances. Tokens are stored as SHA-256 hashes. Each token can be restricted to specific services, can have an expiry date, and can be revoked. See Public API.

Rate limiting

Login and API tokens

  • Failed logins and invalid API tokens are counted per client IP (auth_throttle table).
  • Defaults: login 5 attempts per 15 minutes, API tokens 20 per 5 minutes, lockout 15 minutes. All values are configurable with AUTH_RATELIMIT_*; AUTH_RATELIMIT_ENABLED=false turns throttling off.
  • A successful login or a valid token request resets the counter of that IP. A valid token without permission for the service (403) does not count as a failed attempt.

Public forms

  • Subscribing, requesting an unsubscribe link and requesting data access are throttled (lib/auth/public-form-throttle.ts). Here, every request counts, not only failed ones.
  • Two counters in auth_throttle: per client IP across all three forms (publicform:ip:<ip>, default 10 per 60 minutes, lockout 60 minutes), and per address and form (publicform:<kind>:email:<hmac>, default 1 per 10 minutes, lockout 10 minutes).
  • The address key is an HMAC-SHA-256 of the trimmed, lower-cased address, keyed with SESSION_SECRET. The address itself is not stored.
  • Without a detectable client IP, the shared counter unknown applies (default 100 per 60 minutes, lockout 60 minutes). It is logged once per process as a warning.
  • A throttled request answers { ok: true } without a mail, exactly like an unknown address. Only a warning without the address is logged.
  • Configuration: PUBLIC_FORM_RATELIMIT_ENABLED and PUBLIC_FORM_RATELIMIT_{IP,EMAIL,UNKNOWN_IP}_{MAX,WINDOW_MIN,LOCK_MIN}.

Client IP and reverse proxy

Both rate limits determine the client IP with clientIp (lib/auth/rate-limit.ts), controlled by TRUSTED_PROXY_IP_HEADER:

Value Behavior
auto (default) x-real-ip, otherwise the last x-forwarded-for entry
x-real-ip Only x-real-ip. x-forwarded-for is ignored.
x-forwarded-for Only x-forwarded-for, counted from the right: with TRUSTED_PROXY_COUNT=N (default 1), the N-th entry from the end is used. x-real-ip is ignored.
  • Unknown values fall back to auto. Behind a proxy that only sets x-forwarded-for, a client can inject its own X-Real-IP header in auto mode. Use x-forwarded-for in that case.
  • A port suffix is removed. IPv6 addresses are canonicalized per RFC 5952 (zone index removed, IPv4-mapped as ::ffff:a.b.c.d), so different spellings end up in the same counter.
  • If the detected IP is private or loopback in production (IPv4 127/8, 10/8, 172.16/12, 192.168/16, 100.64/10, 169.254/16; IPv6 ::1, fc00::/7, fe80::/10, including IPv4-mapped), the app logs a warning once per process. Usually the proxy then sets no IP header, and all visitors share the proxy's IP.

Store failure

If the throttle storage fails, requests are allowed (fail-open) and the error is logged (tolerateThrottleFailure). Rate limiting is a mitigation, not a hard gate.

Secrets and sensitive data

  • smtp_password, resend_api_key, twilio_auth_token, vapid_private_key and webhook secrets are encrypted with AES-256-GCM using SETTINGS_ENCRYPTION_KEY. Consumers always receive plaintext through getSettings().
  • Secrets and password hashes are never written to the audit log.
  • Legal HTML (data protection notice) is sanitized with a strict whitelist (h1, h2, h3, b, i) before rendering.

Audit log

All mutating admin actions are written to audit_log with actor, action (<entity>.<verb>), entity, and before/after snapshots. This covers settings, users, components and groups, status changes, outages, incidents, maintenance, SLA plans, holiday lists, SLA report steps, API tokens, webhooks, SMS recipients, subscribers, templates, resend, and regenerating the VAPID keys.

Not logged are public actions without an admin (subscribe, confirm, unsubscribe, data access, push subscription), pure test actions (test mail, test SMS, check test, test webhook), login and logout, and internal incident notes. The audit page is restricted to the admin role. See Settings, users, API tokens & audit log.