Skip to content

Security hardening

BottleCRM is multi-tenant: every deployment holds more than one organization's data behind the same database and the same application process. This page collects the settings and surfaces that matter most once you're running something other people depend on, most of which are covered in more depth elsewhere in Self-hosting. This page is the checklist, with pointers to the detail.

Production checklist

  • DEBUG=False. crm/settings.py reads it as os.environ.get("DEBUG", "False").lower() == "true", so it's already False by default. The failure mode to avoid is .env.docker (which ships DEBUG=True for local development) carrying into a production environment unchanged.
  • A real SECRET_KEY, not the shipped django-insecure-... placeholder. See Secrets below.
  • ALLOWED_HOSTS set to your real hostname(s), and CSRF_TRUSTED_ORIGINS set with a scheme (https://crm.example.com, not a bare hostname), both detailed in Production deployment.
  • CORS_ALLOW_ALL=False (the default) with CORS_ALLOWED_ORIGINS scoped to your real frontend origin(s); .env.docker sets CORS_ALLOW_ALL=True for local development, which should not carry into production either.
  • SECURE_PROXY_SSL_HEADER added yourself if you terminate TLS at a reverse proxy, nothing in crm/settings.py sets it, so without it Django never considers a proxied request secure and the HSTS headers this project does set unconditionally (SECURE_HSTS_SECONDS, SECURE_HSTS_INCLUDE_SUBDOMAINS, SECURE_HSTS_PRELOAD) silently never get sent. See Production deployment.
  • A non-superuser database role: see Database role below.
  • Don't set ENV_TYPE=prod casually. It's not just a flag: it makes AWS_BUCKET_NAME, AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, AWS_SES_REGION_NAME, AWS_SES_REGION_ENDPOINT and SENTRY_DSN mandatory (a KeyError at startup if any is missing), switches email to Amazon SES, and hardcodes SESSION_COOKIE_DOMAIN = ".bottlecrm.io", a domain specific to this project's own SaaS hosting. See Production deployment before setting it.

Database role

The single highest-leverage setting in this list: DBUSER must not be a PostgreSQL superuser, or every Row-Level Security policy in the schema is bypassed silently, with no error and no log line. The default (DBUSER unset, falling back to postgres) is a superuser on most installations. Create a dedicated role and confirm it isn't one:

cd backend
uv run python manage.py manage_rls --verify-user

Full detail: how to create the role, how RLS policies are applied, and how to prove isolation holds for real data rather than trusting that it does, is in PostgreSQL and RLS.

Secrets

SECRET_KEY does more than Django's usual signing duties here: SIMPLE_JWT["SIGNING_KEY"] in crm/settings.py is set to SECRET_KEY directly, so it is also the key every access and refresh token is signed and verified with. There is no separate JWT signing-key setting anywhere in this codebase, rotating SECRET_KEY invalidates every outstanding access and refresh token immediately, which is worth knowing before you rotate it as a routine maintenance step rather than in response to an actual compromise.

crm/settings.py itself guards against the worst case of forgetting to set it: if SECRET_KEY is empty or still starts with "django-insecure" and ENV_TYPE is anything other than dev, the module raises ValueError("SECRET_KEY must be set to a secure value in non-dev environments") at import time, a hard startup failure rather than a silent insecure default. That guard only fires when ENV_TYPE is explicitly non-dev, so set a real, unique SECRET_KEY regardless of whether you ever set ENV_TYPE=prod.

Token surfaces

Three different credential types exist, with different blast radii if one leaks:

  • Personal access tokens (bcrm_pat_..., managed at profile/tokens/) are scoped to the one profile that created them. resolve_valid_pat() in backend/common/pat_auth.py checks pat.profile.is_active (and pat.org.is_active) on every use, so deactivating a profile cuts off its personal access tokens immediately. There's no separate token-revocation step required when offboarding someone.
  • Admin oversight of tokens is separate from the self-service surface above: org/tokens/ (OrgAccessTokenListView/OrgAccessTokenDetailView) is admin-only and org-wide. An admin can see and revoke any token in their org, including one belonging to a colleague who has since been deactivated. It's deliberately a distinct endpoint from profile/tokens/ rather than a widened version of it, so the self-service guard never has to be loosened to support oversight.
  • The org API key (GET/POST /api/org/api-key/, OrgApiKeyView, admin-only) is the bluntest of the three: common/middleware/get_company.py's _process_api_key_auth resolves a request bearing this key to the org's first active ADMIN profile. One key per tenant, never expiring, so it cannot be revoked per integration. It is read-only and barred from the credential endpoints (common/scopes.py), which stops a leaked key deleting records, escalating a role, or minting a personal access token owned by the admin whose identity it borrowed. It still reads every record in the org. It's kept out of every nested API representation and served only by this endpoint. Set DJANGO_ORG_API_KEY_AUTH=false once every integration uses a personal access token, which refuses the key as an authentication method entirely. If it's ever exposed: committed to a repo, logged, shared over an insecure channel, rotate it with POST /api/org/api-key/ from a signed-in session; the response is explicit that "the previous key is no longer valid" immediately on rotation.
  • No token can manage a credential. /api/profile/tokens/, /api/org/tokens/ and /api/org/api-key/ are refused for any personal access token and for the org API key, whatever their scopes. Both chains would otherwise defeat revocation: a token that mints tokens leaves children behind when you revoke it, and a token that reads the org API key upgrades itself into a credential that outlives its own revocation. Credential management requires an interactive sign-in.

What to monitor

SecurityAuditLog (backend/common/audit_log.py, table security_audit_log) is an RLS-protected, per-org table of security-relevant events, with a matching security.audit Python logger that also writes to security_audit.log (configured in crm/settings.py's LOGGING dict). It's worth watching, with one caveat: of the thirteen event types the model defines (LOGIN_SUCCESS, LOGIN_FAILURE, LOGOUT, ORG_SWITCH, TOKEN_REFRESH, TOKEN_REVOKED, PERMISSION_DENIED, CROSS_ORG_ATTEMPT, API_KEY_USED, API_KEY_INVALID, MEMBERSHIP_REVOKED, SUSPICIOUS_ACTIVITY, SAMPLE_DATA_CLEARED), only LOGIN_SUCCESS, ORG_SWITCH, TOKEN_REFRESH, TOKEN_REVOKED, PERMISSION_DENIED (from one call site) and SAMPLE_DATA_CLEARED are actually logged anywhere in the application code as of this writing. LOGIN_FAILURE, LOGOUT, CROSS_ORG_ATTEMPT, API_KEY_USED, API_KEY_INVALID, MEMBERSHIP_REVOKED and SUSPICIOUS_ACTIVITY are defined on the model and have corresponding methods on the AuditLogger helper, but nothing in the codebase currently calls them. Don't rely on this table to surface a brute-forced login or a bad API key attempt today; it doesn't yet.