Skip to main content
Causeloop has two independent, cookie-based session systems — there is no shared login, no JWT, and no Authorization: Bearer header anywhere on the product API. Both are implemented in services/api/auth/ and exposed through services/api/auth_api.py.

Employee session

cl_admin_session — Causeloop staff signing into the Onboarding Portal. Backed by services/api/auth/employee_auth.py.

Tenant session

cl_tenant_session — a customer’s own user signing into their workspace console. Backed by services/api/auth/tenant_auth.py.
The two cookies have different names, different formats, and are validated against entirely separate tables (control.employees/control.employee_sessions vs. each tenant schema’s users/user_sessions). A cl_tenant_session value is structurally meaningless to get_current_employee and vice versa — there is no shared principal type to confuse.

Staff session

admin@causeloop.local / Local-Admin-Pass1! is a local-only credential seeded into the product Compose stack (infra/docker-compose.product.yml). It does not exist anywhere outside local development.
admin_login() in auth_api.py:
  1. Rate-limits the attempt (see Rate limiting below) before touching the database.
  2. Looks up control.employees by email; a missing/inactive employee or a bad password both fail identically with 401 {"detail": "Invalid email or password."} — the response never reveals which half was wrong.
  3. Inserts a new control.employee_sessions row holding a hash of a freshly generated opaque token (services/api/auth/tokens.py), the caller’s IP, and user agent. Only the raw token goes in the cookie; the database never stores it in cleartext.
  4. Writes a control.audit_log row (employee_login).
  5. Sets cl_admin_session (httpOnly, SameSite=Lax, max_age = 12 hours, Secure per cookie attributes below) and a fresh cl_csrf cookie.
  6. Returns EmployeeMeResponse: id, email, full_name, control_role.
control_role is one of onboarding_admin or support_readonly (CONTROL_ROLES in employee_auth.py). Every mutating onboarding route additionally requires onboarding_admin via require_employee_role("onboarding_admin") — a support_readonly employee can authenticate and read everything under /admin/tenants/* but gets 403 on any write.
get_current_employee() (the dependency behind /admin/me and every /admin/tenants/* route) resolves the principal exclusively from the cl_admin_session cookie against a non-revoked, non-expired session joined to an active employee row — there is no header fallback and no default identity. A missing cookie is 401 {"detail": "No employee session."}; a hashed token that doesn’t match any live session is 401 {"detail": "Employee session expired or revoked."}. Employees are seeded by scripts/create_employee.py — there is no self-service employee signup route on this API.

Tenant session

workspace is the tenant’s slug (the same value shown by GET /workspaces, below — not a display name). login_tenant_user():
  1. Resolves the slug against control.tenants; a tenant that doesn’t exist or isn’t lifecycle_state = "live" fails as 401 {"detail": "Invalid workspace, email, or password."} — a tenant mid-onboarding is indistinguishable from a wrong password to an outside caller.
  2. Rate-limits the attempt, keyed per tenant so one workspace’s lockouts don’t affect another’s users with the same email.
  3. Verifies the user against that tenant’s own schema (tenant_uow), then mints a session.
  4. Sets cl_tenant_session (httpOnly, SameSite=Lax, max_age = 24 hours) whose value is "<tenant_slug>:<raw_token>" — not just the token. Each tenant owns its own session table (in its own Postgres schema), so the cookie has to carry which tenant’s table to check before any lookup can happen; get_current_tenant_user() splits on the first : to recover both halves.
  5. Returns TenantMeResponse: user_id, workspace, email, full_name, permissions (a sorted list of permission strings resolved from the user’s role profiles).
Tenant-scoped mutating routes use require_permission("<permission>") (e.g. sources.manage, caps.write, reviews.write, exports.create) rather than a role name directly — a 403 from one of these means the authenticated tenant user’s role profile doesn’t grant that specific permission, not that authentication failed.

GET /workspaces

Public, pre-login directory used by the login page’s workspace picker. Deliberately minimal — client_id (the slug), company, industry, status — and restricted to tenants where lifecycle_state = "live". It is intentionally a different, smaller read than the employee-authenticated GET /admin/tenants registry, which returns full tenant detail to staff only.

Invitations and /accept-invite

A tenant user is never created directly by an API call the user themselves makes. Staff invite them (POST /admin/tenants/{tenant_id}/invite, documented on Client onboarding pipeline), which:
  • Generates an opaque invite token (7-day lifetime, INVITATION_LIFETIME in tenant_auth.py), hashes it into control.tenant_invitations, and enqueues an email job carrying a link of the shape .../accept-invite?workspace={slug}&token={token}.
The invitee then completes onboarding themselves:
accept_invitation() validates the invitation is status = "pending" and unexpired (400 otherwise), upserts a TenantUser row (ON CONFLICT on (tenant_id, email) — accepting twice with a new password updates the existing user rather than erroring), grants the invitation’s role profile, marks the invitation accepted, and then calls the same login_tenant_user() path used by POST /login — so accepting an invite leaves the caller with a live cl_tenant_session/cl_csrf pair exactly as if they had logged in, no separate login step required.

Impersonation

Implemented in services/api/onboarding_api.py (not auth_api.py, since it’s an onboarding-staff action against a specific tenant, not a generic auth route), requiring onboarding_admin and CSRF. It mints a real cl_tenant_session for the tenant’s earliest-created active user via impersonate_tenant_user() — this is how “open portal” support actions work: an onboarding admin sees exactly what that tenant’s own admin would see, because they’re holding that user’s actual session, not a read-only preview. Two guardrails, both 409:
  • The tenant must be lifecycle_state = "live" ("Tenant is not live yet.").
  • The tenant must have at least one active user ("Tenant has no active users to impersonate.").
Every impersonation writes a control.audit_log row (employee_impersonation, actor = the employee, resource_id = the impersonated user) unconditionally — this is a real access grant into customer data, so it’s logged on every use, not just flagged as available. The response also sets a fresh cl_csrf cookie: the employee’s existing CSRF cookie is scoped to /admin/* writes made as that employee, and the new tenant session needs its own CSRF pairing to make tenant-scoped writes. The employee’s cl_admin_session is left untouched by impersonation — an onboarding admin ends up holding both cookies at once (their own staff session and the tenant session they just minted) until they explicitly log out of one or the other. All three cookies use SameSite=Lax and path="/". Secure is derived per-request by secure_cookie_flag() (services/api/auth/cookies.py), not hardcoded: it is forced true whenever CAUSELOOP_ENVIRONMENT=production (and disabling it in production raises on the first request that tries to set a cookie — it cannot be turned off there), and otherwise inferred from the request’s actual scheme (https:// or a trusted X-Forwarded-Proto: https) so local HTTP development and FastAPI’s test client keep working without a hardcoded secure=True silently breaking every cookie round-trip. Only session tokens are ever stored server-side — and only as a hash (services/api/auth/tokens.py’s hash_token()), never in cleartext. Logout revokes the session row (revoked_at set); it doesn’t merely delete the client’s cookie.

CSRF

Mutating routes (anything except GET/HEAD/OPTIONS) that rely on cookie-based session auth carry Depends(require_csrf). This is double-submit-cookie CSRF (services/api/auth/csrf.py): the non-httpOnly cl_csrf cookie’s value must exactly match an X-CSRF-Token request header, checked with hmac.compare_digest. A cross-site form can make the browser attach the cookie automatically, but same-origin JavaScript is required to read it back out and set the header — which is exactly the property this defends. There is no server-side CSRF token store; verification is pure equality, so it costs nothing per request. Login and /accept-invite routes are themselves exempt from require_csrf — there is no session yet for CSRF to protect at that point — but every route that acts on an existing session enforces it, including logout. A missing or mismatched token is 403 {"detail": "CSRF token missing or mismatched."}. src/lib/api/adminAuth.ts and src/lib/api/tenantAuth.ts on the frontend read the cookie (src/lib/authCookies.ts) and attach x-csrf-token on every mutating call automatically — if you’re calling the API directly (not through the console), you have to do this yourself.

How the Next.js proxy forwards all of this

The browser never calls api.causeloop.ai (or 127.0.0.1:18000) directly. Every request goes to the frontend’s own /api/backend/* route (src/app/api/backend/[...path]/route.ts), which:
  • Rejects the request with 401 {"detail": "no session"} before even reaching the backend if neither cl_admin_session nor cl_tenant_session is present — unless the path is one of a small public allowlist: admin/login, admin/password-reset/request, admin/password-reset/confirm, login, accept-invite, password-reset/request, password-reset/confirm, health, health/live, health/ready.
  • Forwards the browser’s Cookie header straight through — a same-origin reverse proxy relaying the caller’s own cookies to the real backend that reads them is expected behavior, not a leak.
  • Strips every inbound x-causeloop-* header before forwarding, so a client can’t spoof a trusted header the backend might read (e.g. a role/tenant override) by just sending it themselves.
  • Injects x-causeloop-client-ip from the trusted ingress’s X-Forwarded-For chain.
  • Relays every Set-Cookie from the backend response back to the browser using .append (not .set) — login responses carry two separate Set-Cookie headers (session + CSRF), and .set would silently keep only the last one.
  • Returns a stable 502 {"detail": "backend unavailable"} if the backend can’t be reached at all, instead of letting Next.js turn a raw fetch failure into an opaque 500.

Rate limiting on login

services/api/auth/rate_limit.py enforces two fixed-window budgets on every login attempt, checked before the password is verified:
  • Per IP: 20 attempts / 5 minutes (LOGIN_PER_IP)
  • Per account: 5 attempts / 15 minutes (LOGIN_PER_ACCOUNT) — keyed employee:{email} for staff logins, tenant:{tenant_id}:{email} for tenant logins (so the same email in two different workspaces has independent budgets)
Both POST /admin/login and POST /login check this; POST /accept-invite inherits it too, since accept_invitation() finishes by calling login_tenant_user() internally. Exceeding either budget returns:
with a Retry-After header (seconds until the oldest attempt in the window ages out). The limiter is Redis-backed (REDIS_URL) when configured — the only correct choice across multiple API replicas — and falls back to a per-process in-memory counter otherwise, which is a genuine, documented limitation (resets on restart, doesn’t coordinate across workers) acceptable only for local single-process development, never production.