> ## Documentation Index
> Fetch the complete documentation index at: https://docs.causeloop.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# Security model

> Staff vs tenant authentication, Redis-backed login rate limiting, invitation/reset token flows, impersonation auditing, credential encryption, and per-tenant PII isolation.

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

|               | Staff (Onboarding Portal)                                             | Tenant user (Console)                         |
| ------------- | --------------------------------------------------------------------- | --------------------------------------------- |
| Cookie        | `cl_admin_session`                                                    | `cl_tenant_session`                           |
| Cookie value  | Opaque token, hashed server-side                                      | `"<tenant_slug>:<opaque token>"`              |
| Session table | `control.employee_sessions`                                           | `<tenant schema>.user_sessions`               |
| Lifetime      | 12 hours (`SESSION_LIFETIME` in `services/api/auth/employee_auth.py`) | 24 hours (`services/api/auth/tenant_auth.py`) |
| Resolved by   | `get_current_employee()`                                              | `get_current_tenant_user()`                   |
| Self-signup   | None — seeded by `scripts/create_employee.py`                         | None — created only via invitation acceptance |

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>`.

<Warning>
  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](/architecture/observability).
</Warning>

## 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`:

| Provider           | Mechanism                                                                                                           | Where it's valid                                                                                                                              |
| ------------------ | ------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- |
| `fernet` (default) | Symmetric encryption keyed by `SOURCE_CREDS_KEY`                                                                    | **Local development only** — `encrypt_secret()` raises at call time if `CAUSELOOP_ENVIRONMENT=production` and the provider is still `fernet`. |
| `aws-kms`          | Envelope encryption: a fresh AES-256-GCM data key per secret, itself encrypted by an AWS KMS key (`AWS_KMS_KEY_ID`) | Production on AWS.                                                                                                                            |
| `azure-key-vault`  | Same envelope pattern, data key wrapped by an Azure Key Vault key (`AZURE_KEY_VAULT_KEY_ID`, RSA-OAEP-256)          | Production on Azure.                                                                                                                          |

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`/`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

| Concern                | Local                                                                                                                             | Production                                                                                                       |
| ---------------------- | --------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------- |
| Secret encryption      | Fernet (`SOURCE_CREDS_KEY`)                                                                                                       | AWS KMS or Azure Key Vault — Fernet is explicitly rejected                                                       |
| Email provider         | `console` (logged, not sent) or `local-smtp` (Mailpit)                                                                            | `aws-ses` or `azure-communication` — local providers explicitly rejected                                         |
| Cookie `Secure`        | Derived from request scheme (usually `false` over plain HTTP)                                                                     | Always `true`, and cannot be disabled                                                                            |
| Redis / rate limiting  | Optional — falls back to in-process limiter                                                                                       | Required by default (`CAUSELOOP_REDIS_REQUIRED` defaults `true`); `/health/ready` reports `not_ready` without it |
| Local staff credential | `admin@causeloop.local` / `Local-Admin-Pass1!` (seeded by the product Compose stack — local-only, never valid outside that stack) | N/A — accounts are seeded per environment by `scripts/create_employee.py`                                        |

## Related

* [Tenancy and data model](/architecture/tenancy-and-data-model)
* [Observability](/architecture/observability)
* [Authentication](/api-reference/authentication)
* [Invitations and impersonation](/features/invitations-and-impersonation)
