How it works Features Docs Compare Blog GitHub
Self-host it Sign up
Documentation

Security & privacy.

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.

Maintained by Anthony Izzo · Last updated

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
  • Workload telemetry on evaluated auto requests — the detected class, a confidence in [0, 1], the source that produced it, and a configuration-only revision stamp; all four travel together or not at all
  • 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
  • Per-attempt fallback trail on failed requests — one structured entry per chain member the router walked or skipped (position, provider, model, error kind, status, whether it was dispatched, which cascade leg), capped at 32 entries and carrying no free text at all
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).

ModeWhat it captures
offdefaultNothing — no bodies are ever written.
errors_onlyPrompt 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”.
allPrompt 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.
Privacy Metadata 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/**). Four routes sit outside both by design: GET /api/health for orchestration probes; GET /api/login-config, which the sign-in screen reads before anyone has a session; POST /api/invites/accept, which is how an invited user gets their first session; 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, where every rule is keyed by client IP — sign-in 10/min, sign-up 5/min, password-reset request and reset 3 per 5 min, invite accept 5/min, subscription OAuth connect 10/min. It runs ahead of the session guard, so there is no principal to key on there; the per-principal throttles are separate limiters behind the guard — the same OAuth connect route again at 10/min per principal, and notification test-send at 5/min per user.
  • 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)
  1. Extract the poly_ prefix
  2. Look up the agent by UNIQUE api_key_prefix
  3. Validate the full HMAC-SHA256 against api_key_hash
  4. 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.
An open dashboard event stream re-runs the same authorization check on an interval and closes as soon as it stops passing, so a long-lived connection can't outlive the disable. Re-enabling requires a fresh sign-in.
BREAK-GLASS · no enabled admin left
UPDATE "user" SET disabled = false, role = 'admin' WHERE email = 'you@example.com';
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, subscription OAuth token endpoints, 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.

RangeClassificationBlocked
10.0.0.0/8Private †Blocked
172.16.0.0/12Private †Blocked
192.168.0.0/16Private †Blocked
127.0.0.0/8LoopbackBlocked*
169.254.0.0/16Link-localBlocked
100.64.0.0/10CGNAT †Blocked
fc00::/7Unique-local (IPv6) †Blocked
Metadata endpointsAWS / GCP / AzureBlocked
* Loopback is allowed in self-host mode only — for a local provider, and for notification targets (a loopback SMTP relay or the Apprise sidecar), which carry no provider-kind condition. It is blocked outright in cloud 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.
DNS Resolved 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 oauth subscription 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
SecretEnv varPurpose
Auth secretBETTER_AUTH_SECRETDashboard session signing
API-key HMACAPI_KEY_HMAC_SECRETAgent-key hashing, plus a domain-separated per-tenant learning HMAC
Credential keyPROVIDER_CREDENTIAL_KEYProvider secrets (plain keys + OAuth envelopes) and captured bodies
Notification keyNOTIFY_CREDENTIALS_SECRETNotification 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 Principal type makes an unscoped query a compile error — the single function that derives the ownership predicate demands one, and every scoped repository funnels through it — 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. It does two jobs from one bounded embedding per request — the band verdict, and the research / writing workload classes — never a second embed for the pair. Eight invariants hold at both the application layer and in the suites.

#InvariantEnforced by
1No telemetry for skip verdicts — a populated semantic column always came from a real evaluation.All-or-none quartet DB CHECK
2No raw embedding in any column, log line, metric, or API response.Ephemeral Float32Array, dropped after use
3No single raw embedding in Redis — only a sum over a cohort of at least SEMANTIC_LEARNING_MIN_COHORT (default 8).Accumulator flush floor
4No prompt text in an audit row — scalars only (counts, drift, similarity) and a fixed reason string.semantic_learning_event column types
5No “learned” badge when stale — a moved embedder or revision shows source: bundled.View-model + unit tests
6Revert fences everything — the revocation epoch bumps in Postgres before Redis is cleared, so every in-flight sweep's CAS fails.Ordered revert + e2e test
7Honest availability — an opted-in bundle that fails to load fails boot naming the variable and reason, so the layer is never silently inert. Where the embedder is simply absent (or its anchors do not separate) the toggle greys instead (semanticAvailable: false) and the verdict never runs.Bootstrap capability check
8A workload verdict is numbers only — the class, a confidence in [0, 1], the source, and a configuration digest computed at boot. No prompt text, no hash of it, and no vector reaches a column, the routing reason, or a log line.All-or-none quartet DB CHECK + boot-computed revision
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

An internal 19-surface multi-agent audit was 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. The report itself is an internal working document and is not published; the fixes it produced are in the public history. 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
API reference Configuration