- Passwords are hashed with Argon2 through
pwdlib; plaintext credentials are never stored. - Access tokens are short-lived. Refresh tokens rotate on use, and only their SHA-256 fingerprints are persisted.
- The Next.js browser-facing layer keeps both tokens in
HttpOnly,SameSite=Laxcookies. Tokens are never exposed to client JavaScript or browser storage. - Protected routes revalidate access against
/auth/me; expired access tokens are transparently rotated through the server-side refresh flow. - A token's
company_idselects a tenant, but authorization revalidates the user-company membership from PostgreSQL on each authenticated request. - Role checks are composed as FastAPI dependencies. Domain services must receive the resolved principal and derive tenant IDs from it.
- Login lockout and per-IP rate limiting are enforced on the login endpoint. Failed attempts are counted per account email and per client IP; after
account_lockout_max_attemptsfailures withinaccount_lockout_window_secondsthe account/IP is blocked foraccount_lockout_duration_seconds(429 +Retry-After), and the response is identical whether or not the email exists. The store followsrate_limiter_backend(in-process memory by default; Redis for shared multi-instance enforcement) and fails open if the store is unavailable. Frontal brute-force volume is additionally capped by the per-IP auth rate limit (30/min default). - Every response carries hardened security headers set by
security_headers_middleware(outermost middleware, so they appear even on 429/4xx paths):X-Content-Type-Options: nosniff,X-Frame-Options: DENY,Referrer-Policy: strict-origin-when-cross-origin,Permissions-Policy,Cross-Origin-Opener-Policy: same-origin, andStrict-Transport-Security(max-age 180 days, includeSubDomains). - Email verification ships as single-use codes (
POST /auth/email-verify+/auth/email-verify/confirm, delivered through the messaging port with an in-UI dev code when no real provider is set; outstanding codes expire on re-request; the reply never reveals whether an account exists). Verification state lives onusers.email_verifiedand is surfaced via/auth/me. Enforcement is opt-in viaEMAIL_VERIFICATION_REQUIRED(off by default so the pilot isn't disrupted — note flipping it on locks out existing unverified accounts until they verify). - TOTP MFA ships for owner/admin accounts (enrollment gated by
CONFIG_ROLES):POST /auth/mfa/enrollreturns the Base32 secret, provisioning URI, and 8 single-use recovery codes (codes are stored BCrypt-hashed, never plaintext);POST /auth/mfa/enroll/confirmactivates enrollment only after the operator proves they scanned it with a valid code;POST /auth/mfa/disablerequires a fresh TOTP or recovery code. Once confirmed,POST /auth/loginstops returning a token pair and instead issues a 5-minutemfa_sessionchallenge (mfa_required: true);POST /auth/mfa/verifyexchanges a TOTP code or an unconsumed recovery code for the real tokens. Recovery use is single-use and audited (auth.mfa.verifiedwithrecovery_used), and all lifecycle actions emitauth.mfa.*audit entries. The TOTP secret itself must be Base32-plaintext inmfa_settings(codes must be derivable at verify time) and is scoped by row-level security. - Tenant-owned tables require indexed, non-null company identifiers with foreign-key integrity.
- Audit records are append-oriented and retain actor, action, resource, tenant, and structured context.
- Secrets belong in the deployment secret manager or local
.env, never source control.
Before production: turn on EMAIL_VERIFICATION_REQUIRED; connect a real messaging provider so the in-app dev codes stop showing; wire the payments provider; CSRF-aware audit of cookie-based mutation routes (mutation proxies already enforce same-origin); strict Content-Security-Policy for the Next.js host; S3 upload scanning (if file uploads ship); database row-level-security policies (considering Supabase); and centralized security event monitoring. Rate limiting, account lockout, password reset, email verification (with a web verify-email page), TOTP MFA (with login challenge step + enrollment in Settings), and the hardened security-header set are done (covered by tests).