Smart Agent Teams

Security

How SAT authenticates people and runners, what it authorizes, where secrets live and what it stores.

This page describes the security model as implemented. It covers authentication, API keys, authorization, CORS, transport, secrets, stored data, the audit log and rate limiting. For the runner's view (what an agent process can do), see Runner security.

Authentication

Every request outside the public endpoints carries Authorization: Bearer <token>. The token is either a JWT access token or a personal API key.

Public endpointPurpose
GET /, GET /healthService info and health
POST /api/registerCreate an account
POST /api/loginSign in with email and password
POST /api/refreshExchange a refresh token for a new access token
POST /api/password/forgot, POST /api/password/resetPassword reset

get_current_user decides how to read the token: a value starting with sat_live_ or sat_test_ is an API key; anything else is decoded as a JWT.

Tokens

PropertyValue
AlgorithmHS256, signed with JWT_SECRET_KEY
Access token lifetime30 minutes. Claims: sub (user id), type: access, iat, exp
Refresh token lifetime7 days. Claims: sub, type: refresh, jti, iat, exp
Refresh token storageSHA-256 of the token in refresh_tokens, with is_active and expires_at
RefreshPOST /api/refresh returns a new access token only. The refresh token is not rotated and stays valid until it expires or is revoked.
LogoutPOST /api/logout with a refresh_token revokes that one session; without one it revokes every refresh token for the user. It requires a session access token: an API key gets 403 SESSION_REQUIRED. Access tokens already issued stay valid until they expire (at most 30 minutes).
Password hashingbcrypt (passlib)

JWT_SECRET_KEY is mandatory unless APP_ENV (or, if APP_ENV is unset, ENVIRONMENT) is development, dev, local or test. The check trims and ignores case, and an unset value counts as development. Only in those environments does the API fall back to a fixed, public development key; with any other value (staging, production, prod, a typo) and no secret, it refuses to start. Never run an internet-facing API with the fallback. See Configuration.

Rotating JWT_SECRET_KEY (a new secret version, then a new revision) invalidates every access and refresh token at once; everyone signs in again. API keys are not JWTs and keep working.

Login lockout

  • Five consecutive wrong passwords lock the account for 30 minutes (account_locked_until). Sign-in then answers 400 ACCOUNT_LOCKED even with the right password.
  • A successful sign-in resets the counter. Completing a password reset also clears the lock.
  • When a lock has expired, the next attempt starts the counter again from zero.
  • An unknown email and a wrong password both answer 401 INVALID_CREDENTIALS.
  • The lockout is per account.

Password reset

  • POST /api/password/forgot always answers 202 with the same message, whether or not the email has an account. The email is sent in the background after the response.
  • The link carries a random 32-byte token (secrets.token_urlsafe(32)). Only its SHA-256 is stored. It expires after 30 minutes and works once.
  • Requesting a new link voids earlier unused links. At most one link per account per 60 seconds; extra requests inside that window send nothing.
  • Completing a reset sets password_changed_at, revokes every refresh token, rejects every access token issued before the change (iat earlier than password_changed_at), and rejects every API key created before it (created_at earlier than password_changed_at, compared to the second). Passwords must be 8 to 128 characters, at registration and at reset.
  • Rate limits: 5 requests per minute per client address on /forgot, 10 per minute on /reset.

See Account for the user-facing flow.

API keys

Personal API keys are long-lived bearer credentials for the CLI, runners and CI. Manage them at /api/me/api-keys or with sat apikey.

PropertyValue
Formatsat_live_ followed by 32 URL-safe characters
StorageSHA-256 in api_keys.key_hash (unique index). The secret is returned once, at creation.
Displayprefix: the first 13 characters
ExpiryNone by default; optional expires_in_days from 1 to 3650
Per-user limit20 active keys; names unique among active keys. Revoked and expired keys do not count and are not listed
PowersExactly the owning user's powers, in every company the user belongs to
ManagementCreating and revoking keys requires a password session, on /api/me/api-keys and on the legacy /api/users/{username}/api-keys routes. A request authenticated by an API key gets 403 SESSION_REQUIRED. Listing works with either.
RevocationDELETE /api/me/api-keys/{key_id} sets is_active = false; the key stops working on the next request
last_usedUpdated at most once a minute per key

A key stops working when it is revoked, when it expires, when its user is deactivated, or when its user completes a password reset after the key was created. A reset therefore cuts off every runner and script; create new keys afterwards. The reset revokes those keys, so they leave the key list and stop counting toward the limit.

get_session_user is the dependency behind the session-only rule. It refuses API keys on key creation and revocation and on POST /api/logout, so an unattended key holder, such as an agent acting through a runner, cannot mint keys or end its owner's sessions.

Authorization

SAT's authorization model is company membership plus one admin check.

RuleImplementation
Every path under /api/companies/{company_id} requires membershipget_company_context loads the caller's company_members row
Non-members get 404 NOT_FOUND, not 403So company ids cannot be probed
Rows are loaded only if they belong to the company in the pathget_owned filters on company_id; an id from another company is 404
Changing the company (PATCH /api/companies/{company_id}) requires role owner or adminrequire_admin; others get 403 FORBIDDEN
Everything else is open to any memberHiring, pausing and terminating agents, tasks, goals, projects, routines, runs, and deciding approvals
Creating a company makes the caller its ownerPOST /api/companies
API key management is per userA user can only list or revoke their own keys

There is no endpoint to add members, change roles or remove members. In practice each company has its creator as its only member unless rows are added to company_members directly. See Companies.

Acting as an agent

Writes to tasks and comments accept a run_id. When the run is running and belongs to the company, the write is attributed to the run's agent (created_by_agent_id, author_agent_id, audit actor type agent). The runner passes it through SAT_RUN_ID.

The API checks that the run exists in the company and is running. Treat agent attribution as a record for the audit trail, not as proof of origin.

CORS

SettingValue
Allowed originsALLOWED_ORIGINS, comma-separated; * when unset
CredentialsAllowed
MethodsGET, POST, PUT, DELETE, OPTIONS, PATCH
HeadersAny
Exposed headersX-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset

The API authenticates with bearer headers, not cookies, so CORS is not what protects it from cross-site requests. It still matters for live events: the browser opens the SSE stream on the API's direct URL, a different origin from the web app, so the web origins must be listed. Always set ALLOWED_ORIGINS explicitly in production.

Transport

In the reference deployment (see Architecture):

  • Web app. Served over HTTPS by Firebase Hosting on your domain. /api/** and /health are rewritten to the Cloud Run service on the same origin.
  • API. Cloud Run terminates TLS on *.run.app. The container listens on plain HTTP port 8080 behind it.
  • Unauthenticated invocation. The browser must reach the SSE stream on the Cloud Run URL directly, without Google credentials, so the service allows unauthenticated invocation. The API's own authentication protects every route that is not public.
  • Database. Give the database no public IP and reach it over a private network, for example Direct VPC egress to its private IP. The connection URL does not set sslmode unless you add it.
  • Host header. ALLOWED_HOSTS can restrict accepted Host values. Leave it unset on Cloud Run, because Cloud Run, Firebase Hosting and tagged revision URLs use different hosts.

Secrets

SecretWhere it should liveWho reads it
Database URL with passwordYour secret store, for example Secret ManagerThe API's runtime service account, as DATABASE_URL
JWT signing keyYour secret store (at least 48 random bytes, hex)The API's runtime service account, as JWT_SECRET_KEY
SMTP passwordYour secret store, when email is set upThe API's runtime service account, as SMTP_PASSWORD
Deploy credentialsPrefer short-lived credentials, for example Workload Identity Federation, over stored keysYour deploy pipeline
npm token for @yarlisai/uiA read-only token held as a CI secretCI builds of the web app

On Cloud Run, attach secrets with --set-secrets ...:latest, so a new secret version takes effect on the next revision. Nothing secret is compiled into the web bundle: apps/web/.env.* hold only public URLs.

Clients store credentials as follows:

ClientStorage
Web appAccess and refresh tokens in localStorage (sat_auth_token, sat_refresh_token).
sat CLImacOS Keychain or Linux Secret Service when available; otherwise ~/.config/sat/credentials.json with mode 0600. SAT_API_KEY and SAT_TOKEN from the environment are never written.
Agent processesOnly the runner's API key, through SAT_API_KEY, with an empty config directory.

Data stored

DataTableNotes
Name, email, username, avatar URL, preferencesusersEmail is the sign-in identifier
Password hashusers.hashed_passwordbcrypt
Failed login count, lock time, last login, password change timeusers
Refresh tokens, API keys, reset tokensrefresh_tokens, api_keys, password_reset_tokensSHA-256 hashes only. Reset tokens also store the requesting IP address.
Company contentcompanies, agents, goals, projects, tasks, task_comments, approvals, routinesWhatever members and agents write, including agent instructions
Run transcripts, summaries, errorsrunsAgent output: commands, tool results, file names and fragments of code. Assume transcripts can contain anything the agent saw.
Audit trailaudit_eventsField-level old and new values for every change

Request logs record the client address, user agent, full URL and status of every request. Auth logs include user email addresses. Cloud Logging retains them according to the project's log retention settings.

Audit log

Every change to company data adds an audit_events row in the same database transaction as the change, so an audited change cannot commit without its record.

  • Actor. actor_type is user (with the user's id and name), agent (writes made through a running run), or system (run claims and finishes, budget pauses, lease expiry).
  • Content. Verb (created, updated, moved, deleted, hired, queued, started, succeeded, failed, cancelled, paused, approved, rejected, triggered, ...), entity type and id, a label, and a changes object of {field: [old, new]}.
  • Access. Any member can read it through GET /api/companies/{company_id}/activity or the activity log. There is no API to change or delete entries.
  • Not covered. Sign-ins, failed sign-ins, password resets, API key creation and revocation are not in the company audit log. They appear only in the API's request and application logs.

Rate limiting

EndpointLimit
GET /health100 per minute
POST /api/password/forgot5 per minute
POST /api/password/reset10 per minute
GET /api/rate-limit-test10 per minute

Limits are per client address as the API sees it, and counters are kept in memory in the API process. Exceeding a limit answers 429.

Known gaps

Known gaps are tracked internally.

Reporting a vulnerability

Do not open a public issue. Use GitHub private vulnerability reporting on the repository (Security, then Report a vulnerability), or email security@yarlis.com. Include what you found, how to reproduce it and the impact you expect. Reports are acknowledged within 3 business days. Only the version deployed at https://app.yarlis.com (the main branch) receives security fixes. See SECURITY.md.

On this page