Skip to content

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.

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.

  • 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, and Secure when 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.
  • 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 BYPASSRLS role.
  • A test suite signs in as two workspaces and tries every direct reference, mutation and listing of the other workspace’s data.
  • GET /healthz and GET /readyz are public and reveal no addresses. GET /metrics exists only when a token is set, and its labels never carry tenant or request identifiers.
  • 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.

  • 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.
  • 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=polo takes 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.

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

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.

  1. Set the public origin (POLO_API_PUBLIC_URL; POLO_PUBLIC_URL in Compose, publicUrl in the chart) to the exact HTTPS origin users open. Secure cookies, HSTS, the browser write rule and the SSO callback all derive from it.
  2. Set the trusted proxies (POLO_API_TRUSTED_PROXIES; api.trustedProxies in 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’s X-Forwarded-For is believed.
  3. 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.
  4. Generate strong keys for POLO_API_OPERATION_KEY and POLO_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.
  5. 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.
  6. Keep Redis reachable. Authentication limits fail closed without it.
  • 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 leave worker.engineering.enabled off).
  • Use POLO_API_OPERATION_ALLOWED_HOSTS and POLO_API_UPTIME_ALLOWED_HOSTS to 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.
  • 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.

Run the security suites against disposable PostgreSQL and Redis services (POLO_TEST_DATABASE_URL, POLO_TEST_REDIS_URL):

Terminal window
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.js

See Testing for the full set of suites.