DeveloperSecurity

Security. Defense in depth.

Plooi leans on Postgres RLS, per-row encryption, strict secret handling, and server-side validation. No single layer is the wall — every layer is.

What protects what

  • Row Level Security (RLS) on every table — auth.uid() = user_id enforced on SELECT, INSERT, UPDATE, DELETE, with a WITH CHECK on every mutation.
  • BYOK key encryption — AES-256-GCM with a per-row salt and nonce, key material derived per-row via HKDF-SHA256 from a master key kept in Supabase Function secrets (a separate store from Postgres).
  • Bcrypt hashing for public-link passwords.
  • Security headers — CSP, HSTS, X-Frame-Options, referrer policy.
  • Stripe webhook signing and atomic idempotency at the database layer.
  • Audit logs for every admin action, key save/delete, and deletion request.
  • Granular admin roles scoped by capability rather than blanket access.

How BYOK keys are stored

  • Cipher: AES-256-GCM, per-row 16-byte salt + 12-byte nonce.
  • Key derivation: HKDF-SHA256 against a master key held in Supabase Function secrets — not in Postgres, not in env vars shipped to the browser.
  • Writes: browser → store-api-key edge function → encrypt → store. Plaintext exists for one network hop.
  • Reads from the browser: only through the user_api_keys_public view, which projects id, service_name, key_hint (last four chars), and key_version. The ciphertext column is REVOKEd from authenticated entirely.
  • Decrypts: only inside ai-proxy, in memory, for one provider call. Plaintext is discarded on response.
  • Never written to application logs, audit logs, Sentry, analytics, or database backups.

Errors that bubble up from providers are sanitized server-side. Patterns like sk-ant-*, sk-proj-*, AIzaSy*, ?key=*, and Bearer * are stripped before they reach the browser, Sentry, or any log line.

See Bring your own key for the canonical version.

Secrets and keys

  • Frontend variables only ever via VITE_* — these end up in the browser, so they cannot hold anything sensitive.
  • Server-side secrets only via Supabase Secrets — Stripe webhook key, Service Role key, master encryption key, cron secrets.
  • The Service Role key is never used in the frontend. If you find a reference, it’s a bug.
⚠️

Never commit secrets to git or .env.local. Anything with admin privileges lives in Supabase Secrets.

Ongoing practice

  • MFA for admins (planned).
  • Dependabot for dependency updates.
  • Monitoring on unusual auth events and on the rate of store-api-key / delete-api-key calls per user.