Security
On this page
- Encryption at rest
- Operator implications
- Provider credentials with managed services
- Authentication and session
- Second factor
- Self-service password reset
- Impersonation
- Trade-off: no immediate session revocation
- Who may sign in with Google
- Authorisation and tenant isolation
- What holding platform admin actually confers
- The escape hatch, and its limits
- Management API keys
- Per-instance API keys
- Outward-facing agent endpoints
- Outbound: MCP servers
- Webhook tokens
- Audit log
- Rotation cadence (advisory)
- Threat model
- Incident response
This page is the operator’s reference for Polyant’s security posture: what is encrypted, how authentication works, where the tenant boundary lives, and what the trade-offs are. It complements Authentication (operator-facing UI) and Audit Logs (forensic record).
If you are evaluating Polyant for production, read this end-to-end. If you are responding to an incident, jump to Threat model and Rotation cadence.
Encryption at rest
All sensitive instance-scoped data is encrypted with AES-256-GCM before being written to PostgreSQL. The implementation lives in packages/engine/src/crypto/index.ts and is a thin wrapper around Node’s crypto module (aes-256-gcm, 12-byte IV, 16-byte auth tag, IV + tag stored alongside the ciphertext).
| What | Table | Notes |
|---|---|---|
| Per-instance secrets | instance_secrets | Provider API keys (open-source build), LangSmith key, Auth API key, Tavily key, etc. |
| Channel credentials | instance_channels | Telegram/Slack/WhatsApp tokens. |
| Skill environment variables | instance_skill_env | Free-form key/value, declared by skills via requiredEnv. |
| Event-source configuration | event_sources | Webhook secrets and any source-specific config. |
| Organization shared secrets | organization_secrets | Managed services: the outbound keys an organization’s agents fall back to. |
| Provider connections | provider_connections | Managed services: the provider credentials of organizations, workspaces and agents, bound to roles. |
| MCP server configuration | instance_mcp_servers | Remote server URLs and their auth material. |
Two more encrypted stores sit outside the per-agent model: per-principal conversation secrets, and the short-lived OAuth state records.
Rotating ENCRYPTION_KEY therefore means re-encrypting every row of all of these, not only the per-agent four.
The master key is read from ENCRYPTION_KEY (64 hex chars = 32 bytes) at boot, validated by Zod in packages/engine/src/config.ts, and kept in process memory. It is never persisted to disk by the engine. A wrong-length or missing key fails boot.
Operator implications
- Do not commit
.env. It already lives in.gitignore; treat any leak as P0 and rotate. - Backups inherit the trade-off. A Postgres dump contains ciphertext. To read it back you need the same
ENCRYPTION_KEY. Treat dumps and the key with the same care. - Key loss = data loss for secrets. There is no recovery path if you lose
ENCRYPTION_KEY. Rotating it requires re-encrypting every row of the four tables above.
Provider credentials with managed services
With managed services every provider key lives in a provider connection, and every task that calls a provider is a role bound to exactly one. Three properties follow:
- A withdrawn credential stops the agents that used it. A role that cannot reach its connection is refused and reported; it is never served by another key, so a revoke cannot quietly move spend onto another contract.
- Leftover provider keys never reach tool code. The copies the upgrade left in the secrets for a rollback reach no tool, hook, plugin or dev-mode tool, declared or not, and the secret routes refuse to write provider keys.
- Plugins receive the conversation’s provider key. A tool’s
ctx.apiKeyscarries thechatconnection’s key and nothing else. A tool or hook from a marketplace plugin receives it too, even when the connection is the organization’s and the agent’s builders never see the key; a dev-mode tool never receives it. Installing a plugin is trusting it with that key. - The deployment’s AWS identity is lent per organization. A Bedrock connection holding only a region would sign with the engine’s own instance or task role and bill the operator’s account. That needs a platform admin’s grant for the organization, checked when the connection is written and again at every call, so revoking it takes effect on calls already configured. Review the grants after upgrading to 1.2.0: the upgrade gave one to each organization that was already relying on that identity.
Authentication and session
The admin panel uses Auth.js v5 (packages/web/src/lib/auth.ts) with up to two providers:
- Google OAuth (managed services) - interactive sign-in for human operators. The open-source build has only the credentials provider.
- Credentials (email/password) - bootstrap account for first-run and headless setups, authenticated by a shared
AUTH_INTERNAL_SECRETbetween web and engine.
Sessions are encrypted JWE (A256CBC-HS512), not opaque session ids. The encryption key is AUTH_SECRET (≥32 random chars). The cookie is decrypted in two places:
- Web (Next.js middleware) - to enforce the redirect-to-
/loginrule. Edge Runtime can’t reach Postgres, so we lean on JWE for stateless validation. - Engine (NestJS guard) -
packages/engine/src/auth/auth-user.service.tsdecrypts the same cookie (or anAuthorization: Bearer <jwt>header) usingjose+@panva/hkdf.
Both processes must share identical values for AUTH_SECRET. A mismatch silently breaks engine-side auth - symptoms in troubleshooting.md.
The Credentials provider goes one step further: the web package posts credentials to the engine for verification, signed with AUTH_INTERNAL_SECRET. This secret must be configured identically in both packages/web/.env.local and the engine’s .env - if it’s missing on either side, the credentials login path is disabled (web) or rejects every request (engine).
Second factor
Password sign-ins can be required to carry a TOTP second factor, mandated by the installation or by any organization the account belongs to. Enrolment issues ten single-use recovery codes; five consecutive failed codes freeze the account for five minutes, on top of the per-IP throttle.
Two scopes are deliberately outside it, and both are properties of where the credential lives rather than gaps: a federated (Google/SSO) sign-in satisfies the mandate, because the provider has already applied its own second factor and the engine never sees that flow; and in gateway mode the engine sees no password at all, so the second factor is the gateway’s responsibility.
A mandated account with no factor yet is admitted and forced to enrol rather than refused - refusing would lock out every member the moment the mandate was switched on, including the administrator who switched it. See Authentication.
Self-service password reset
A reset emails a link valid for 30 minutes and drops every existing session on use. No password is ever emailed, generated or temporary. With no mailer configured the request logs an error rather than silently doing nothing.
Impersonation
A platform admin can view the product as another account. It is read-only - every mutation is refused, credential actions included - cannot target another platform admin, is limited to one at a time, expires after 30 minutes, and records both start and stop against the real administrator.
Trade-off: no immediate session revocation
Because validation is stateless, there is no server-side session record to delete. A leaked JWE is valid for its full lifetime (24 hours by default). Mitigations:
- Rotate
AUTH_SECRETto invalidate all sessions globally. - Disable the user (when user accounts gain that toggle) - engine queries the DB on the next request and refuses.
- Shorten the cookie lifetime in
auth.tsif your threat model demands it.
If you need true per-session revocation, the recommended evolution is a short-lived JWT (15 min) plus a refresh-token rotation backed by a sessions table.
Who may sign in with Google
Managed services.
Two rules, both product data, and both have to admit the account. The full description is in Authentication; what matters for a threat model is the shape:
- The organization’s domain list, edited by a platform admin. Membership-scoped: only the organizations the account already belongs to are consulted, an empty list restricts nothing, and someone in two organizations is admitted when either admits them.
- Entry is by invitation. An account with no membership and no valid invitation is refused. This is what closes the hole the domain rule leaves open, and it is why
allowDangerousEmailAccountLinkingis safe here: a stranger’s Google account has nothing to link to.
There is no deployment-wide allowlist any more. AUTH_ALLOWED_DOMAIN / AUTH_ALLOWED_DOMAINS were one list for a whole installation, which cannot answer the question for a second tenant.
Both rules gate the Google provider only. The Credentials provider bypasses them by design - it is the bootstrap path, and an account with a password was created by an administrator rather than admitted by a domain.
One exemption, and it is narrow: a platform admin, identified by users.is_platform_admin read from the database. They hold no membership in any organization, so both rules would refuse them - from the very console where the lists are edited. A build without the invitations module (the open-source build, which also has no federated sign-in) does not apply the invitation rule.
A read that cannot be completed refuses the sign-in rather than admitting it.
Authorisation and tenant isolation
Polyant is multi-tenant within a single Postgres database, at two levels: Organization > Workspace > Agent. Isolation is row-level via foreign keys - instance_id for agent-scoped data, workspace_id on the agent, organization_id on the workspace - not separate schemas or tables per tenant.
The discipline is enforced at the data-access layer: every store that touches scoped data scopes its WHERE clause. Reviews specifically reject any query that omits the scope. The catalogue of scoped tables is documented in the architecture page.
Above the stores, three guards decide scope on every request: the permission itself (workspace-tier or organization-tier), the addressed organization, and the addressed workspace. Enforcement fails closed in both directions - a denied permission is refused, and so is a route that declares none, which is what stops a newly added endpoint from shipping unguarded.
Four implications worth flagging:
- A bug that drops the scope is a cross-tenant leak. This is why most stores expose the scope id as a required positional argument rather than an optional filter.
- Cross-organization rejections are deliberately indistinguishable. An unknown organization, another tenant’s, and one the caller is not a member of all answer the same status and message. A 404 for only the unknown case would be an existence oracle: a caller enumerating slugs would learn the deployment’s tenant list.
- A management API key can never address a second organization. Its own organization is its entire authority, and that is checked on every route shape - not only on agent-addressed ones. It never gets the platform-admin bypass either: there is no user behind it to check a flag against.
- A platform admin crosses every organization boundary, by design. The membership lookup runs first and the flag is consulted only when it finds nothing, so the bypass is one decision applied to every
api/organizations/{orgSlug}/…route rather than a per-screen carve-out. Treat it as an installation-wide credential, not an elevated tenant role. - Postgres-level RLS is not (yet) enabled. The app trusts itself. If you run untrusted code with raw DB credentials, isolation breaks. Don’t.
What holding platform admin actually confers
Worth stating plainly, because the flag is easy to hand out and hard to take back cleanly:
| Capability | Detail |
|---|---|
| Reads every organization’s provider connections | Names, providers and which fields are set - the API never returns a stored value |
| Rotates or deletes those connections | Full write, in any organization |
| Grants an organization the deployment’s AWS identity for Bedrock | A platform-admin check that no organization role can hold; audited as ambient_aws.org_grant and ambient_aws.org_revoke |
| Mints a management API key in any organization | With the entire Owner permission matrix: the “you may only grant what you hold” check is exempted, because a platform admin holds no binding inside the tenant to measure against |
| Renames and deletes organizations | Except the default one, which nobody can delete |
| Lists every organization on the installation | GET /api/organizations answers installation-wide for this principal alone |
Two properties make revocation a procedure rather than a switch:
- The keys survive. Nothing on a key row links it to the account that created it, so removing the flag revokes none of them. They must be deleted per organization.
- The flag is cached per process for five minutes, with no shared store. The replica handling the revocation flushes immediately; the others answer from their own copy until it expires. Budget five minutes on a multi-replica deployment, and prefer draining the account’s sessions as well if the revocation is adversarial.
Cross-organization refusals stay uniform through all of this: an unknown slug is refused for a platform admin exactly as it is for anyone else, with the same Organization not accessible.
The escape hatch, and its limits
REMOVE_AUTHORIZATION_FILTER_FOR_TESTING=true drops the permission guard to log-only. With it set, every permission check is a no-op and cross-organization isolation is off, so any authenticated caller reaches the whole management plane. It is ignored when NODE_ENV=production - enforcement in production cannot be turned off by configuration.
Note that this deployment does not read AUTHZ_ENFORCE; setting it does nothing.
Management API keys
A management key is stored and verified as a SHA-256 digest, in every build: the secret is a long random value, so a slow password hash added no protection and held the event loop for about 250 ms on every request a key authenticated. A key stored as bcrypt by an earlier release keeps working and is rewritten as a digest the first time it is used. A rollback to 1.1.x therefore needs every key used on 1.2.0 issued again, since 1.1.x cannot read the digest.
Per-instance API keys
For OpenAI-compatible inference (/v1/chat/completions), the per-instance flow uses bearer tokens rather than sessions. When instances.auth_enabled = true:
- The caller sends
Authorization: Bearer <key>. - The engine compares the key against
instance_secrets.auth_api_keyusingtimingSafeEqualto prevent timing attacks. - A mismatch returns
401with no leak about whether the instance exists.
Keys are configured in the agent’s Channels → Web/API section and stored AES-256-GCM encrypted like every other secret.
auth_api_key (and the HTTP channel key) are deliberately excluded from the organization-level credential fallback: they authenticate an inbound caller to one specific agent, so letting them inherit from the organization would let a single key authenticate every agent in it. See Manage secrets.
Outward-facing agent endpoints
Two features publish an agent beyond the deployment, and both are off until someone turns them on:
- The OpenAI-compatible endpoint (
/v1/chat/completions), guarded by the per-agent auth key above. - The A2A server - a public agent card and a JSON-RPC endpoint per agent, authenticated the same way. The JSON-RPC route is rate-limited (20 req/min) and its task store is bounded, and the boundary is the organization: a task belongs to the agent it was created on.
An agent with either enabled and no authentication configured is an open endpoint. The agent’s Status section reports exactly that, as broken, for both.
Outbound: MCP servers
An agent configured with an external MCP server makes the engine an HTTP client to an address an operator supplied. Two controls matter:
- SSRF. The URL is validated at dial time, not only at save time, against loopback, private, link-local, unique-local and unspecified addresses - in IPv4, IPv6 and IPv4-mapped-IPv6 notations. Without dial-time checking, a DNS record that resolves to a private address after the save would pass.
- Prompt injection surface. A remote server’s tool descriptions land in the cached, highest-trust part of the system prompt. They are truncated and wrapped in an untrusted-content tag, and tool names are namespaced so a remote tool cannot impersonate a core one. Treat adding an MCP server as granting that server a voice in the agent’s instructions.
Adding or deleting a server is recorded in the management audit trail.
Webhook tokens
Each event source carries an opaque token used in the public URL /webhooks/:webhookToken. The token is the only authentication on the inbound path - the matcher does not require a signature unless the source declares one. To narrow the blast radius:
- The webhook receiver returns 200 OK whether or not the token is valid, the source is enabled, or the payload matched a definition. This prevents an attacker from enumerating valid tokens by reading status codes. The single exception is below.
- A source can declare an
authKey, a second factor on top of the URL. When it does, the sender must presentAuthorization: Bearer <key>, compared in constant time, and a mismatch is the one case that answers 401 instead of 200. Configure it wherever the sender can set a header. - Tokens are rotatable via
POST /api/instances/:slug/event-sources/:id/rotate-token. Rotation is atomic - the old token stops working immediately. - Without an
authKey, the URL is the credential - keep it secret. Provider-specific signature verification (Stripe-style HMAC, GitHubX-Hub-Signature) is not implemented; rotate on suspected leak.
Audit log
There are three audit trails, one per tenancy tier, and they are kept separate on purpose:
| Trail | Tier | Records |
|---|---|---|
| tool audit | workspace | every tool invocation - actor, agent, tool, redacted input, outcome, duration |
| authorization audit | organization | role and membership mutations (managed services) |
| management audit | installation | configuration writes, and account mutations: role changes, deletions, password resets, impersonation start/stop |
A password is never recorded, chosen or generated - actor, action and target only. See Audit Logs for the UI.
The audit log is the long-term forensic record. The Activity feed is its real-time, ephemeral counterpart - use it for live operations, not for post-mortems.
Rotation cadence (advisory)
Polyant does not enforce rotation. The defaults below are starting points - adjust to your organisation’s policy.
| Secret | Suggested cadence | How |
|---|---|---|
ENCRYPTION_KEY | Annually + on suspected compromise | Plan a re-encryption migration. Out-of-band: pause writes, decrypt with old key, encrypt with new, swap env, resume. |
AUTH_SECRET | Quarterly + on suspected compromise | Rotate in packages/web/.env.local and engine .env simultaneously. Forces all users to re-login. |
AUTH_INTERNAL_SECRET | Quarterly | Rotate in both packages; rotate Credentials provider tooling. |
| Per-instance Auth API keys | Per consumer / per quarter | Agent → Channels → Web/API → API Key: enter a new key and save. Push the same key to the downstream client. |
| Provider keys (OpenAI/Anthropic/Tavily/LangSmith) | Per provider policy | Agent → Model for model providers (open-source build), Tools for tool keys, Advanced → LangSmith Tracing for LangSmith. Provider dashboards usually allow overlapping keys for zero-downtime rotation. |
| Channel tokens (Slack/Telegram/Twilio) | On staff change | Admin Panel → Channels. |
| Webhook tokens | On compromise / supplier change | POST /api/instances/:slug/event-sources/:id/rotate-token. |
Threat model
A non-exhaustive checklist of what we have thought about and what we have not.
- Secrets on disk. Only
.envfiles. They are in.gitignoreand the encryption key is never logged. Mitigation: OS file-system permissions, no shared shells, secret manager in production. - Database compromise. An attacker with
SELECTon the engine database sees ciphertext for the four sensitive tables and cleartext for everything else (conversations, memories, audit logs). Mitigation: Postgres role separation, restricted network reachability, encrypted disk volumes. - Man-in-the-middle. Polyant assumes TLS-terminated traffic everywhere - the panel, the webhook endpoint, channel adapters. Mitigation: Run behind a TLS-terminating proxy; never expose the engine on plain HTTP outside localhost.
- Session theft. A stolen JWE cookie is valid for up to 24 hours (see trade-off above). Mitigation:
Secure+HttpOnly+SameSite=Laxcookies (Auth.js defaults), short-lived sessions if your threat model warrants it,AUTH_SECRETrotation on suspected compromise. - Privileged-role escalation. The
users.is_platform_adminflag gates the platform-admin endpoints and the RBAC bypass. It is the sole authority - theusers.rolecolumn that used to sit beside it has been dropped - and anyone withUPDATE usersSQL can grant themselves platform admin by setting it. Mitigation: as above - only application code should touch that column. Because the flag is read from the database on each privileged check rather than from the session, a direct write takes effect within the check’s cache window, in both directions. - Cross-tenant queries. Mitigated by code review and the row-level scoping discipline above; not by Postgres RLS.
- Public routes with no sign-in in front.
/webhooks/*,/channels/http/*and, with managed services,/dev-mode/socketmust reach the engine without an identity-aware proxy, because the callers are providers and machines that cannot complete a sign-in. Each route authenticates its caller itself (provider signature, path secret, agent key or dev token), and the dev-mode handshake authenticates before it does any other work. Mitigation: route only those paths around the sign-in; see the reverse proxy checklist. - Webhook flooding. Mitigated by the 60 events/min rate limit and 100-event backlog cap per source. Beyond that, events are dropped silently.
- Tool-call exfiltration. Tools have unrestricted outbound HTTP by design (
httpRequest,webSearch). Restrict at the network layer if your environment requires it. - A dev session serves real users (managed services). While open, its tools and hooks run on every conversation of the agent and can rewrite the replies users receive; the session ends within one keepalive tick on every replica once its issuer loses
agent.dev:write, and its socket is closed past 500 frames a second. Mitigation: grantagent.dev:writeonly to people you would trust with the agent’s production traffic; see Dev mode. - A stuck plugin (managed services). A marketplace plugin’s tool call has a deadline,
PLUGIN_CALL_TIMEOUT_MS(60 s by default; a hook uses its configured timeout), and a worker that ignores the abort forPLUGIN_ABORT_GRACE_MS(5 s by default) is terminated, so a plugin cannot hold its worker for good. - Files outside retention. Files written by
extra:fileUploadare not deleted by retention or by deleting a conversation or an agent, because the plugin chooses their bucket and key. Mitigation: apply a lifecycle policy to that bucket.
Incident response
If a secret leaks:
- HALT - Stop any in-flight changes.
- ESCALATE - Classify (P0/P1/P2) and notify.
- AUDIT - Read the audit log + git history to determine blast radius.
- ROTATE - Rotate every secret that could plausibly be exposed, not just the one you found.
See troubleshooting.md for symptoms that look like security issues but are configuration errors (AUTH_SECRET mismatch, drift between web and engine, etc.).