Security
Authentication, rate limiting, encryption at rest, webhook verification, and production hardening.
Vardo's security model covers authentication, encryption, rate limiting and multi-tenant isolation. Here's how it all works.
Authentication
Vardo uses Better Auth for authentication. Four sign-in methods are available — enable or disable them with feature flags and environment variables.
| Method | How it works | Requirements |
|---|---|---|
| Passkeys (WebAuthn) | Hardware keys, Touch ID, Face ID, Windows Hello. FIDO2-compatible. | None |
| GitHub OAuth | Sign in with GitHub. Auto-links accounts by email. | GITHUB_CLIENT_ID, GITHUB_CLIENT_SECRET |
| Magic link | Passwordless email link. Expires in 10 minutes. | Email provider configured (SMTP, Mailpace, Resend or Postmark) |
| Email + password | Standard credentials with optional TOTP 2FA. Minimum 8 characters. | Enabled by default; disable with passwordAuth feature flag |
Provider restrictions
Two environment variables gate specific auth methods:
ALLOW_PASSWORD_AUTH— set tofalseto disable password-based login entirelyALLOW_SMTP— controls whether SMTP email delivery is available (affects magic link)
The passwordAuth feature flag also controls whether the password form appears on the login page. When disabled, users authenticate via passkey, magic link or GitHub OAuth.
Passkeys
Passkey authentication follows the WebAuthn standard. Credentials are stored in the passkey table and scoped to the application origin — they can't be used on a different domain.
Two-factor authentication (TOTP)
2FA uses TOTP (RFC 6238) with the issuer name Vardo. Backup codes are generated at enrollment for one-time recovery if the authenticator app is lost.
Users enable 2FA from their account settings. It's opt-in per user — admins can't force it organization-wide yet.
Sessions
- Duration: 7 days
- Refresh: updated every 24 hours on activity
- Storage: PostgreSQL
sessiontable - Org context: the active organization is tracked via the
host_current_orgcookie. Membership is verified against the database on every request — the cookie isn't trusted on its own.
Account linking
GitHub and Google are configured as trusted OAuth providers. If the email matches an existing account, the OAuth identity is auto-linked — users can sign in with OAuth after previously creating an account with email and password.
Registration modes
Three modes control who can create accounts:
- Open — anyone can register
- Closed — registration is disabled
- Approval required — new accounts need admin approval
Rate limiting
Two layers of rate limiting protect the API.
Layer 1: middleware (IP-based)
Every /api/ request hits an in-memory per-IP limiter first:
- Limit: 200 requests per minute per IP
- Implementation: in-memory
Mapwith a 10,000-entry cap (LRU eviction) - Why in-memory: middleware runs on every request — hitting Redis here adds latency for a coarse abuse check. For a single-process self-hosted deployment, in-memory counters are correct.
Layer 2: route-level (Redis-backed)
Sensitive routes use withRateLimit() with a Redis sliding window limiter. Six tiers:
| Tier | Limit | Window | Identity | Used for |
|---|---|---|---|---|
auth | 10 req | 60s | IP | Login, signup, magic link, passkey |
public | 30 req | 60s | IP | Webhooks, mesh join |
mutation | 60 req | 60s | User ID | Create, update, delete |
read | 120 req | 60s | User ID | List, get, stream |
admin | 30 req | 60s | User ID | Admin API endpoints |
critical | 10 req | 60s | Org ID | Deploy, rollback |
The sliding window uses a Redis sorted set with a Lua script for atomicity. The Retry-After header tells you exactly when the oldest request ages out.
If Redis goes down, rate limit checks pass — a Redis outage won't take down the API. The in-memory middleware layer keeps running regardless.
All Better Auth routes (/api/auth/[...all]) share the auth tier — 10 requests per minute per IP covers sign-in, sign-up, magic link and passkey registration in a single budget.
Encryption at rest
Environment variables and secrets stored in the database are encrypted using AES-256-GCM.
Key hierarchy
Each encryption operation generates a random 12-byte IV. Ciphertext format:
enc:v1:<iv_hex>:<ciphertext_hex>:<auth_tag_hex>The enc:v1: prefix makes encrypted values unambiguous — no false positives on plaintext that happens to contain colons.
What's encrypted
- App environment variables — encrypted with a per-org derived key
- System settings — GitHub App private key, webhook secret, email API keys, backup storage credentials (encrypted with the system key)
Generating a master key
openssl rand -hex 32Don't lose your master key
Set this as ENCRYPTION_MASTER_KEY in .env or vardo.secrets.yml. Changing it makes previously encrypted data unreadable.
Fallback behavior
Values that aren't in the enc:v1: format are returned as-is. This supports rows written before encryption was enabled without breaking reads.
Webhook verification
The GitHub webhook endpoint at /api/v1/github/webhook verifies every request using HMAC-SHA256:
expected = "sha256=" + HMAC-SHA256(secret, request_body)Comparison uses timingSafeEqual to prevent timing attacks. If GITHUB_WEBHOOK_SECRET isn't configured, the endpoint returns 500 and refuses all requests — there's no insecure fallback.
The webhook secret is sourced from:
- Database (
system_settings->github_app) — preferred for setup-wizard deployments GITHUB_WEBHOOK_SECRETenvironment variable — fallback
Multi-tenant isolation
Strict org-scoping
All database queries are scoped by organization_id.
The verifyOrgAccess() helper — used in all /api/v1/organizations/[orgId]/ routes — verifies the authenticated user is a member of the requested organization before the handler runs. An org ID in the URL that the user doesn't belong to returns 403.
Roles within an organization: owner, admin and member.
The isAppAdmin flag on the user table grants access to the admin panel and admin API routes via requireAdmin().
API tokens
API tokens provide programmatic access to the REST API. Create them in Settings -> API Tokens.
- Prefix:
vardo_ - Storage: SHA-256 hashed — the plaintext is shown only once at creation
- Scope: tied to a specific organization and user
curl -s https://vardo.example.com/api/v1/organizations \
-H "Authorization: Bearer vardo_<your-token>"When authenticating via token, the org context is derived from the token itself — the host_current_org cookie isn't required.
CSRF protection
Better Auth includes CSRF protection for its own endpoints. Vardo's API routes (/api/v1/) are protected by session cookie authentication — requests without a valid session are rejected before any state-changing operation runs.
Secret management
Config files
Vardo supports a two-file configuration system:
vardo.yml— non-secret settings (instance name, domain, feature flags). Safe to commit.vardo.secrets.yml— secrets (encryption key, auth secret, API keys). Must bechmod 0600and gitignored.
Config file takes priority over database settings, which takes priority over environment variables.
# vardo.secrets.yml
encryptionKey: "<64-char hex>"
authSecret: "<random string>"
email:
apiKey: "<mailpace or resend api key>"
backup:
accessKey: "<s3 access key>"
secretKey: "<s3 secret key>"
github:
clientSecret: "<github app client secret>"
privateKey: "<base64-encoded PEM>"
webhookSecret: "<random string>"Environment variables
For .env.prod deployments, the required secrets:
| Variable | Description |
|---|---|
ENCRYPTION_MASTER_KEY | 256-bit hex key. Generate: openssl rand -hex 32 |
BETTER_AUTH_SECRET | Auth signing secret. Generate: openssl rand -hex 32 |
GITHUB_WEBHOOK_SECRET | HMAC secret for GitHub App webhooks |
GITHUB_CLIENT_SECRET | GitHub OAuth app client secret |
Never commit .env.prod or vardo.secrets.yml to version control.
Cloudflare recommendations
If Vardo is behind Cloudflare:
- SSL/TLS mode: Full (Strict) — prevents SSL stripping between Cloudflare and your server
- "Under Attack" mode during incidents — adds a JS challenge before requests reach Vardo
- Rate limiting rules — Cloudflare's WAF can rate-limit at the edge. Additive, not a replacement.
- Firewall rules — block
/api/v1/github/webhookexcept from GitHub's IP ranges CF-Connecting-IP— ensure Cloudflare's header is passed correctly for rate limit identity
If using Cloudflare, set NEXT_PUBLIC_BETTER_AUTH_URL and NEXT_PUBLIC_APP_URL to the Cloudflare-fronted domain, not the origin.
Production hardening checklist
-
ENCRYPTION_MASTER_KEYset to a 256-bit random hex value -
BETTER_AUTH_SECRETset to a long random string -
GITHUB_WEBHOOK_SECRETset (if GitHub App is configured) -
vardo.secrets.ymlischmod 600and gitignored - Traefik configured with a valid ACME email for Let's Encrypt
- Ports 7300 (cAdvisor) and 7400 (Loki) firewalled — not publicly accessible
- PostgreSQL and Redis on internal Docker network — not publicly accessible
-
passwordAuthfeature flag set tofalseif you only want passkey/OAuth login - Registration mode set to
closedorapprovalafter initial setup - Cloudflare or equivalent DDoS protection in place
- Backups configured and tested
- SSH access restricted (key-only, no password)