Vardo

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.

MethodHow it worksRequirements
Passkeys (WebAuthn)Hardware keys, Touch ID, Face ID, Windows Hello. FIDO2-compatible.None
GitHub OAuthSign in with GitHub. Auto-links accounts by email.GITHUB_CLIENT_ID, GITHUB_CLIENT_SECRET
Magic linkPasswordless email link. Expires in 10 minutes.Email provider configured (SMTP, Mailpace, Resend or Postmark)
Email + passwordStandard 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 to false to disable password-based login entirely
  • ALLOW_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 session table
  • Org context: the active organization is tracked via the host_current_org cookie. 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 Map with 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:

TierLimitWindowIdentityUsed for
auth10 req60sIPLogin, signup, magic link, passkey
public30 req60sIPWebhooks, mesh join
mutation60 req60sUser IDCreate, update, delete
read120 req60sUser IDList, get, stream
admin30 req60sUser IDAdmin API endpoints
critical10 req60sOrg IDDeploy, 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 32

Don'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:

  1. Database (system_settings -> github_app) — preferred for setup-wizard deployments
  2. GITHUB_WEBHOOK_SECRET environment 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 be chmod 0600 and 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:

VariableDescription
ENCRYPTION_MASTER_KEY256-bit hex key. Generate: openssl rand -hex 32
BETTER_AUTH_SECRETAuth signing secret. Generate: openssl rand -hex 32
GITHUB_WEBHOOK_SECRETHMAC secret for GitHub App webhooks
GITHUB_CLIENT_SECRETGitHub OAuth app client secret

Never commit .env.prod or vardo.secrets.yml to version control.

Cloudflare recommendations

If Vardo is behind Cloudflare:

  1. SSL/TLS mode: Full (Strict) — prevents SSL stripping between Cloudflare and your server
  2. "Under Attack" mode during incidents — adds a JS challenge before requests reach Vardo
  3. Rate limiting rules — Cloudflare's WAF can rate-limit at the edge. Additive, not a replacement.
  4. Firewall rules — block /api/v1/github/webhook except from GitHub's IP ranges
  5. 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_KEY set to a 256-bit random hex value
  • BETTER_AUTH_SECRET set to a long random string
  • GITHUB_WEBHOOK_SECRET set (if GitHub App is configured)
  • vardo.secrets.yml is chmod 600 and 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
  • passwordAuth feature flag set to false if you only want passkey/OAuth login
  • Registration mode set to closed or approval after initial setup
  • Cloudflare or equivalent DDoS protection in place
  • Backups configured and tested
  • SSH access restricted (key-only, no password)

On this page