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

Payment webhooks are signature-verified and idempotent. The production provider is Paddle (merchant of record): /api/v1/webhooks/paddle verifies an HMAC-SHA256 signature over ts:body with a 5-minute freshness window, dedupes event IDs through the webhook_events table, and applies plan changes idempotently. A Stripe endpoint (/api/v1/webhooks/stripe, verified via stripe.Webhook.construct_event) exists as an alternative provider. With provider credentials unset both run in dev mode and return 200 {"status":"ignored"}. See How to upgrade, downgrade, or cancel for the billing flow.

A cloud run refuses to open a target that resolves to a loopback, private, link-local, reserved, multicast or unspecified address, or that uses a scheme other than http/https. Cloud metadata endpoints are named and refused explicitly. The step fails with a message pointing at the local agent, which is the supported way to test something inside your own network.

This check runs on the URL a step asks for. It is a hard boundary for the obvious path, but it is not a full network sandbox: a page is free to redirect, load subresources and issue its own requests after it opens, and none of that passes back through the check. Treat cloud runs as “public internet only” and use a local agent when the target is genuinely internal.

  • 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.
  • Egress proxying for cloud runs — the target check above validates the URL a step opens, not everything the page then does. Full containment needs all browser traffic to leave through a proxy that re-validates each request; that service isn’t built. Until it is, cloud runs are for public targets.
  • 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