Skip to main content
Causeloop’s security model rests on one structural decision: staff and tenant identity are never the same session type. There is no shared cookie, no shared sessions table, and no code path that resolves one principal type from the other’s credentials. Everything below builds on that separation.

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 by employee:<email> or tenant:<tenant_id>:<email>.
Exceeding either raises 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>.
The in-process fallback is documented, not hidden, as a genuine limitation: it is per-process (doesn’t coordinate across API replicas) and resets on restart. It is acceptable for local single-process development only. GET /health/ready separately reports Redis readiness and, in production, defaults to requiring it: CAUSELOOP_REDIS_REQUIRED defaults to "true" whenever CAUSELOOP_ENVIRONMENT=production (and "false" otherwise), so a production deployment without a reachable REDIS_URL reports not_ready rather than silently running the single-process limiter — see Observability.

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): a control.tenant_invitations row is created with invite_token_hash, a 7-day INVITATION_LIFETIME, and the target role_profile_key. accept_invitation() validates the token is pending and unexpired, upserts the TenantUser row (ON CONFLICT ... DO UPDATE on (tenant_id, email), so re-accepting after a resend just updates the password), grants the invited role profile, marks the invitation accepted, and finally calls login_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 own password_reset_tokens): both use a 2-hour RESET_TOKEN_LIFETIME. confirm_*_password_reset() re-hashes the password, marks the token used_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.
Both request functions (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_log row (action="employee_impersonation", actor_id the employee, tenant_id, resource_id the 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. 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. 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/false are explicit overrides — except false in production, which raises at request time ("Production cannot disable Secure authentication cookies.").
  • auto (default): always true when CAUSELOOP_ENVIRONMENT=production; otherwise derived from the actual request (request.url.scheme == "https" or a trusted x-forwarded-proto: https) — this is what keeps local HTTP development and FastAPI’s TestClient (which talks to http://testserver) working without a hardcoded secure=True silently breaking cookie round-trips.

Local vs. production credential differences