Authentication
Login Flow
- User submits email + password at
/login - API validates credentials against the Argon2 hash
- On success, the server checks for TOTP MFA enrollment
- User submits their 6-digit TOTP code
- Server creates a session row (IP, user-agent, expiry) and issues:
- A session cookie (
sameSite=lax,httpOnly, signed withCOOKIE_SIGNING_KEY) - A short-lived JWT access token (15-minute TTL, signed with
JWT_SIGNING_KEY)
- A session cookie (
- 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:
- Scans a QR code displayed inline (generated server-side from the TOTP secret)
- 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.envin 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}/postgresand${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_codestable - Once consumed, a code cannot be reused (marked with
consumedAttimestamp)
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:
- Create the user record in Admin → Users
- 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).