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 markedSecurewheneverAPP_URLuseshttps. OnlyAPP_URLis 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_DOMAINSandPLATFORM_ADMIN_EMAILSall 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_EMAILSnames the admins); everyone else needs an invitation. SeeSIGNUP_MODEandALLOWED_SIGNUP_DOMAINSin Configuration.
Roles and permissions
Each workspace member has one of three roles.
| Capability | Member | Admin | Owner |
|---|---|---|---|
| See meetings and leads | Only meetings they host, co-host or booked, and leads they own or meet with | All | All |
| View routers and reports | No | Yes | Yes |
| Edit their own availability and connect their calendar | Yes | Yes | Yes |
| Create and edit their own individual booking links | Yes | Yes | Yes |
| Use Handoff, enrich their leads and change their leads' owners | Yes | Yes | Yes |
| Test routers | No | Yes | Yes |
| Cancel, reschedule or mark meetings | Only meetings they host, co-host or booked | All | All |
| Edit team booking links and other people's availability | No | Yes | Yes |
| Build routers and manage teams | No | Yes | Yes |
| Connect integrations, create API keys and webhooks | No | Yes | Yes |
| Change workspace settings, invite and manage members, view the audit log | No | Yes | Yes |
| Invite owners, promote to owner, change other owners | No | No | Yes |
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
| Endpoint | Limit |
|---|---|
| Routing a lead from the widget or a router link | 30 per minute per IP address |
| Booking a meeting | 10 per minute per IP address |
| Loading available times | 120 per minute per IP address |
| REST API and MCP | 600 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.