Self-hosting / Security

Self-hosting

Security

How data, credentials, sessions and public endpoints are protected.

Cauliflower handles calendars, CRM access and personal data about your leads, so it is built to keep that data in your hands and to be safe on the public internet. This page explains what is stored where, how credentials and sessions are protected, who can do what, and how the public parts of the product — booking pages, the widget and webhooks — are hardened.

Where your data lives

Everything Cauliflower stores is in your PostgreSQL database: accounts and sessions, workspaces, leads and their enrichment, routing decisions, meetings, the background job queue and webhook delivery logs. There is no separate file store and no Cauliflower-operated service in the loop when you host it yourself.

Data only leaves your installation for the services you connect:

  • Google — free/busy lookups and calendar events for hosts who connect Google Calendar, and sign-in with Google if enabled.
  • Your CRM (HubSpot, Salesforce or Attio), Apollo and Slack — lookups and updates for CRM matching, enrichment and notifications.
  • Resend or your SMTP server — the emails Cauliflower sends.
  • The AI provider your operator configures — questions asked in the AI assistant and the workspace data it looks up to answer them.
  • Your webhook endpoints — the events you subscribe to.

Better Auth's telemetry is turned off, and the Docker image disables Next.js telemetry.

Credentials and secrets

Encrypted integration credentials

Google Calendar access and refresh tokens, CRM credentials (HubSpot, Salesforce and Attio), the Slack bot token, the Apollo API key and platform secrets (Resend, PostHog and AI provider keys) are encrypted before they are written to the database, using AES-256-GCM with a random IV per value and an authentication tag that detects tampering.

The key comes from ENCRYPTION_KEY (32 bytes, as hex or base64). If you don't set one, a key is derived from BETTER_AUTH_SECRET with HKDF-SHA256. A dedicated key is recommended: it lets you treat the two secrets separately, and Admin → System shows whether one is configured.

To rotate the key, set the new one as ENCRYPTION_KEY and keep the old one in ENCRYPTION_KEY_PREVIOUS (comma separated for several). New values use the new key; old values stay readable. The key derived from BETTER_AUTH_SECRET is always tried too, so adding ENCRYPTION_KEY to an existing installation doesn't lock anything out. If stored platform secrets can't be decrypted with any configured key, Cauliflower refuses to save over them and logs the problem, instead of silently replacing them.

Webhook signing secrets are stored as-is, because Cauliflower needs them to sign every delivery. Workspace admins can reveal and rotate them.

Hashed API keys

API keys are generated from 30 random bytes and shown exactly once. Cauliflower stores a SHA-256 hash of the key and its first 14 characters for display, so a database leak doesn't expose usable keys. Keys can be given an expiry date and revoked at any time, and the key list shows when each was last used. See API keys.

Signed tokens

Short-lived state, such as the OAuth state during Google Calendar connection, is signed with an HMAC of BETTER_AUTH_SECRET and expires after 10 minutes. Routing tokens and booking manage links use random 32-character tokens (about 190 bits of entropy). A routing token can book a single meeting and expires after 7 days.

Sign-in and sessions

Authentication is handled by Better Auth with email and password (8 to 128 characters) and optional Google sign-in.

  • Sessions are stored in the database, last 30 days and are refreshed daily while in use. Session details are cached in a signed cookie for up to a minute, so a session revoked or suspended from the admin portal stops working within a minute. Resetting a password signs out every other session.
  • Session cookies are prefixed cauliflower, are HTTP-only, and are marked Secure whenever APP_URL uses https. Only APP_URL is trusted as an origin for authentication requests.
  • In production, sign-in is limited to 10 attempts per minute, sign-up to 5, and password reset requests to 3, counted in the database so the limits hold across every server instance.
  • New accounts get an email to confirm their address (Google sign-ins are confirmed already). People can use the app right away, but nothing is granted because of an address until it's confirmed: pending invitations, ALLOWED_SIGNUP_DOMAINS and PLATFORM_ADMIN_EMAILS all wait for it. An invitation link works before that, because the link itself proves the person received the email.
  • Inviting someone who already has an account sends them an invitation too: nobody is added to a workspace without accepting.
  • Password reset links expire after one hour. Invitations expire after 14 days and can only be accepted by the invited email address.
  • By default only the first person can sign up and becomes the platform admin (unless PLATFORM_ADMIN_EMAILS names the admins); everyone else needs an invitation. See SIGNUP_MODE and ALLOWED_SIGNUP_DOMAINS in Configuration.

Roles and permissions

Each workspace member has one of three roles.

CapabilityMemberAdminOwner
See meetings and leadsOnly meetings they host, co-host or booked, and leads they own or meet withAllAll
View routers and reportsNoYesYes
Edit their own availability and connect their calendarYesYesYes
Create and edit their own individual booking linksYesYesYes
Use Handoff, enrich their leads and change their leads' ownersYesYesYes
Test routersNoYesYes
Cancel, reschedule or mark meetingsOnly meetings they host, co-host or bookedAllAll
Edit team booking links and other people's availabilityNoYesYes
Build routers and manage teamsNoYesYes
Connect integrations, create API keys and webhooksNoYesYes
Change workspace settings, invite and manage members, view the audit logNoYesYes
Invite owners, promote to owner, change other ownersNoNoYes

A workspace always keeps at least one active owner. Changes made by people and API keys are recorded in Settings → Audit log.

API keys act with full workspace access, regardless of who created them, so treat each key like an admin account. OAuth connections (such as an AI agent over MCP) act as the person who approved them: a member's connection only reaches that member's meetings and leads.

Everyone needs a seat to use a workspace; without one, sign-in leads to a page explaining who can give them one, and their API and MCP access stops.

Platform admins

Platform admins manage the installation as a whole: every workspace and user, impersonation, suspensions and failed background work. Every action in the admin portal is written to a separate platform audit log with the admin and, for account actions, their IP address. Impersonation sessions last at most an hour, show a banner on every page, and can't be used to reach the admin portal. Platform admins themselves can't be impersonated.

Public endpoints

Booking pages, the widget and the public routing API are reachable without signing in, so they have extra protection.

Rate limits

EndpointLimit
Routing a lead from the widget or a router link30 per minute per IP address
Booking a meeting10 per minute per IP address
Loading available times120 per minute per IP address
REST API and MCP600 per minute per API key

Limits are counted in Postgres, so they hold across every server instance (serverless functions don't share memory). The client IP comes from the one header your platform sets, never from a header a visitor could send: on Vercel x-real-ip; behind Cloudflare's proxy cf-connecting-ip (set TRUSTED_PROXY=cloudflare); behind your own reverse proxy, the last X-Forwarded-For entry. See Reverse proxy and TLS.

Spam and payload hygiene

The booking form includes a hidden honeypot field; submissions that fill it in are rejected. Routing payloads are capped at 100 fields with values cut to 2,000 characters, and fields whose names look like passwords, card numbers, CVVs or social security numbers are dropped before anything is stored.

Widget origin allow-list

Under Settings → Widget security & qualification, list the sites allowed to route leads through your widget — exact origins like https://www.acme.com or wildcards like *.acme.com. Browser requests from any other site are rejected with 403. Your Cauliflower domain itself is always allowed. When the list is empty, any site can use the widget.

Webhooks and outbound requests

Webhook URLs are checked when they're saved and again when every delivery connects. In production they must use https; hostnames such as localhost or *.internal are rejected; and the request is refused if the address it actually connects to is private or reserved — loopback, private ranges, link-local and cloud metadata addresses, carrier-grade NAT, multicast and their IPv6 equivalents. Because the check happens on the resolved address at connect time, DNS tricks that point a public name at an internal address afterwards don't get through. Redirects are never followed. Every delivery is signed with HMAC-SHA256 so receivers can verify it. See Webhooks.

The same protection covers the blog's publish webhook and images the content API downloads from the web (where up to three redirects are followed, each checked the same way).

URLs that people are sent to, such as a booking link's redirect after booking or a router's redirect step, must be http or https, and the booking page and widget check again before navigating. The widget only acts on messages from Cauliflower frames it opened itself.

Exports

CSV exports of leads, meetings and routing decisions neutralize spreadsheet formula injection: any cell that starts with =, +, -, @, a tab or a carriage return is prefixed with a single quote, so a malicious form entry can't run as a formula when someone opens the file.

Framing and headers

The application sends Content-Security-Policy: frame-ancestors 'self'; base-uri 'self'; object-src 'none', so the dashboard and settings can't be embedded in another site for clickjacking. Booking pages (/book, /r, /booking), the public API and embed.js are deliberately left embeddable so the widget and inline booking work on your website.

Every response also sends Strict-Transport-Security (two years, including subdomains), X-Content-Type-Options: nosniff, Referrer-Policy: strict-origin-when-cross-origin and a Permissions-Policy that disables camera, microphone and geolocation. The X-Powered-By header is removed.

Deleting data

Deleting a user that hosts meetings is refused, so meetings never disappear as a side effect: suspend the account instead. Deleting a workspace removes everything in it and asks for its slug to confirm. Back up the database before either (see Backups).

No double bookings

When a meeting is booked, availability is checked again against fresh Google Calendar free/busy data rather than a cache. The meeting is then written inside a database transaction that holds a Postgres advisory lock for each host and performs a final overlap check including buffers, so two people racing for the same slot can't both win — the second gets a clear "that time was just booked" message. Identical submissions are serialized too, and a retried request returns the meeting that was already created instead of booking a duplicate.

Reporting a vulnerability

If you find a security issue, please report it privately to the maintainers rather than opening a public issue. Include the version or commit you tested, steps to reproduce and the impact you expect. Give the maintainers reasonable time to release a fix before you disclose details publicly.