Authentication

Login flow, MFA enforcement, session management, and account lockout.

Login Flow

  1. User submits email + password at /login
  2. API validates credentials against the Argon2 hash
  3. On success, the server checks for TOTP MFA enrollment
  4. User submits their 6-digit TOTP code
  5. Server creates a session row (IP, user-agent, expiry) and issues:
    • A session cookie (sameSite=lax, httpOnly, signed with COOKIE_SIGNING_KEY)
    • A short-lived JWT access token (15-minute TTL, signed with JWT_SIGNING_KEY)
  6. Subsequent API requests carry the access token; the server validates the JWT and verifies the session row exists and is not revoked

TOTP MFA

MFA is mandatory on every account. There is no way to create a Weavestream account without enrolling an authenticator app.

During account setup (via the one-time setup URL), the user:

  1. Scans a QR code displayed inline (generated server-side from the TOTP secret)
  2. Enters the current 6-digit code to confirm enrollment

The TOTP secret is encrypted at rest using MFA_ENCRYPTION_KEY (AES-256).

Backup note. TOTP secrets, password-vault entries, and SMTP credentials are encrypted with keys that live in .env (MFA_ENCRYPTION_KEY, PASSWORD_ENCRYPTION_KEY, SMTP_SECRET_KEY, and so on). A Postgres dump alone is not enough to recover these surfaces — the matching key must be present at restore time too. Keep .env in a separate secrets store, and copy ${DATA_DIR}/backup (the directory the in-app scheduled exports write to) off-host on the same cadence as ${DATA_DIR}/postgres and ${DATA_DIR}/files.

Supported authenticator apps: Google Authenticator, Authy, 1Password, Bitwarden, Microsoft Authenticator, and any app that supports the standard TOTP (RFC 6238) protocol.

MFA Backup Codes

Each account is issued 10 single-use backup codes that can be used instead of a TOTP code during login. These are useful if your authenticator device is lost or unavailable.

Format

  • 10 characters in the format XXXXX-XXXXX
  • Alphanumeric (excludes confusing characters: 0, 1, I, O)
  • Example: ABCDE-FGHJ2

Storage and Security

  • Codes are hashed with Argon2 before storage (same algorithm as passwords)
  • Stored in the user_mfa_backup_codes table
  • Once consumed, a code cannot be reused (marked with consumedAt timestamp)

Usage

During the MFA challenge step at /mfa/challenge, enter any valid backup code instead of your 6-digit TOTP code. The code is consumed immediately upon successful login.

Management

Users can view and regenerate backup codes from My Account → MFA → Regenerate codes.

  • Regenerating creates a fresh set of 10 codes
  • Previous codes are invalidated when you regenerate
  • First-time MFA enrollment automatically issues backup codes

Session Management

Sessions are stored server-side in Redis and referenced by the session cookie. Each session record stores:

  • User ID
  • IP address at creation
  • User-agent string
  • Creation timestamp
  • Last-used timestamp
  • Expiry timestamp
  • Revocation flag

Session TTL is controlled by SESSION_MAX_AGE_DAYS (default: 30 days).

Access tokens (JWTs) have a separate TTL of ACCESS_TOKEN_TTL_MIN (default: 15 minutes). The web layer refreshes the access token transparently using the session cookie — users are not prompted to re-login within their session window.

Session Revocation

Sessions can be revoked:

  • By the user — from My Account → Sessions, revoke any specific session
  • By a SUPER_ADMIN — from the user management page, revoke all sessions for a user

Revocation is immediate. The revocation flag is set on the session row; the next API request with that session's token returns 401 and the session cookie is cleared.

JWTs are short-lived (15 minutes), so even if a token is used after revocation, the damage is time-bounded.

Rate Limiting and Lockout

The login endpoint has two independent protections:

Per-IP and per-email rate limit

Default: 5 login attempts per minute, per IP address and per email address independently.

If either bucket is exhausted, the endpoint returns 429 with a Retry-After header.

Configured via AUTH_RATE_LIMIT_PER_MIN.

Account soft-lock

After LOCKOUT_MAX_FAILURES (default: 5) failed attempts within LOCKOUT_WINDOW_MIN (default: 15 minutes), the account is soft-locked.

During soft-lock, the login endpoint returns a generic 401 (no information about lock state is revealed). The lock clears automatically after the window expires.

A SUPER_ADMIN can clear a lock manually from the user management page.

Generic Error Responses

All authentication errors return generic 401 responses. The response body does not reveal:

  • Whether an account with the given email exists
  • Whether the account is locked
  • Whether the password was correct (but MFA failed)

This prevents user enumeration and state inference by external attackers.

Invite-Only Registration

There is no self-registration page. The only way to create an account is for a SUPER_ADMIN to:

  1. Create the user record in Admin → Users
  2. Distribute the one-time setup URL (valid for a limited time, single-use)

The setup URL includes a cryptographically random token. When the user opens the URL, they complete account setup (name, password, TOTP enrollment). The token is invalidated after use.

Password and MFA Recovery

A SUPER_ADMIN can generate a one-time password reset link from Admin → Users → user detail → Generate password reset link.

The reset link uses the same secure setup-token flow as first-time account setup:

  • The user chooses a new password themselves
  • Existing unconsumed setup/reset links for that user are invalidated
  • The link is single-use and expires after 24 hours
  • MFA is cleared and the user re-enrolls an authenticator on the next step

To reset only MFA from the web UI, use Admin → Users → user detail → Reset MFA. This clears the user's MFA secret and backup codes, revokes active sessions, and requires the user to enroll MFA again on their next sign-in.

Super Admin Recovery CLI

If the first or only SUPER_ADMIN is locked out, run the CLI from the host running Docker Compose.

Create the initial super admin:

docker compose exec api node dist/cli.js create-admin

Create an additional fail-safe super admin when one already exists:

docker compose exec api node dist/cli.js create-admin --force

Optional non-interactive form:

docker compose exec api node dist/cli.js create-admin --force --email admin2@example.com --name "Admin 2" --password "StrongPassword123!"

Other recovery commands:

docker compose exec api node dist/cli.js list-users
docker compose exec api node dist/cli.js reset-password user@example.com
docker compose exec api node dist/cli.js reset-mfa user@example.com

reset-password <email> is CLI-only. It sets a new password directly and revokes that user's sessions. reset-mfa <email> clears MFA, removes backup codes, and revokes sessions, matching the web UI reset-MFA behavior.

"Remember This Device" (Session Persistence)

The login form optionally offers to persist the session for the full SESSION_MAX_AGE_DAYS window. Without this option, the session expires when the browser is closed (session-scoped cookie).