Defense in depth across two independent authentication planes. polyrouter is metadata-only by default — your prompts and responses are never stored unless you explicitly enable encrypted body capture (self-host only, off by default). Credentials are encrypted at rest, egress is SSRF-guarded, and every query is scoped to its tenant.
Privacy — metadata only by default
By design, polyrouter never stores prompt or response bodies by default. Every request writes one immutable request_log row of metadata — enough to price, route, and debug, and nothing that reveals what was said.
Recorded — metadata only
Token counts (input / output)
Model and provider IDs
Cost and price snapshot
Routing decision metadata
Latency
Structural, semantic and calibration telemetry — scores and bands, never raw text
Terminal error detail on failed requests — kind, HTTP status, the provider's own error message (secret-scrubbed, capped at 300 chars) and an allowlisted upstream request id
Never recorded (default)
Prompt text
Response / completion text
Embedding vectors
Even a full database breach exposes no conversation content. There is no prompt or response text to leak — only counts, IDs, prices, and routing metadata. The one free-form field is error_message on failed requests: the provider's own error text, secret-scrubbed and capped at 300 chars. Providers occasionally quote prompt fragments back in an error, so it is treated as sensitive and truncated rather than assumed clean.
Opt-in body capture (self-host only)
Self-hosted instances may turn on prompt/response body capture from the dashboard Settings page — for debugging or audit — behind an explicit consent gate. It is off by default and unavailable on cloud instances (MODE=selfhosted only).
Mode
What it captures
offdefault
Nothing — no bodies are ever written.
errors_only
Prompt and response for requests that error — and for requests the cascade escalated (an escalation is a successful outcome). The dashboard labels this mode “Errors & escalations only”.
all
Prompt and response for every request.
Encrypted at rest with the same PROVIDER_CREDENTIAL_KEY as provider credentials.
Capped at 256 KiB per direction — larger bodies are truncated and flagged (truncated / partial).
Retention-bounded — purged daily on a per-tenant window (default 30 days); infinite retention requires an explicit keep-forever choice.
Per-agent overrides (always / never) refine the global mode.
Purge-all and per-request delete; a capture_epoch counter and deletion tombstones stop queued writes from resurrecting deleted bodies.
Cloud instances never arm capture.
PrivacyMetadata only by default; prompt and response bodies are never stored unless you explicitly enable encrypted body capture — self-host only, off by default.
Two credential planes
Two independent authentication systems that never share a code path: browser sessions for the dashboard (/api/**) and HMAC-signed API keys for agents (/v1/**). Two routes sit outside both by design: GET /api/health for orchestration probes, and GET /metrics, which serves instance-level Prometheus aggregates to anyone who can reach the port — set METRICS_ENABLED=false to 404 it, and keep the port behind a reverse proxy.
Dashboard sessions
WEB PLANE · /api/**
Better Auth 1.6 handles /api/auth/**; the rest of /api/** runs behind polyrouter's own session guard
Email / password — scrypt-hashed
Optional OAuth — Google / GitHub / Discord (via env)
Database-backed cookie sessions with expiry — one session row per sign-in, revocable server-side and deleted on disable
A Redis-backed rate limiter runs before the Better Auth handler — per-IP on login, per-principal on sensitive ops
Localhost auto-login (self-host only) — on a loopback-bound instance a request from the machine itself is served as the admin with no cookie. It requires all of: MODE=selfhosted, a loopback BIND_ADDRESS, no forwarding header, a loopback socket peer, a loopback Host header, and a same-origin request. Any proxy in front disables it.
Agent API keys
API PLANE · /v1/**
HMAC-SHA256-signed poly_… keys
Sent as Authorization: Bearer poly_… or x-api-key
Verified in O(1) by prefix lookup — never bcrypt on the hot path
Rotation invalidates the old key immediately
VERIFICATION · O(1)
Extract the poly_ prefix
Look up the agent by UNIQUE api_key_prefix
Validate the full HMAC-SHA256 against api_key_hash
Attach the authenticated principal
Users & registration
The first account to sign up owns the instance; everything after that is invite-only and admin-managed.
First-signup-wins
The first account becomes admin via a single atomic claim on instance_settings.bootstrap_claimed_at — multi-instance deploys can't double-crown. A stale claim (crashed winner, still zero users) is stealable after a short window, so a failed bootstrap self-heals.
Invite-only by default
Admins mint single-use invite links pinned to an email, expiring after 72 h. Only a token prefix and hash are stored — never the raw token, which rides the link's #fragment (browsers never send it to servers or proxies). With SMTP configured the invite is emailed; otherwise copy the link and deliver it yourself.
Roles & registration mode
Admins promote or demote admins and flip registration between invite_only and open, live. The last enabled admin can never be deleted, demoted, or disabled — the API refuses with 409.
Disable cuts both planes
Disabling a user revokes their dashboard sessions and stops every agent key they own on /v1 — in one transaction. Re-enabling requires a fresh sign-in.
BREAK-GLASS · no enabled admin left
UPDATE "user" SET disabled = false, role = 'admin' WHERE email = '[email protected]';
Fix the row directly in Postgres if you ever lock yourself out, then sign in again.
SSRF-guarded egress
Every server-fetched URL or host — provider base URLs, Apprise notification targets, SMTP hosts, and the pricing-refresh URL — is resolved and checked before any connection. assertUrlSafe() throws on a private, loopback, link-local, CGNAT, or cloud-metadata target, IPv4 and IPv6 alike.
Range
Classification
Blocked
10.0.0.0/8
Private †
Blocked
172.16.0.0/12
Private †
Blocked
192.168.0.0/16
Private †
Blocked
127.0.0.0/8
Loopback
Blocked*
169.254.0.0/16
Link-local
Blocked
100.64.0.0/10
CGNAT †
Blocked
fc00::/7
Unique-local (IPv6) †
Blocked
Metadata endpoints
AWS / GCP / Azure
Blocked
* Loopback is allowed only for a local provider in self-host mode. † Private and CGNAT ranges are blocked by default and can be relaxed only for notification targets, and only by an explicit host + CIDR + port NOTIFY_ALLOWED_ENDPOINTS entry. Provider base URLs pass through no allowlist — private space is unconditionally blocked there. Link-local, metadata, multicast, reserved, IPv4-mapped, 6to4 and NAT64 ranges are hard-blocked and cannot be allowlisted at all.
DNSResolved IPs are re-validated at connect time, not just when the URL is parsed — a host that resolves to a public IP on first lookup but a private IP on the real connection is still blocked. That is the DNS-rebinding defense.
Credential encryption
Provider and notification credentials are encrypted at rest with AES-256-GCM. They are decrypted only at call time, and adapters are built lazily per attempt, so an unused provider's secret is never decrypted. Credential content is never logged or echoed.
Typed credential envelope
Each stored provider credential is a typed envelope — polycred:v1: — that is either a plain API key or an oauthsubscription token. Plain writes wrap whatever is pasted, so a polycred:v1:… lookalike just becomes a plain value containing that string. The oauth kind is unforgeable through every paste path — only the connect / refresh flow can mint one. Marker-prefixed content that fails to parse raises a typed TamperedCredentialError, never a silent downgrade to plain.
KEY MANAGEMENT
Secret
Env var
Purpose
Auth secret
BETTER_AUTH_SECRET
Dashboard session signing
API-key HMAC
API_KEY_HMAC_SECRET
Agent-key hashing, plus a domain-separated per-tenant learning HMAC
Credential key
PROVIDER_CREDENTIAL_KEY
Provider secrets (plain keys + OAuth envelopes) and captured bodies
Notification key
NOTIFY_CREDENTIALS_SECRET
Notification channel credentials
There is no automatic key rotation — decryption takes exactly one key, with no previous-key fallback. BETTER_AUTH_SECRET and API_KEY_HMAC_SECRET can be changed at the cost of invalidating live sessions and every agent key (re-mint them from the Agents page afterwards). Changing PROVIDER_CREDENTIAL_KEY or NOTIFY_CREDENTIALS_SECRET permanently orphans every credential already encrypted with the old key — you re-enter them by hand. The installer generates all four once and refuses to regenerate an existing .env for exactly this reason; see Configuration →
Tenant isolation
Every owned table carries an owner_user_id, and every query is scoped to the authenticated principal. The userPrincipal type makes an unscoped query a compile error — you can't construct a query without an owner — and a dedicated cross-tenant e2e suite verifies isolation across every endpoint.
TYPE-SAFE TENANCY
// THE single site deriving the ownership predicate
function ownershipPredicate(
table: { ownerUserId: AnyPgColumn },
principal: Principal,
): SQL {
assertUserPrincipal(principal);
return eq(table.ownerUserId, principal.userId);
}
Foreign keys with ON DELETE CASCADE clean up a deleted tenant's data (the last enabled admin excepted). The Layer 2 learning sweep inherits the same tenancy: its per-tenant Redis buckets are keyed by a domain-separated tenantHmac, so one tenant can neither read nor pollute another's state.
Layer 2 privacy invariants
The optional semantic router (Layer 2) is the only surface that touches request text, so its privacy contract is explicit and test-enforced. Seven invariants hold at both the application layer and in the suites.
#
Invariant
Enforced by
1
No telemetry for skip verdicts — a populated semantic column always came from a real evaluation.
All-or-none quartet DB CHECK
2
No raw embedding in any column, log line, metric, or API response.
Ephemeral Float32Array, dropped after use
3
No single raw embedding in Redis — only a sum over a cohort of ≥ MIN_COHORT (default 8).
Accumulator flush floor
4
No prompt text in an audit row — scalars only (counts, drift, similarity) and a fixed reason string.
semantic_learning_event column types
5
No “learned” badge when stale — a moved embedder or revision shows source: bundled.
View-model + unit tests
6
Revert fences everything — the revocation epoch bumps in Postgres before Redis is cleared, so every in-flight sweep's CAS fails.
Ordered revert + e2e test
7
Honest availability — a broken bundle greys the toggle (semanticAvailable: false) and the verdict never runs.
Bootstrap capability check
Layer 2 is opt-in and off by default. No prompt text or embedding vector is ever logged, persisted to Postgres, placed in a metric, or returned by the API. How the semantic layer routes →
Security audit
FABLE_AUDIT.md records a 19-surface multi-agent audit run on 2026-07-16 against commit 8abd4b6, covering every file in packages/ plus the root operational files. It confirmed 0 critical, 9 high and ~37 medium findings, each survivor of an adversarial verification pass; all have since been resolved. It predates the Layer 2 semantic stack, which ships its own dedicated suites.
19
surfaces audited
0
critical findings
Tenancy seam — no cross-tenant leaks
Mid-stream commit — no model swap after the first token
SSRF pinning — full IPv4 + IPv6 blocking
Append-only pricing — immutable cost records
Budget counters — monotonic reconcile
Breaker settling — generation-stamped state
Auto-layer gating — opt-in only (capability and preference)
Notification isolation — never blocks the request path
Boot fail-fast config registry — no raw process.env reads
Frontend key handling — the raw agent key is transient, shown once