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).
| What | Table | Notes |
|---|---|---|
| Per-instance secrets | instance_secrets | Provider API keys, 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. |
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_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).
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_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.
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:
- A bug that drops the scope is a cross-tenant leak. This is why most stores expose
instanceIdas a required positional argument rather than an optional filter. - 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_keyusingtimingSafeEqualto prevent timing attacks. - A mismatch returns
401with 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.
| 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 | Admin Panel → Settings → regenerate. Push new key to downstream client. |
| Provider keys (OpenAI/Anthropic/Tavily/LangSmith) | Per provider policy | Admin Panel → Settings. 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 30 days (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.rolecolumn gates Superadmin endpoints. Anyone withUPDATE usersSQL 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:
- 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.).