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 endpoint | Purpose |
|---|---|
GET /, GET /health | Service info and health |
POST /api/register | Create an account |
POST /api/login | Sign in with email and password |
POST /api/refresh | Exchange a refresh token for a new access token |
POST /api/password/forgot, POST /api/password/reset | Password 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
| Property | Value |
|---|---|
| Algorithm | HS256, signed with JWT_SECRET_KEY |
| Access token lifetime | 30 minutes. Claims: sub (user id), type: access, iat, exp |
| Refresh token lifetime | 7 days. Claims: sub, type: refresh, jti, iat, exp |
| Refresh token storage | SHA-256 of the token in refresh_tokens, with is_active and expires_at |
| Refresh | POST /api/refresh returns a new access token only. The refresh token is not rotated and stays valid until it expires or is revoked. |
| Logout | POST /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 hashing | bcrypt (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 answers400 ACCOUNT_LOCKEDeven 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/forgotalways answers202with 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 (iatearlier thanpassword_changed_at), and rejects every API key created before it (created_atearlier thanpassword_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.
| Property | Value |
|---|---|
| Format | sat_live_ followed by 32 URL-safe characters |
| Storage | SHA-256 in api_keys.key_hash (unique index). The secret is returned once, at creation. |
| Display | prefix: the first 13 characters |
| Expiry | None by default; optional expires_in_days from 1 to 3650 |
| Per-user limit | 20 active keys; names unique among active keys. Revoked and expired keys do not count and are not listed |
| Powers | Exactly the owning user's powers, in every company the user belongs to |
| Management | Creating 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. |
| Revocation | DELETE /api/me/api-keys/{key_id} sets is_active = false; the key stops working on the next request |
last_used | Updated 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.
| Rule | Implementation |
|---|---|
Every path under /api/companies/{company_id} requires membership | get_company_context loads the caller's company_members row |
Non-members get 404 NOT_FOUND, not 403 | So company ids cannot be probed |
| Rows are loaded only if they belong to the company in the path | get_owned filters on company_id; an id from another company is 404 |
Changing the company (PATCH /api/companies/{company_id}) requires role owner or admin | require_admin; others get 403 FORBIDDEN |
| Everything else is open to any member | Hiring, pausing and terminating agents, tasks, goals, projects, routines, runs, and deciding approvals |
Creating a company makes the caller its owner | POST /api/companies |
| API key management is per user | A 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
| Setting | Value |
|---|---|
| Allowed origins | ALLOWED_ORIGINS, comma-separated; * when unset |
| Credentials | Allowed |
| Methods | GET, POST, PUT, DELETE, OPTIONS, PATCH |
| Headers | Any |
| Exposed headers | X-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/healthare 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
sslmodeunless you add it. - Host header.
ALLOWED_HOSTScan restrict acceptedHostvalues. Leave it unset on Cloud Run, because Cloud Run, Firebase Hosting and tagged revision URLs use different hosts.
Secrets
| Secret | Where it should live | Who reads it |
|---|---|---|
| Database URL with password | Your secret store, for example Secret Manager | The API's runtime service account, as DATABASE_URL |
| JWT signing key | Your secret store (at least 48 random bytes, hex) | The API's runtime service account, as JWT_SECRET_KEY |
| SMTP password | Your secret store, when email is set up | The API's runtime service account, as SMTP_PASSWORD |
| Deploy credentials | Prefer short-lived credentials, for example Workload Identity Federation, over stored keys | Your deploy pipeline |
npm token for @yarlisai/ui | A read-only token held as a CI secret | CI 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:
| Client | Storage |
|---|---|
| Web app | Access and refresh tokens in localStorage (sat_auth_token, sat_refresh_token). |
sat CLI | macOS 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 processes | Only the runner's API key, through SAT_API_KEY, with an empty config directory. |
Data stored
| Data | Table | Notes |
|---|---|---|
| Name, email, username, avatar URL, preferences | users | Email is the sign-in identifier |
| Password hash | users.hashed_password | bcrypt |
| Failed login count, lock time, last login, password change time | users | |
| Refresh tokens, API keys, reset tokens | refresh_tokens, api_keys, password_reset_tokens | SHA-256 hashes only. Reset tokens also store the requesting IP address. |
| Company content | companies, agents, goals, projects, tasks, task_comments, approvals, routines | Whatever members and agents write, including agent instructions |
| Run transcripts, summaries, errors | runs | Agent output: commands, tool results, file names and fragments of code. Assume transcripts can contain anything the agent saw. |
| Audit trail | audit_events | Field-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_typeisuser(with the user's id and name),agent(writes made through a running run), orsystem(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 achangesobject of{field: [old, new]}. - Access. Any member can read it through
GET /api/companies/{company_id}/activityor 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
| Endpoint | Limit |
|---|---|
GET /health | 100 per minute |
POST /api/password/forgot | 5 per minute |
POST /api/password/reset | 10 per minute |
GET /api/rate-limit-test | 10 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.