Skip to Content
Polyant is open source under AGPL-3.0 — star us on GitHub.
OperationsSecurity

Security

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

WhatTableNotes
Per-instance secretsinstance_secretsProvider API keys, LangSmith key, Auth API key, Tavily key, etc.
Channel credentialsinstance_channelsTelegram/Slack/WhatsApp tokens.
Skill environment variablesinstance_skill_envFree-form key/value, declared by skills via requiredEnv.
Event-source configurationevent_sourcesWebhook secrets and any source-specific config.

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.

Authentication and session

The admin panel uses Auth.js v5 (packages/web/src/lib/auth.ts) with two providers:

  • Google OAuth — interactive sign-in for human operators.
  • Credentials (email/password) — bootstrap account for first-run and headless setups, authenticated by a shared AUTH_INTERNAL_SECRET between 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:

  1. Web (Next.js middleware) — to enforce the redirect-to-/login rule. Edge Runtime can’t reach Postgres, so we lean on JWE for stateless validation.
  2. Engine (NestJS guard)packages/engine/src/auth/auth-user.service.ts decrypts the same cookie (or an Authorization: Bearer <jwt> header) using jose + @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).

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 (30 days by default). Mitigations:

  • Rotate AUTH_SECRET to 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.ts if 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.

Domain allowlist

Set AUTH_ALLOWED_DOMAINS to a comma-separated list of email domains (example.com,acme.io). Only sign-ins whose email matches will be allowed through the Google provider. An empty value (or unset) allows everyone.

The allowlist applies only to Google sign-ins. The Credentials provider bypasses the check by design — it’s the bootstrap path, and the assumption is that anyone holding AUTH_INTERNAL_SECRET is already privileged.

Authorisation and tenant isolation

Polyant is multi-tenant within a single Postgres database. Isolation is row-level via instance_id foreign keys, not separate schemas or separate tables per instance.

The discipline is enforced at the data-access layer: every store that touches instance-scoped data scopes its WHERE clause by instance_id. Reviews specifically reject any query that omits the scope. The catalogue of instance-scoped tables is documented in the architecture page.

Two implications worth flagging:

  1. A bug that drops the scope is a cross-tenant leak. This is why most stores expose instanceId as a required positional argument rather than an optional filter.
  2. Postgres-level RLS is not (yet) enabled. The app trusts itself. If you run untrusted code with raw DB credentials, isolation breaks. Don’t.

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_key using timingSafeEqual to prevent timing attacks.
  • A mismatch returns 401 with no leak about whether the instance exists.

Keys are configured in Admin Panel → Instance → Settings → Auth API key and stored AES-256-GCM encrypted like every other secret.

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 always returns 200 OK, regardless of whether 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.
  • Tokens are rotatable via POST /api/instances/:slug/event-sources/:id/rotate-token. Rotation is atomic — the old token stops working immediately.
  • The webhook URL itself is the authentication — keep it secret. Signature verification (Stripe-style HMAC, GitHub X-Hub-Signature, etc.) is not currently implemented; treat the token as the credential and rotate on suspected leak.

Audit log

Every tool invocation and every admin-panel mutation is recorded in audit_logs. Fields include the actor, the instance, the tool/endpoint, a redacted summary of the input, the outcome, and the duration. 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.

SecretSuggested cadenceHow
ENCRYPTION_KEYAnnually + on suspected compromisePlan a re-encryption migration. Out-of-band: pause writes, decrypt with old key, encrypt with new, swap env, resume.
AUTH_SECRETQuarterly + on suspected compromiseRotate in packages/web/.env.local and engine .env simultaneously. Forces all users to re-login.
AUTH_INTERNAL_SECRETQuarterlyRotate in both packages; rotate Credentials provider tooling.
Per-instance Auth API keysPer consumer / per quarterAdmin Panel → Settings → regenerate. Push new key to downstream client.
Provider keys (OpenAI/Anthropic/Tavily/LangSmith)Per provider policyAdmin Panel → Settings. Provider dashboards usually allow overlapping keys for zero-downtime rotation.
Channel tokens (Slack/Telegram/Twilio)On staff changeAdmin Panel → Channels.
Webhook tokensOn compromise / supplier changePOST /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 .env files. They are in .gitignore and the encryption key is never logged. Mitigation: OS file-system permissions, no shared shells, secret manager in production.
  • Database compromise. An attacker with SELECT on 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 30 days (see trade-off above). Mitigation: Secure + HttpOnly + SameSite=Lax cookies (Auth.js defaults), short-lived sessions if your threat model warrants it, AUTH_SECRET rotation on suspected compromise.
  • Privileged-role escalation. The users.role column gates Superadmin endpoints. Anyone with UPDATE users SQL can grant themselves Superadmin. Mitigation: as above — only application code should touch this column.
  • Cross-tenant queries. Mitigated by code review and the row-level scoping discipline above; not by Postgres RLS.
  • 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.

Incident response

If a secret leaks, follow .claude/rules/security.md discipline:

  1. HALT — Stop any in-flight changes.
  2. ESCALATE — Classify (P0/P1/P2) and notify.
  3. AUDIT — Read the audit log + git history to determine blast radius.
  4. 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.).

Last updated on