Skip to content

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:

Every request resolves to exactly one of five credential shapes, each unlocking a different slice of the API:

CredentialOrg-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 trueCan 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:

Terminal window
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:

Terminal window
curl -H "X-API-Key: xK9mZ..." http://localhost:8000/analyzers

Human 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:

Terminal window
curl -X POST http://localhost:8000/auth/register \
-H "Content-Type: application/json" \
-d '{"email": "[email protected]", "password": "at-least-8-chars", "org_name": "My Org"}'
# → 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_token instead of org_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.

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.

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:

ActionEndpoint
Account deactivated platform-widePATCH /admin/users/{user_id} with is_active: false
Password reset by an adminPOST /admin/users/{user_id}/reset-password, POST /orgs/{org_id}/members/{user_id}/reset-password
Two-factor reset by an adminPOST /admin/users/{user_id}/2fa-reset, POST /orgs/{org_id}/members/{user_id}/2fa-reset
Platform-admin rights granted or revokedPOST /admin/users/{user_id}/superadmin
Password changed by the userPOST /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.

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:

Terminal window
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 safe

2FA 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:

Terminal window
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 set

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

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.