Quick Start: Authentication
By default the server is open — every request resolves to the anonymous owner of a default organization. Two ways to add real auth, both scoped per-org:
Access scope at a glance
Section titled “Access scope at a glance”Every request resolves to exactly one of five credential shapes, each unlocking a different slice of the API:
| Credential | Org-scoped endpoints (/orgs/*, rules, findings, …) | /admin/* (cross-tenant) | POST /orgs / POST /auth/register with org_name (create an org) | Notes |
|---|---|---|---|---|
Anonymous (auth.api_key: null) | ✅ as owner of the default org | ✅ (auto-superadmin) | ❌ | Every request resolves to the same principal — local dev only |
Bootstrap key (config.auth.api_key, sent as X-API-Key) | ✅ as owner of the default org | ✅ (auto-superadmin) | ❌ | Root-equivalent within default and across every org’s admin data — but still has no user_id behind it, so anything gated on being a logged-in human stays out of reach |
Service-account API key (POST /auth/keys, X-API-Key) | ✅ scoped to the org it was created in, at its assigned OrgRole | ❌ | ❌ | The everyday machine-to-machine credential — CI, MCP clients, most Terraform-provider resources |
| Human session — ordinary member | ✅ scoped to whichever org the session is currently switched to, at that OrgRole | ❌ | Only if registration.allow_public_org_creation is true | Can belong to multiple orgs; POST /auth/session/switch moves between them |
Human session — superadmin (is_superadmin: true) | ✅ same as above, for orgs they belong to | ✅ | ✅ always (exempt from the toggle) | The only credential that can do everything — see Platform Admin |
OrgRole (viewer < member < admin < owner) and is_superadmin are independent axes — a
principal’s role within an org says nothing about whether it can see across every org, and vice
versa. See Platform Admin for the full is_superadmin reference, including
GET /admin/organizations/{org_id} vs GET /orgs/{org_id} — the former is the cross-tenant
view a superadmin needs to reach an org it isn’t a member of, since the latter 404s for any
session not already scoped to that exact org.
Bootstrap admin key (single org, simplest)
Section titled “Bootstrap admin key (single org, simplest)”auth: api_key: "change-me-to-a-strong-secret"That key acts as the permanent owner of the default org. Create scoped service accounts — named, role-scoped keys, optionally restricted to a set of source IPs — for CI pipelines:
curl -X POST http://localhost:8000/auth/keys \ -H "X-API-Key: change-me-to-a-strong-secret" \ -H "Content-Type: application/json" \ -d '{"role": "member", "name": "ci-pipeline", "ip_allowlist": ["203.0.113.0/24"]}'name and ip_allowlist are both optional. ip_allowlist entries are bare IPs ("203.0.113.5", treated as /32) or CIDR blocks ("203.0.113.0/24"); omit it (or send null) for a key usable from anywhere. A request from outside the allowlist gets 403 — the key itself is valid, it’s just not usable from there, distinct from the 401 an unrecognised or revoked key gets. Behind a reverse proxy, the leftmost X-Forwarded-For hop is what’s checked, not the proxy’s own address — see Service accounts for the full IP-matching semantics.
Pass the key in subsequent requests:
curl -H "X-API-Key: xK9mZ..." http://localhost:8000/analyzersHuman registration (multi-tenant, real orgs)
Section titled “Human registration (multi-tenant, real orgs)”For a real multi-tenant deployment — multiple independent teams, each with their own org — humans register directly instead of using the bootstrap key:
curl -X POST http://localhost:8000/auth/register \ -H "Content-Type: application/json" \# → sets a session cookie; you now own a brand-new organization as its "owner"Every subsequent request from that session (or an API key created within that org) is scoped to it — completely isolated from every other org’s resources, findings, and billing.
org_name is optional. Two other ways to register:
- Join an existing org via an invite link — send
invite_tokeninstead oforg_name; you land directly in that org at the role the link grants. See Invitations for how an admin creates one. - Register with no org at all — send neither. The account exists and can log in, but every org-scoped endpoint 403s until it creates an org (
POST /orgs) or accepts an invite — this is the dashboard’s “you’re signed in but not part of an organization yet” state.
Roles, from least to most privileged: viewer < member < admin < owner. member covers the full analysis workflow (rules, ingest, analyze, findings triage); admin/owner are required for org settings (service accounts, invitation links, LLM credentials, quota, analyzer load/unload, GitHub App integration).
A logged-in user can belong to more than one org. GET /auth/me and GET /auth/me/organizations recover “who am I / which orgs” from the session cookie alone (used by the dashboard on page load), and POST /auth/session/switch re-scopes the session cookie to a different org the user is a member of.
Profile and password
Section titled “Profile and password”A signed-in user can set their own display name and change their own password from the
dashboard’s Account page (PATCH /auth/me, POST /auth/me/password — see
API Reference — Session). Changing your password requires the
current one; there’s no email-based reset flow, since this codebase has no outbound-email
infrastructure — an org-admin or platform admin resetting someone else’s password instead
generates a one-time temporary password (see Members and
Platform Admin — Managing members). None of this applies
to an account created through SSO or a sign-in provider (auth_provider != "local") — it has no
local password to change or reset, and the Account page doesn’t offer to (GET /auth/me reports
has_password: false).
Changing your password signs the account out everywhere else: every other browser and device holding a session for it is rejected from its next request onward, while the browser that made the change stays signed in on a replacement cookie returned with the response. That asymmetry is the point — the usual reason to change a password is that someone else may be in the account, and a live session never re-checks the password on its own.
Session lifetime and revocation
Section titled “Session lifetime and revocation”A session cookie is a signed JWT that lives for session.access_ttl_minutes (default 480
— eight hours, roughly a working day; adjustable from PATCH /admin/settings). That TTL is
how long a session lasts if nothing interrupts it. It is not how long a withdrawn session
keeps working:
Every request made with a session cookie re-reads the account behind it and rejects the cookie with 401 if the account has been deactivated, or if its sessions have been revoked since the cookie was issued. Both take effect on the target’s very next request — there is no window in which a turned-off account keeps working because its token had not expired yet. The dashboard treats that 401 the same way it treats any other: it redirects to the login page.
These revoke every session an account holds:
| Action | Endpoint |
|---|---|
| Account deactivated platform-wide | PATCH /admin/users/{user_id} with is_active: false |
| Password reset by an admin | POST /admin/users/{user_id}/reset-password, POST /orgs/{org_id}/members/{user_id}/reset-password |
| Two-factor reset by an admin | POST /admin/users/{user_id}/2fa-reset, POST /orgs/{org_id}/members/{user_id}/2fa-reset |
| Platform-admin rights granted or revoked | POST /admin/users/{user_id}/superadmin |
| Password changed by the user | POST /auth/me/password — every session except the caller’s own |
A deactivated account also cannot sign back in, by password, through SSO, or through a sign-in provider.
Two-factor authentication
Section titled “Two-factor authentication”A signed-in user with a local (email/password) account can add TOTP-based two-factor authentication (Google Authenticator, Authy, 1Password, or any RFC 6238-compatible app) from the dashboard’s Account page — not available for SSO-backed accounts, which have no local password either:
curl -X POST http://localhost:8000/auth/me/2fa/enroll \ -H "Cookie: mcp_session=..."# → {"secret_provisioning_uri": "otpauth://...", "manual_entry_code": "JBSW..."}# scan the URI as a QR code, or enter manual_entry_code by hand, then:curl -X POST http://localhost:8000/auth/me/2fa/confirm \ -H "Cookie: mcp_session=..." -H "Content-Type: application/json" \ -d '{"code": "123456"}'# → {"recovery_codes": ["a1b2c3...", ...]} — 10 one-time codes, shown once, store them somewhere safe2FA isn’t active until confirm succeeds — a secret from enroll alone is pending, not
enforced. Once enabled, POST /auth/session no longer sets a session cookie directly: it
returns {"mfa_token": "...", "methods": [...]} instead, redeemed by a second call:
curl -X POST http://localhost:8000/auth/session/2fa \ -H "Content-Type: application/json" \ -d '{"mfa_token": "...", "code": "123456"}'# → same response shape as a normal successful login, session cookie setcode is checked as a current TOTP code first, then as an unused recovery code — either
completes login the same way. A matched recovery code is consumed and can’t be reused; generate
a fresh set by disabling and re-enrolling. POST /auth/me/2fa/disable requires the current
password, same as changing it.
Lost the device and the recovery codes? An org-admin or platform superadmin can reset a
member’s 2FA from the dashboard’s Members page (or POST /orgs/{org_id}/members/{user_id}/reset-2fa / the /admin/organizations/{org_id}/members/{user_id}/2fa-reset
mirror), clearing the enrollment so they can start over — see Platform Admin — Managing
members.
Next: SSO and SCIM
Section titled “Next: SSO and SCIM”Need an organization’s identity provider (Entra ID, Okta, Google, …) handling login and/or user provisioning instead of local passwords and manual invites? See Quick Start: SSO & SCIM.