import type { Metadata } from "next"; import Link from "next/link"; import { ArrowRight, Cookie, Fingerprint, Gauge, KeyRound, ScrollText, ShieldCheck } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Container, Eyebrow, Section, SectionHeading } from "@/components/marketing/section"; import { Reveal } from "@/components/marketing/reveal"; import { KeyLifecycle, SecurityFlow } from "@/components/marketing/security-flow"; export const metadata: Metadata = { title: "Security", description: "How PolyLLM protects your API keys and account: AES-256-GCM key encryption with HKDF-derived keys, Argon2id passwords, HttpOnly cookies, a strict Content-Security-Policy, per-route rate limits and an audit log. Every claim on this page is verified in the source code.", alternates: { canonical: "/security" }, openGraph: { title: "PolyLLM — Security", description: "Where your keys go, how they are encrypted, and what we log.", url: "/security" }, }; /* ------------------------------------------------------------------------------------------------ * Facts verified in code (see docs/upgrade-notes/F-marketing.md for the file references) * ---------------------------------------------------------------------------------------------- */ const AUTH_LIMITS: [string, string][] = [ ["Sign in", "8 / min"], ["Sign up", "4 / min"], ["Password reset request", "4 / min"], ["Verification e-mail", "3 / min"], ["Change password", "5 / min"], ["Change e-mail", "3 / min"], ["Delete account", "3 / min"], ["Any other auth route", "100 / min"], ]; const APP_LIMITS: [string, string][] = [ ["Chat requests", "60 / min"], ["Arena sessions", "20 / min"], ["Arena streams", "80 / min"], ["Save a key", "12 / min"], ["Validate a key", "10 / min"], ["Model sync", "6 / min"], ["File uploads", "40 / min"], ["Search", "120 / min"], ["Share / conversation actions", "20 / min"], ]; const HEADERS: [string, string][] = [ ["Content-Security-Policy", "default-src 'self'; frame-ancestors 'none'; object-src 'none'; base-uri 'self'; form-action 'self'; connect-src 'self'; img-src 'self' data: blob: https:; font-src 'self' data:; worker-src 'self' blob:; upgrade-insecure-requests"], ["Strict-Transport-Security", "max-age=31536000; includeSubDomains (production)"], ["X-Frame-Options", "DENY"], ["X-Content-Type-Options", "nosniff"], ["Referrer-Policy", "strict-origin-when-cross-origin"], ["Permissions-Policy", "camera=(), microphone=(), geolocation=(), payment=()"], ]; const AUDIT_EVENTS = ["account.created", "login", "email.verified", "email.change_requested", "password.reset_requested", "password.reset", "provider.key_added", "provider.key_replaced", "provider.key_deleted", "account.deleted", "export · share · branch · duplicate"]; export default function SecurityPage() { return ( <> {/* Header */}
Security

Where your keys go, how they are encrypted, and what we log.

PolyLLM handles the most sensitive thing a developer owns: API keys with a billing account attached. This page describes the actual implementation — every statement below is checked against the source code, not a policy document. When something is a trade-off, we say so.

{/* Flow */}
{/* Key lifecycle */}
{/* Account & sessions */}

Argon2id password hashing

Passwords are hashed with Argon2id at the OWASP-recommended parameters: 19 MiB memory, 2 iterations, parallelism 1 (the argon2 native library, configured in Better Auth). Minimum 10 and maximum 128 characters. The password itself is never stored.

HttpOnly, SameSite=Lax, Secure cookies

Sessions live in Better Auth cookies prefixed polyllm: HttpOnly (not readable by JavaScript), SameSite=Lax, and Secure in production. Sessions last 30 days and are refreshed daily; a 5-minute cookie cache avoids a database round-trip on every request. Nothing sensitive is kept in localStorage.

Verified e-mail, revocable sessions

E-mail verification is mandatory before the first sign-in (links valid 24 h). Password reset links expire after 60 minutes and every other session is revoked when the password changes. Changing your e-mail or deleting the account requires a confirmation link sent to the current address. Auth requests are only accepted from the app's own origin.

{/* Headers */}
Set globally in next.config.ts. The site cannot be framed, scripts and connections are restricted to our own origin, and HTTPS is enforced for a year with HSTS. The X-Powered-By header is removed. } />

Trade-off, stated plainly: script-src and style-src include 'unsafe-inline' because Next.js hydration and Radix components inject inline scripts and style attributes. There are no third-party scripts on the site, so the practical exposure is small, but nonces would be stricter and are on the list.

{HEADERS.map(([k, v]) => (
{k}
{v}
))}
{/* Rate limits */}
{[ { title: "Authentication", rows: AUTH_LIMITS, icon: }, { title: "Application", rows: APP_LIMITS, icon: }, ].map((group, gi) => (

{group.icon} {group.title}

{group.rows.map(([k, v]) => (
{k}
{v}
))}
))}
{/* Audit log */}

Server logs are structured JSON; every string passes through a secret redactor (provider key patterns, bearer tokens) and keys named like api key, password, token, cookie, prompt or content are replaced by [redacted]. Prompts and answers are not logged.

    {AUDIT_EVENTS.map((e) => (
  • {e}
  • ))}

What we deliberately do not do

  • · No analytics trackers, advertising pixels or fingerprinting scripts on any page.
  • · No third-party inference brokers: requests go to the provider you picked and nowhere else.
  • · No training on your data, and temporary chats are never written to the database.
  • · No owner keys for user traffic: the operator's own provider keys are used only to sync the public model registry.
{/* CTA */}

Questions about any of this?

Write to{" "} contact@spboucher.ai . Responsible disclosure is welcome; please give us a reasonable window before publishing.

); }