Security
Polo is a multi-tenant control plane that can propose and carry out changes to real infrastructure. This page describes who it defends against, the controls that are in place, and the settings you are responsible for when you run it. For the workspace-level features (roles, SSO, approval rules), see Team and security and Approvals and guardrails.
Threat model in brief
Section titled “Threat model in brief”A tenant is a workspace. A user holds one account and may be a member of several workspaces with the role admin, operator or viewer. Runners, model providers and integrations are reached with credentials the workspace registers. The worker, the scheduler and the watch processes talk to the API over internal routes with their own worker credentials.
| Adversary | Goal | Main controls |
|---|---|---|
| Anonymous Internet client | Guess passwords, enumerate accounts, exhaust sign-in, abuse webhooks, reach internal networks through URL-accepting features | Argon2id password hashing, per-client and per-account rate limits, uniform error messages, signed webhooks, public-address egress policy |
| Member of another workspace | Read or change your workspace’s data, escalate a role, keep access after removal | Sessions bound to one workspace, role re-read on every request, forced row-level security on every tenant table |
| Cross-site page in a member’s browser | Perform writes with the member’s cookies | SameSite=Lax and HttpOnly cookies, a required custom header and origin check on every write, no CORS |
| Stolen worker or runner credential | Act for another workspace, read tenant data | Dedicated worker identities, commands scoped to a workspace, task and run, signed runner requests, row-level security |
| Someone reading logs or error responses | Learn credentials or tenant content | No request logging, bounded error messages, secrets sealed at rest and never returned |
| Compromised database backup | Recover credentials | Password hashes, token digests, and provider secrets sealed with AES-256-GCM |
The deployment platform itself — TLS termination, network policy, node and host hardening — is your responsibility and is covered under Operational guidance.
Controls in place
Section titled “Controls in place”Authentication and sessions
Section titled “Authentication and sessions”- Passwords are hashed with Argon2id, with a minimum length of 12 characters. An unknown account takes the same time to reject as a wrong password.
- Sessions are random tokens stored only as SHA-256 digests, each bound to one workspace. The lifetime is
POLO_API_SESSION_SECONDS(default 7 days, maximum 30 days). The membership and role are read on every request, so a demotion or removal takes effect immediately. - Rotation. Signing in issues a new session and revokes the presented one. A password change or reset ends every session of the account.
- Cookies are
HttpOnly,SameSite=Lax, andSecurewhen the public origin is HTTPS. - Browser writes must carry Polo’s own client header and come from the configured origin; cross-site requests are refused.
- Rate limits apply to sign-in, signup, password reset, invitations and SSO, per client address and per account. They are kept in Redis and fail closed: without Redis these routes answer 503.
- SSO with Google or Microsoft uses state, nonce and PKCE, checks the issuer and verified-email claims, and may only reach the provider’s own origins. Client secrets are sealed under
POLO_API_SSO_KEY.
Authorisation and tenant isolation
Section titled “Authorisation and tenant isolation”- Every API route is guarded by a session and an explicit role, or by a worker credential, or is on a short public allowlist. A test enforces this over every controller.
- Row-level security is enabled and forced on every table that holds tenant data. The tenant scope is set per transaction, so it cannot outlive the transaction or leak through the connection pool.
- The API runs as a database role that owns nothing and cannot bypass row-level security. It refuses to start as an owner, a superuser or a
BYPASSRLSrole. - A test suite signs in as two workspaces and tries every direct reference, mutation and listing of the other workspace’s data.
GET /healthzandGET /readyzare public and reveal no addresses.GET /metricsexists only when a token is set, and its labels never carry tenant or request identifiers.
Agents and changes
Section titled “Agents and changes”- An agent holds only the grants its profile gives it. Unattended alert tasks receive read-only investigation grants.
- Every change outside Polo’s sandbox is proposed as an exact operation. It runs only after a person approves it, or an administrator has pre-authorized that exact operation, and is verified afterwards with a separate read.
- An approval is bound to the reviewed arguments, destination and digest. If they no longer match what the agent would execute, the approval is refused and the agent must propose again.
- Administrators can write rules that redact, block or record text before any agent sends it to a model, and can turn on masking of identifiers such as IP addresses, email addresses and host names. Masking is a reduction, not a guarantee: only what is recognisable by its form is masked.
- Log and trace reads are read-only, built by the control plane from registered targets rather than from a model’s query, and stripped of what looks like a secret before an agent sees them.
See The operation contract for how a proposal, approval and verification fit together.
Secrets and data
Section titled “Secrets and data”- Connection credentials, model API keys and the workspace vault are sealed with AES-256-GCM under
POLO_API_OPERATION_KEY, bound to their workspace. No route returns a value; responses say only whether a credential is configured. - Settings are held in memory as an object that is never serialised or logged.
- The API has no access log. Its error log carries a request id, method, route pattern, status and error class, with token-like text redacted; it never logs bodies, parameters or headers.
- Tokens Polo issues (sessions, MCP access tokens, runner tokens, mail links) are stored as digests and shown once.
Runners
Section titled “Runners”- A runner you install holds its own credentials; the control plane does not. Each request is signed with the connection’s signing key, and the runner verifies it and acts only within its configured scope.
- A runner that connects out to Polo authenticates with a token that only lets it collect its own workspace’s requests. It does not replace the request signature.
- A runner started with
POLO_RUNNER_SETTINGS=polotakes its targets from Polo. That is a deliberate trade of control for simplicity: whoever administers the workspace controls what that runner reads with its machine’s access. - The default runner (off unless
defaultRunner.enabled) runs inside Polo’s own trust boundary: Polo creates its signing key and holds its settings. The API’s cluster permission is limited to creating and patching Secrets, volume claims and Deployments in the runner namespace.
More in Runners.
Input handling and egress
Section titled “Input handling and egress”- JSON bodies are limited in size and parsed with strict schemas; identifiers are validated before use.
- Every feature that accepts a URL (uptime probes, runner endpoints, MCP servers, observability sources, integrations) resolves the hostname, refuses private, loopback and link-local addresses, and pins the resolved address for the request.
- Webhooks are verified by signature before they are processed.
Audit trail
Section titled “Audit trail”- Membership and invitation changes are recorded with actor, subject and roles.
- Sign-ins, sign-outs, session revocations, password changes and SSO configuration changes are recorded inside the same transaction as the action.
- Approvals and rejections record the deciding user, the time and the reason on the request.
Headers and transport
Section titled “Headers and transport”Every response carries a restrictive Content-Security-Policy, X-Content-Type-Options: nosniff, X-Frame-Options: DENY, Referrer-Policy: no-referrer, cross-origin isolation headers, and Strict-Transport-Security when the public origin is HTTPS. The UI is served with a policy that allows only its own scripts and styles.
Operational guidance
Section titled “Operational guidance”Required settings
Section titled “Required settings”- Set the public origin (
POLO_API_PUBLIC_URL;POLO_PUBLIC_URLin Compose,publicUrlin the chart) to the exact HTTPS origin users open. Secure cookies, HSTS, the browser write rule and the SSO callback all derive from it. - Set the trusted proxies (
POLO_API_TRUSTED_PROXIES;api.trustedProxiesin the chart) to the addresses of the reverse proxies in front of the API. Without it, every client behind the proxy shares the per-client rate limits. With it, make sure clients cannot reach the API port directly, because a listed proxy’sX-Forwarded-Foris believed. - Keep the database roles separate. Migrations run as the owner role; the API runs as the restricted role that provisioning creates. Do not give the API the owner’s URL.
- Generate strong keys for
POLO_API_OPERATION_KEYandPOLO_API_SSO_KEY(32 random bytes each) and keep them outside the database and the image. Changing either invalidates the secrets sealed under it, which workspaces must then re-enter. - Give every worker a unique credential of 32 or more characters in
POLO_API_WORKER_KEYS, with dedicated identities for the scheduler and each watch process. - Keep Redis reachable. Authentication limits fail closed without it.
Network and hosts
Section titled “Network and hosts”- Terminate TLS in front of the API. It serves plain HTTP and rejects a non-loopback public origin that is not HTTPS.
- Do not expose the API port, the metrics ports or the Temporal UI to the Internet. Compose binds them to loopback by default (
POLO_BIND_ADDRESS). - On a shared Kubernetes cluster, use a NetworkPolicy so only the Ingress controller and Polo’s own pods reach the API Service.
- The engineering sandbox gives the worker a Docker socket, which is root-equivalent on that host or node. Run it on dedicated hosts or nodes, or turn it off (
POLO_ENGINEERING_ENABLED=false, or leaveworker.engineering.enabledoff). - Use
POLO_API_OPERATION_ALLOWED_HOSTSandPOLO_API_UPTIME_ALLOWED_HOSTSto restrict which hosts can be registered as endpoints or probed. - If you enable the default runner, give it a namespace of its own. Check whether your cluster enforces NetworkPolicies before relying on them to isolate runner pods.
Credentials you connect
Section titled “Credentials you connect”- Prefer read-only credentials for cloud account connections, and separate read and apply credentials on runners.
- Rotate runner signing keys and worker tokens on a schedule; the steps are in Runners and Deploy with Docker Compose.
- Treat a database backup together with the encryption keys as sensitive as the live system; see Backup and restore.
Before a release
Section titled “Before a release”Run the security suites against disposable PostgreSQL and Redis services (POLO_TEST_DATABASE_URL, POLO_TEST_REDIS_URL):
cd api && npm run build && node --test --test-concurrency=1 \ dist/test/identity/*.test.js dist/test/platform/*.test.js \ dist/test/security/*.test.js dist/test/architecture/module-boundaries.test.jsSee Testing for the full set of suites.