Skip to content

Security model

This is the access-control side of Data handling. Read both before answering procurement security questions.

Two ways to authenticate, picked per use case.

Email/password and OAuth (Google, GitHub) accounts produce a JWT access token + refresh token pair on login. The web app stores them and sends the access token as Authorization: Bearer <jwt> on every protected request.

  • Access token lifetime: 30 minutes by default (JWT_ACCESS_TOKEN_EXPIRE_MINUTES).
  • Refresh token lifetime: 7 days by default (JWT_REFRESH_TOKEN_EXPIRE_DAYS).
  • Algorithm: HS256 (JWT_ALGORITHM), signed with JWT_SECRET_KEY. The settings module refuses to start in production if these secrets are still at their default placeholder values.
  • Revocation: every JWT carries a session ID (sid). Logging out, revoking via Active Sessions, or any session being deleted invalidates the token even if it hasn’t expired — the auth middleware checks the session record on every request.

API keys are 64 hex characters prefixed with ak_live_. Sent the same way: Authorization: Bearer ak_live_•••.

  • Storage: only the SHA-256 hash and the masked prefix are persisted. The raw key is shown once on creation, never again.
  • Scope: each key belongs to exactly one organization.
  • Lifetime: keys never expire on their own — you control rotation.
  • Revocation: immediate. Subsequent requests get 401 Invalid or revoked API key.
  • Audit: last_used_at updates on every successful auth (visible on the Settings → Security → API Keys row).

See API keys for the lifecycle workflow.

Email/password signups must verify the address before a session is issued. Verification links expire after 15 minutes (EMAIL_VERIFICATION_EXPIRE_MINUTES).

Bcrypt with a cost factor that bcrypt’s gensalt() chooses by default. We never log passwords.

Every authenticated request resolves to a user and an organization context. The org context comes from:

  1. The ak_live_ API key (each key maps to one org), or
  2. The X-Org-Id request header (web sessions can switch orgs), or
  3. The user’s personal org as a fallback.

The middleware then verifies the user has an active membership in that org. If not: 403 Not a member of this organization. If the org doesn’t exist: 404.

From that point on, every database query in the request lifecycle filters by org_id. Cross-org reads return 403.

Four roles per org:

RoleAPI keysSchedulesMembersTests
OwnerFullFullFullFull
AdminFullFullFull (except other admins)Full
MemberView onlyRun + editView onlyRun + edit
ViewerView onlyView onlyView only

Role checks are enforced at the route layer using require_admin_or_owner, permissions middleware, and the org context. See Inviting team members for the constraints (admin can’t change another admin, owner can’t be removed, etc.).

Every API response (regardless of route) carries:

  • X-Content-Type-Options: nosniff
  • X-Frame-Options: DENY
  • X-XSS-Protection: 1; mode=block
  • Referrer-Policy: strict-origin-when-cross-origin

API responses additionally get Cache-Control: no-store, no-cache, must-revalidate so credential-bearing data doesn’t sit in intermediate caches.

CORS allows credentialed requests (cookies + Authorization header) from origins explicitly listed in CORS_ORIGINS. Methods: GET, POST, PUT, DELETE, OPTIONS. Headers: Authorization, Content-Type, X-Requested-With, X-Org-Id. Star-CORS (*) is never used.

Active sessions surface under Settings → Security → Active Sessions. Each session row shows the device label and last-used time. Two actions:

  • Revoke individual session — instantly invalidates that session’s tokens.
  • Sign out everywhere — revokes every session except the current one.

Refreshing a token issues a new access/refresh pair against the same underlying session record (the sid doesn’t change, but the session’s last_used_at updates). If the session has been deleted in the meantime, the refresh request is rejected with 401 Session has been revoked.

  • JWT_SECRET_KEY and SESSION_SECRET_KEY must be changed from defaults in production. The settings model raises ValueError on startup if ENVIRONMENT=production and either is still the placeholder.
  • API provider keys (OPENAI_API_KEY, ANTHROPIC_API_KEY, etc.) are read from environment variables. They aren’t logged; we mask tokens after the first 8 characters in any log line that touches them.
  • Database URL uses connection pooling sized via DATABASE_POOL_SIZE / DATABASE_MAX_OVERFLOW.

The Stripe webhook endpoint exists at /api/v1/webhooks/stripe but returns 503 today — Stripe isn’t wired up. When it ships, signature verification (stripe.Webhook.construct_event) and event-id deduping will be enforced on the handler. See How to upgrade, downgrade, or cancel for the current Mock Stripe stub.

  • MFA / 2FA — not in the codebase today.
  • SAML SSO — Enterprise tier feature, planned. Google Workspace SSO is on Team and Enterprise (google_sso=True in tier catalog).
  • Audit log API — Enterprise tier feature, planned (audit_logs flag is set; the user-facing log isn’t shipped).
  • IP allowlists / geo-restrictions — not implemented.
  • Per-key scoping — keys are org-wide, not endpoint-scoped or read-only. Rotate frequently if you’re embedding keys in shared CI environments.
  • Data handling — what’s stored, where it lives, what gets sent to AI providers
  • API keys — creating, rotating, revoking
  • Plan tier limits — which security features (SSO, audit logs) ship at which tier