Two independent authentication systems
Both modules follow the same pattern for a login: rate-limit check → constant-time credential comparison via
argon2 → mint a random token (secrets.token_urlsafe(32), services/api/auth/tokens.py) → store only its SHA-256 hash → set the cookie httponly=True, samesite="lax", secure= derived from the request (see below). A leaked database dump reveals no usable session tokens; a leaked token is meaningless without its matching hashed row, and is further scoped to the correct principal type because the two session tables have nothing in common.
The tenant cookie’s "<slug>:<token>" shape exists because each tenant owns its own user_sessions table — resolving the session requires first resolving which tenant schema to even look in, and the slug is how get_current_tenant_user() does that before opening the tenant’s tenant_uow().
Password hashing
services/api/auth/hashing.py is the only place a plaintext password is ever compared against stored state, using argon2id (via the argon2 package’s PasswordHasher, its own defaults). needs_rehash() flags a stored hash created under weaker parameters than the hasher’s current defaults, so a route can opportunistically re-hash on next successful login after a parameter upgrade.
Redis-backed login rate limiting
services/api/auth/rate_limit.py enforces two fixed-window budgets on every login attempt, checked before password verification and recorded regardless of whether the attempt succeeds:
LOGIN_PER_IP: 20 attempts / 300 seconds, keyed by client IP.LOGIN_PER_ACCOUNT: 5 attempts / 900 seconds, keyed byemployee:<email>ortenant:<tenant_id>:<email>.
RateLimitExceededError, which both login_employee() and login_tenant_user() turn into 429 with a Retry-After header.
The limiter backend is chosen at first use: if REDIS_URL is set, a _RedisLimiter does an atomic INCR/TTL pipeline per key (correct across multiple API processes/replicas); otherwise an in-process _InProcessLimiter (a thread-safe sliding list per key) is used. All keys are namespaced by CAUSELOOP_RATE_LIMIT_NAMESPACE (default causeloop), e.g. causeloop:ratelimit:login:ip:<ip>.
Invitation and password reset token flows
Both flows share the same token discipline as sessions — a random URL-safe token is emailed, only its SHA-256 hash is stored:- Tenant invitation (
create_invitation,services/api/auth/tenant_auth.py): acontrol.tenant_invitationsrow is created withinvite_token_hash, a 7-dayINVITATION_LIFETIME, and the targetrole_profile_key.accept_invitation()validates the token ispendingand unexpired, upserts theTenantUserrow (ON CONFLICT ... DO UPDATEon(tenant_id, email), so re-accepting after a resend just updates the password), grants the invited role profile, marks the invitationaccepted, and finally callslogin_tenant_user()to mint a real session — invitation acceptance and login are one continuous flow, not two separate steps a client has to orchestrate. - Password reset (employee:
control.password_reset_tokens; tenant user: the tenant schema’s ownpassword_reset_tokens): both use a 2-hourRESET_TOKEN_LIFETIME.confirm_*_password_reset()re-hashes the password, marks the tokenused_at, and — critically — revokes every other active session for that principal, so a password reset also invalidates any session an attacker may have already established.
request_tenant_password_reset, request_employee_password_reset) return silently on an unknown email — they never reveal whether an address exists in the system.
Impersonation
POST /admin/tenants/{tenant_id}/impersonate (onboarding_api.py) is the Onboarding Portal’s “open portal” action: it mints a real cl_tenant_session for the tenant’s earliest-created active user (impersonate_tenant_user(), services/api/auth/tenant_auth.py), gated to tenants in lifecycle_state="live". This is a genuine access grant into customer data — the staff member’s browser receives a working tenant session cookie and can see exactly what that tenant’s own admin sees — so it is treated as one:
- It requires the mutating-route CSRF dependency (
Depends(require_csrf)) in addition to employee authentication. - Every use writes a
control.audit_logrow (action="employee_impersonation",actor_idthe employee,tenant_id,resource_idthe impersonated user’s id,detail={"impersonated_email": ...}) unconditionally — not opt-in, not sampled. There is no code path that impersonates without leaving this record. - The resulting session gets its own fresh CSRF cookie (the employee’s existing CSRF cookie is scoped to
/admin/*writes, not the new tenant session).
Credential encryption (services/api/secrets.py)
Two categories of secret are stored encrypted, never in plaintext: per-tenant LLM API keys (llm_settings.encrypted_api_key) and source connector credentials (sources.encrypted_credentials). Both go through the same versioned envelope scheme, chosen by CAUSELOOP_SECRET_PROVIDER:
Every ciphertext is a self-describing JSON envelope prefixed
clsec:v1: or clsec:v2:, recording which provider and key encrypted it — so rotating providers or keys doesn’t strand already-written rows; decrypt_secret() reads the envelope’s own provider tag rather than assuming the currently configured one. Untagged legacy ciphertext (written before the envelope scheme) is still decryptable via a Fernet fallback for a controlled migration window.
GET /health/ready’s credential_encryption check (secret_encryption_readiness()) reports whether the configured provider is actually usable — for fernet specifically, ready is false in production even if SOURCE_CREDS_KEY happens to be set, because Fernet itself is disqualified there.
Redaction
An API key is only ever accepted, never returned.PUT /admin/tenants/{tenant_id}/training-config’s llm_api_key field is write-only (“omit to keep the existing key” — its own docstring), and the response returns only llm_api_key_hint — a last-4-characters mask (secrets.py’s hint(): "*" * (len - 4) + plaintext[-4:], or all asterisks if 4 characters or fewer). The raw key and its ciphertext never appear in any response body.
Encrypted email outbox
Invitation and password-reset emails are sent through the same durable job queue as everything else (control.platform_jobs), but the payload is never plaintext: build_email_job_payload() (services/api/auth/mailbox.py) JSON-encodes {to, subject, body} — which includes the raw invitation/reset token embedded in the link — and immediately encrypts it with encrypt_secret() before it is ever written to platform_jobs.payload. The worker that claims the job decrypts it only at send time (deliver_email_job()) and calls the configured provider (console for local dev logging, local-smtp for the containerized Mailpit-backed stack, aws-ses/azure-communication in the cloud). No plaintext token or email address is ever committed to the database — only ciphertext, from the moment the job is enqueued to the moment a worker decrypts it in memory to hand to the provider.
email_delivery_readiness() validates the full provider contract (a valid CAUSELOOP_PUBLIC_APP_URL, forced to HTTPS in production; provider-specific required env vars) without ever sending mail or exposing a secret — this is also one of GET /health/ready’s checks.
Data policy and per-tenant PII isolation
Each tenant sets its own data policy (PUT /admin/tenants/{tenant_id}/data-policy): residency (US/EU/APAC), retention (5/7/10 years), and pii_redaction — stored in that tenant’s own settings.key_values JSONB, never in a shared table.
Field-level PII masking is separate and more granular: <tenant schema>.pii_field_policies lists dotted field paths (e.g. customer.email) each with a mask_strategy of redact, hash, or partial. services/api/auth/pii_masking.py’s mask_payload() walks a response dict (through nested dicts and lists of dicts) and applies the tenant’s policies to any principal lacking the pii.unmasked permission — this is dict-based rather than tied to a specific Pydantic model precisely so it can be applied uniformly to whatever payload shape a route returns, right before the response leaves the process.
CSRF (double-submit cookie)
services/api/auth/csrf.py protects every mutating route (POST/PUT/PATCH/DELETE) that relies on cookie-based session auth. On login/accept-invite, the backend sets a readable (non-httpOnly) cl_csrf cookie alongside the session cookie. The frontend reads it client-side (readCsrfToken(), src/lib/authCookies.ts) and echoes it as the x-csrf-token header on every mutating request (tenantSend(), src/lib/api/tenantAuth.ts). require_csrf() rejects the request with 403 unless the cookie and header match via hmac.compare_digest (constant-time comparison). This works because a cross-site attacker’s forged form submission can make the browser send the cookie automatically, but same-origin JavaScript is required to read the cookie and set the matching header — exactly the property CSRF needs to defend against.
Cookie-setting routes themselves (login, accept-invite) are exempt — there is no session yet to protect on those calls.
Cookie security flag
secure_cookie_flag() (services/api/auth/cookies.py) decides the Secure attribute per request rather than hardcoding it, controlled by CAUSELOOP_COOKIE_SECURE (auto default, or true/false):
true/falseare explicit overrides — exceptfalsein production, which raises at request time ("Production cannot disable Secure authentication cookies.").auto(default): alwaystruewhenCAUSELOOP_ENVIRONMENT=production; otherwise derived from the actual request (request.url.scheme == "https"or a trustedx-forwarded-proto: https) — this is what keeps local HTTP development and FastAPI’sTestClient(which talks tohttp://testserver) working without a hardcodedsecure=Truesilently breaking cookie round-trips.