Security & credentials
Vibesboard is multi-tenant self-hosted software. This page covers how it protects routes, stores secrets, and validates outbound requests that tenants configure themselves. For how tenant data is isolated at the database layer, see multi-tenancy & RLS.
Route protection
Route protection happens in two layers:
proxy.ts(Next.js middleware) does a lightweight, cookie-presence check before a request reaches a page or route handler. It looks for Better Auth's session cookie (__Secure-better-auth.session_tokenon HTTPS,better-auth.session_tokenon plain HTTP for local dev) and redirects unauthenticated requests to/sign-infor any path that isn't explicitly public — sign-in/sign-up/password-reset pages,/invite/*,/widget/*,/docs*, the marketing/landing routes, and public agent pages (/{tenantSlug}/{agentSlug}and/{tenantSlug}/l/{linkSlug}).- API routes and server components do the real authentication and
authorization. The proxy cannot run full session verification or role checks —
those require database access, which middleware doesn't have. Route handlers
call helpers like
requireAuthandrequireTenantAdmin(fromapps/web/lib/auth/route-handler) to verify the session and enforce role-based access control before touching tenant data.
Middleware is a UX redirect, not the security boundary
The cookie-presence check in proxy.ts only decides whether to bounce a
browser to the sign-in page. It does not validate the session or check roles —
a request that reaches an API route or server component is still independently
authenticated and authorized there. Don't rely on middleware alone when adding
a new protected route; the handler itself must call the auth helpers.
/api/* paths are excluded from the middleware matcher entirely (along with
_next/static, _next/image, and static assets) — API routes are expected to
handle their own auth rather than depend on the proxy.
Credentials
Tenant-supplied secrets — LLM provider API keys, OAuth tokens (Google Calendar), and channel credentials (WhatsApp, Instagram) — are encrypted at rest before they're written to the database, and read APIs never return them in plaintext.
ENCRYPTION_KEYencrypts tenant LLM configs, OAuth tokens, and channel credentials via AES (CryptoJS) before storage — for example in theapi_key_encryptedcolumn oftenant_llm_configs. Changing this value makes every previously stored credential undecryptable, so treat it like any other long-lived production secret.- Decryption happens in-process at inference/use time through a
CredStoreinterface (seal/unseal/revoke). The shipped implementation isEncryptedDbCredStore(packages/ai/src/cred-store/); swapping the exported store to a different backend (e.g. a secrets manager) doesn't require changing call sites. - Encrypted key material never reaches read API responses — for example
GET /api/tenants/llm-configs(packages/ai/src/tenant-llm-config.ts) maps each row through a view function that only forwards non-secret fields (label, kind, model, base URL, flags, timestamps), soapiKeyEncryptednever gets serialized into the JSON response, even though the underlying query reads the full row. BETTER_AUTH_SECRETsigns and verifies session tokens and is required in production.ACCESS_GATE_SECREThashes access passwords and signs access cookies for password-gated public agent links (packages/ai/src/access-gate-crypto.ts). See public links & access gates.
See environment variables for the full list of required secrets and how to generate them.
Outbound request validation (SSRF protection)
Two places let a tenant admin point Vibesboard at an arbitrary URL: a custom LLM
provider baseUrl (for openai_compatible configs) and outgoing webhook URLs.
Both are validated before the server makes a request to them, to reduce the risk
of the server being used to reach internal/private network addresses.
LLM provider baseUrl
openai_compatible provider configs (see
bring your own LLM) accept a tenant-supplied
baseUrl. validateProviderBaseUrl (packages/ai/src/provider-ssrf-guard.ts)
is applied twice — once when the config is saved via the API route, and again at
model-construction time as defense-in-depth:
- Only
http:andhttps:schemes are accepted. - Loopback, link-local (including the
169.254.169.254cloud metadata address), and private IPv4/IPv6 ranges (10.x,172.16–31.x,192.168.x, CG-NAT100.64–127.x, andfc00::/7/fd00::/8IPv6) are rejected by default, along withlocalhostand*.localhost. - A workspace admin can opt out per-config with an explicit "allow private hosts" flag, or add specific hostnames to a per-tenant allowlist — both are there for on-premise/self-hosted model endpoints (e.g. Ollama, LM Studio on the local network).
Webhook URLs
Tenant-configured outbound webhook URLs go through a separate validator,
validateWebhookUrl (packages/data/src/validate-webhook-url.ts), with the same
shape of checks: only http/https, loopback and 0.0.0.0 blocked, the GCP
metadata hostname (metadata.google.internal) and IP blocked explicitly
alongside 169.254.169.254, and the standard RFC 1918 private ranges blocked.
See webhooks for how webhook delivery works.
IP-based, not DNS-rebinding-proof
Both guards validate the hostname/IP literal in the URL at save time (and, for provider URLs, again at request time). Neither re-validates the IP a hostname resolves to at the moment of the actual outbound fetch, so a hostname that resolves to a public IP at validation time and a private IP later (DNS rebinding) is not defended against today. Keep this in mind before treating tenant-supplied URLs as fully trusted, especially in on-premise deployments where private hosts are intentionally allowed.
Test-connection error handling
The LLM provider "Test connection" endpoint returns sanitized error messages (e.g. "Authentication failed — check your API key") rather than raw provider error bodies, which could otherwise leak internal service details. Full errors are logged server-side only.
Automated scanning
CI runs on every pull request and push to dev/main
(.github/workflows/security.yml):
- Gitleaks — scans tracked files for committed secrets.
- Semgrep — SAST scan of application code (
semgrep scan --error). - Trivy — filesystem vulnerability scan for CRITICAL and HIGH severity issues, failing the job on any match (unfixed findings are ignored).
- Lizard — cyclomatic complexity gate (CCN 15), scoped to catch new complexity regressions rather than flagging the existing backlog.
Reporting a vulnerability
Report security issues privately rather than opening a public GitHub issue —
use GitHub's private vulnerability reporting on the repository, or email
hi@vibesboard.com.