Security hardening
CairnCMS provides primitives like auth, permissions, rate limiting, IP allowlists, and hashed passwords, but the policy decisions belong to the operator. This page covers the production-safety choices that are not on by default and the configurations that are easy to overlook.
The goal here is not exhaustive security advice; it is a checklist of the platform-specific settings that need attention before a deployment goes live.
The CairnCMS image does not terminate TLS. Run a reverse proxy (Caddy, Traefik, nginx) or a managed load balancer in front of it. Set IP_TRUST_PROXY=true (the default) so CairnCMS reads the real client IP from X-Forwarded-For.
For production over HTTPS, set the refresh-token cookie to be secure-only:
REFRESH_TOKEN_COOKIE_SECURE=trueThe default (false) is tuned for local development and should be overridden in any internet-facing deployment. Without this, refresh-token cookies can travel over plain HTTP, undermining the session-security model.
REFRESH_TOKEN_COOKIE_SAME_SITE defaults to lax and is appropriate for most deployments. Tightening to strict adds extra protection against cross-site request forgery in some scenarios, but it can break legitimate flows where the app is reached from external links or cross-site auth handoffs. Treat strict as a stricter option to evaluate, not a default-on hardening step.
For cross-domain SSO setups, SameSite=None is required (and Secure=true is mandatory in that case). See Auth.
Secrets
Section titled “Secrets”The platform requires two secret values:
KEY— instance identifier. Surfaced as the service ID in server info and health-check responses. Not part of token signing.SECRET— random secret used to sign access and refresh tokens. Treat as a credential. Changing it invalidates every existing token, so rotate deliberately.
Plus the database password, any SSO provider client secrets, any storage backend credentials, any SMTP password, and any static tokens generated for service accounts.
For production:
- Source secrets from a secret manager (your platform’s offering, HashiCorp Vault, AWS Secrets Manager, GCP Secret Manager, etc.) rather than from a
.envfile checked into source control. - Rotate
SECRETcarefully. Rotation invalidates every existing session. Sometimes that is the goal (compromise response), other times it is a surprise outage. - Rotate static tokens by clearing the user’s token field and reissuing.
- Never commit
.envfiles containing real values.
The Public role
Section titled “The Public role”The Public role applies to every unauthenticated request. By default it has no permissions; anything you add to it is granted to the entire internet.
Default discipline:
- Leave Public with no write permissions. There is essentially no legitimate reason for an unauthenticated request to create, update, or delete records on a public-facing CairnCMS instance. Spam and abuse are the certain outcome.
- For collections that need public read access, use custom permissions to scope which items and which fields are returned. A typical pattern: filter to
status: publishedand restrict the field list to fields safe for public consumption. - Audit the Public role periodically. It is the single most consequential surface in the permissions model.
For collections where any anonymous interaction is needed (a public form, a contact endpoint), prefer a flow with a Webhook trigger over a public-write Public-role permission. The flow can validate inputs before touching collection data.
Admin access
Section titled “Admin access”Admin access bypasses every permission check. Reserve it for the small number of people who actually administrate the platform.
- Treat admin access as a flag, not a role. A “marketing admin” who only edits content does not need admin access. Give them a custom role with the permissions they actually use.
- Audit who has admin access regularly. A user who needed admin during initial setup should not still have it six months later.
- Never give admin access to a service-account static token unless that token genuinely needs full unrestricted access. Most service accounts are better off with a scoped role and a static token tied to it.
CairnCMS enforces that at least one admin role must always exist, but it does not stop you from having too many admins.
Two-factor authentication
Section titled “Two-factor authentication”CairnCMS supports TOTP-based two-factor authentication. It is opt-in per user by default. For production:
- Enable Enforce 2FA on every role with elevated privileges (admin roles especially, but also any role with broad write or delete permissions). Users in that role cannot log in until they enroll.
- Enroll all admin users yourself before pushing 2FA enforcement to other roles, to make sure the rollout does not lock anyone out.
See Auth for the user enrollment flow.
Password policy
Section titled “Password policy”Set the password complexity policy in Settings > Project Settings > Security:
- None — no complexity requirement (the default).
- Weak — 8-character minimum.
- Strong — uppercase, lowercase, numbers, and special characters.
- Other — custom regex.
Strong is the recommended production baseline. The custom-regex option exists for organizations with specific compliance requirements (NIST 800-63B, ISO 27001-aligned policies, and so on).
Account lockout and login throttling
Section titled “Account lockout and login throttling”These settings are independent of the password policy and form a separate account-protection layer:
- Auth Login Attempts — number of failed logins before the account is automatically locked. Default
25. Configured under Settings > Project Settings > Security. Locked accounts must be reactivated by an admin (set the user’s status back to Active in the User Directory). LOGIN_STALL_TIME— milliseconds the platform waits before responding to a failed login. Default500. Mitigates timing attacks against the login endpoint; do not lower it.
IP allowlists
Section titled “IP allowlists”Each role can restrict access to specific source IPs. Set IP Access on the role to a comma-separated list of allowed addresses. The check runs on every authenticated request, not just login.
Two important constraints:
- The match is exact string comparison. CIDR ranges are not supported. List each address explicitly.
- The check applies after authentication, not before. A caller still proves their identity first; IP restrictions then determine whether their session is allowed to act.
IP allowlists are most useful for tightly scoped admin or service accounts whose legitimate caller comes from a known network, such as a CI worker, a backup script, a corporate VPN range.
Rate limiting
Section titled “Rate limiting”Rate limiting is off by default. Turn it on for any internet-facing deployment:
RATE_LIMITER_ENABLED=trueRATE_LIMITER_POINTS=50RATE_LIMITER_DURATION=1RATE_LIMITER_STORE=redis # for multi-instance deploymentsTwo layers are available:
- Per-IP rate limiter (
RATE_LIMITER_*) — caps requests per IP per window. Stops casual abuse. - Global rate limiter (
RATE_LIMITER_GLOBAL_*) — caps total requests across all callers. Stops aggregate load from outpacing your infrastructure.
For a multi-instance deployment behind a load balancer, the redis store is required so limits are shared. Without it, each instance limits independently and the actual cap is <configured-cap> × <instance count>.
CORS is off by default. Enable it only when a frontend on a different origin needs to call the API:
CORS_ENABLED=trueCORS_ORIGIN=https://app.example.comCORS_CREDENTIALS=trueAvoid CORS_ORIGIN=true (reflects the request origin) in production. List exact origins instead. The reflective default is convenient for development and dangerous for production — it lets any origin send authenticated cross-origin requests.
When CORS_CREDENTIALS=true, the browser sends auth cookies on cross-origin requests; the response’s Access-Control-Allow-Origin cannot be * in that case (the browser refuses), so the explicit origin list is doing real work.
Email-link allow lists
Section titled “Email-link allow lists”Password reset and user invitation flows accept an optional caller-supplied return URL for the link the recipient clicks to complete the action. When a custom URL is supplied, CairnCMS validates it against an allow list:
PASSWORD_RESET_URL_ALLOW_LIST=https://app.example.com/resetUSER_INVITE_URL_ALLOW_LIST=https://app.example.com/inviteIf no custom URL is supplied, the flow falls back to a default URL constructed from PUBLIC_URL (<PUBLIC_URL>/admin/reset-password for resets, the equivalent invitation path for invites). The default flow works without any allow-list configuration.
The allow list only gates caller-supplied URLs. Configure it whenever your frontend triggers password resets or invitations and passes its own return URL. Without an allow list, an attacker who can call the reset endpoint could redirect the recipient to a malicious site after the action completes.
Static tokens
Section titled “Static tokens”Static tokens are long-lived and never expire. Treat them like passwords:
- Store them in your secret manager, not in source control.
- Scope them to a service account with a narrow role, not to an admin user.
- Rotate periodically. Rotation requires regenerating the user’s token field; the old token is invalid the moment the new one is set.
- Audit which static tokens exist regularly. A token tied to a deprecated integration is an unnecessary credential left active.
For interactive users (a person logging in through the app), prefer the access/refresh-token flow over static tokens. Refresh tokens have built-in expiry and rotation; static tokens have neither.
File-relation deletion behavior
Section titled “File-relation deletion behavior”By default, deleting a file with a relation field set to SET NULL (the default) clears the references. The related items continue to exist with a null file pointer. This is permissive: it does not block file deletion when something still references the file.
For projects where files are critical (legal documents, audit trails, anything that should not silently disappear from related records), change the relation’s On Delete to RESTRICT or NO ACTION so file deletion is blocked while references exist. See Files for the configuration UI.
File-import SSRF hardening
Section titled “File-import SSRF hardening”POST /files/import lets the server fetch a URL and store the bytes as a new file. Because the request is issued by the server, it can potentially reach internal services unreachable from the original caller. The platform validates the resolved IP of every URL import against a deny list before opening the connection.
The default deny list blocks:
- loopback ranges (
127.0.0.0/8and IPv6::1), - any IP bound to one of the host’s own network interfaces,
- the EC2 / cloud metadata endpoint at
169.254.169.254.
Imports that resolve to a denied IP fail at the outbound connection step and return 503 SERVICE_UNAVAILABLE with a body indicating the import URL could not be fetched. Operators can extend the deny list with IMPORT_IP_DENY_LIST (comma-separated exact IPs; CIDR is not supported). See Configuration for the exact behavior, including the special meaning of 0.0.0.0.
Database
Section titled “Database”- Use TLS to the database if it travels outside your trusted network. Configure
DB_SSL__*variables to require encryption and validate certificates. - Use a dedicated database user with only the privileges CairnCMS needs (CREATE, ALTER, INDEX on its own tables; SELECT/INSERT/UPDATE/DELETE on data). Avoid running CairnCMS as the database superuser.
- For MySQL/MariaDB, set the connection charset to
utf8mb4to prevent silent truncation of UTF-8 characters that take more than 3 bytes (most CJK, all emoji). See Configuration.
Logging and accountability
Section titled “Logging and accountability”Two surfaces help when investigating an incident:
- The activity log records create, update, delete, comment, and login events with the actor, timestamp, IP, and user-agent. Reached through the Activity Log button at the bottom of the sidebar. Activity is its own module, not a Settings page.
- The server log captures process-level information through Pino. Forward it to a centralized log destination so it survives container restarts.
Two redaction layers are in place:
- HTTP request logs (pino-http) redact the
Authorizationrequest header, theCookierequest header, and theaccess_tokenquery parameter before writing the log line. - Flow revision data (written when a flow’s
accountabilityisall) redacts values associated with a known set of secret-bearing keys, and additionally redacts values that originate from those keys and propagate into later operation options.
The redaction layers target secrets, not arbitrary PII. Operator-controlled debug sinks pass content through unredacted. The Log to Console flow operation writes its message directly to the server log, the Run Script operation forwards console.* output from the sandboxed script, and SQL query tracing (when enabled at the database driver level) emits raw query bindings. Treat these as debug-only surfaces. Do not route secret values through them.
For audit-heavy projects, leave activity logging on (the default) and configure the role’s accountability tracking to include revisions, not just activity. Revisions let you reconstruct an item’s full history; activity records what happened.
Where to go next
Section titled “Where to go next”- Configuration is the reference for every environment variable mentioned here.
- Auth covers SSO, two-factor enrollment, and session details.
- Permissions covers the role and permission system the Public role and admin access flag belong to.
- Backups covers the backup-and-recovery side of operational safety.