Security Guide
Clientra CRM is built with security-first principles. This guide documents all security mechanisms in the application and provides best practices for keeping your installation secure.
- config/ directory must be outside the web root
- Delete /install/ directory after setup
- Never regenerate app_key after data is in the database
- Never commit config/secrets.php to version control
- Always serve over HTTPS in production
Authentication & Sessions
Clientra CRM uses PHP session-based authentication. There is no JWT-based authentication for the main web interface (JWT is only used in the API/Webhooks addon).
Session Cookie Settings
// Cookie name session_name('clientra_session'); // Security flags session_set_cookie_params([ 'lifetime' => 0, // Session cookie (expires on browser close) 'path' => '/', 'secure' => true, // HTTPS only 'httponly' => true, // Not accessible via JavaScript 'samesite' => 'Lax' // CSRF protection on cross-site requests ]);
| Flag | Value | Purpose |
|---|---|---|
HttpOnly | true | Prevents JavaScript from accessing the session cookie, mitigating XSS session theft |
SameSite | Lax | Prevents the cookie from being sent in cross-site requests initiated by third-party sites |
Secure | true | Cookie is only transmitted over HTTPS connections |
name | clientra_session | Non-default name makes the CRM less identifiable to scanners |
Session Validation
Every API request validates the session before processing:
// api.php — All endpoints go through this check if (!isset($_SESSION['user_id']) || !$_SESSION['user_id']) { http_response_code(401); echo json_encode([ 'success' => false, 'message' => 'Unauthorized' ]); exit(); }
CSRF Protection
Cross-Site Request Forgery (CSRF) protection is implemented using synchronizer tokens. A CSRF token is generated per session and must be included in all state-changing requests.
How It Works
- On login, a random 256-bit token is generated and stored in
$_SESSION['csrf_token'] - The token is returned in API responses for auth endpoints
- The frontend ApiClient stores the token and includes it in subsequent POST/PUT requests
- The backend validates the token on every state-changing request
// Generating a CSRF token if (!isset($_SESSION['csrf_token'])) { $_SESSION['csrf_token'] = bin2hex(random_bytes(32)); } // Validating on POST requests if ($_SERVER['REQUEST_METHOD'] === 'POST') { $token = $data['csrf_token'] ?? $_SERVER['HTTP_X_CSRF_TOKEN'] ?? ''; if (!hash_equals($_SESSION['csrf_token'], $token)) { http_response_code(403); echo json_encode(['success' => false, 'message' => 'CSRF token invalid']); exit(); } }
ApiClient automatically includes the CSRF token. If you make direct fetch() calls without using api.call(), you must manually include the token in the request body or as the X-CSRF-Token header.
Role-Based Access Control (RBAC)
Clientra CRM implements a multi-layer RBAC system that controls access at the role, user, module, and feature flag levels.
Role Hierarchy
| Role | Level | Access |
|---|---|---|
| admin | 1 (highest) | Full access to all features including Settings, User Management, Data Wipe, and all admin pages |
| agent | 2 | Standard access to sales and operational features. Cannot access Settings, Users, or Permissions. |
| viewer | 3 (lowest) | Read-only access to assigned modules. Cannot create, edit, or delete records. |
RBAC Layers
Default permissions for each role stored in role_permissions table. Defines which modules each role can access and what actions (read, create, update, delete) are allowed.
Individual users can have permissions that differ from their role defaults. Stored in user_permissions table. These always take precedence over role permissions.
Control which sidebar modules each user can see via user_module_visibility. Hides navigation items without blocking API access (for UI-level hiding only).
Edition-specific features (UAE DLD fees, etc.) and experimental features can be toggled globally or per-user via crm_features and user_feature_overrides.
Checking Permissions in PHP
// RBACService — checking permissions $rbac = new RBACService($pdo); // Check if current user can perform action on module if (!$rbac->can($_SESSION['user_id'], 'leads', 'delete')) { http_response_code(403); echo json_encode(['success' => false, 'message' => 'Permission denied']); exit(); } // Check if user has admin role if (!$rbac->isAdmin($_SESSION['user_id'])) { // Restrict to admin-only action }
Input Validation & SQL Injection Prevention
Validator Service
All user input goes through the Validator service class before processing:
$validator = new Validator($data); $validator->required(['name', 'phone']) ->email('email') ->maxLength('name', 150) ->numeric('price') ->in('status', ['New', 'Contacted', 'Won']); if (!$validator->passes()) { echo json_encode([ 'success' => false, 'message' => 'Validation failed', 'errors' => $validator->errors() ]); exit(); }
Prepared Statements
All database queries use PDO prepared statements — never string concatenation:
// CORRECT — Prepared statement $stmt = $pdo->prepare( "SELECT * FROM leads WHERE id = ? AND assigned_to = ?" ); $stmt->execute([$leadId, $_SESSION['user_id']]); // WRONG — Never do this (SQL injection risk) $pdo->query("SELECT * FROM leads WHERE id = $leadId"); // VULNERABLE!
XSS Prevention
| Context | Method |
|---|---|
| HTML output (PHP) | htmlspecialchars($val, ENT_QUOTES, 'UTF-8') |
| innerHTML in QuickViewModal | QuickViewModal.esc(val) — always use this |
| JSON output | Use json_encode() — automatically escapes HTML |
| JavaScript strings | Avoid injecting user data into innerHTML without escaping |
innerHTML inside QuickViewModal MUST be escaped using QuickViewModal.esc(value). This is a critical requirement — failing to escape leads to stored XSS vulnerabilities.
File Security
Configuration File Protection
| Path | Protection Method |
|---|---|
config/db.php | Outside web root — not accessible via HTTP |
config/secrets.php | Outside web root — not accessible via HTTP |
config/sessions/ | Outside web root, chmod 700 |
public/.htaccess | Blocks direct access to PHP files outside index.php and api.php |
Upload Validation
All uploaded files go through validation before being stored:
// Allowed MIME types — always validate, never trust file extension alone $allowedMimes = [ 'image/jpeg', 'image/png', 'image/gif', 'image/webp', 'application/pdf', 'application/msword', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', 'application/vnd.ms-excel', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' ]; // Validate actual MIME type using finfo $finfo = new finfo(FILEINFO_MIME_TYPE); $mime = $finfo->file($_FILES['file']['tmp_name']); if (!in_array($mime, $allowedMimes)) { throw new Exception('Invalid file type'); } // Store with random filename — never use original filename $filename = bin2hex(random_bytes(16)) . '.' . $ext;
Encryption
App Key
The app_key in config/secrets.php is a 256-bit (32-byte) key used for symmetric encryption of sensitive data stored in the database.
app_key using AES-256-CBC encryption. If you regenerate or change the key, all encrypted email passwords become unreadable and all email accounts will stop working. There is no migration path — you would need to re-enter all email passwords.
// How email passwords are encrypted (simplified) function encryptPassword($password, $key) { $iv = openssl_random_pseudo_bytes(16); $encrypted = openssl_encrypt($password, 'AES-256-CBC', $key, 0, $iv); return base64_encode($iv . $encrypted); }
API Security
Rate Limiting
API requests are rate-limited to prevent brute force and DoS attacks:
| Endpoint Type | Limit | Window |
|---|---|---|
| Login endpoint | 10 attempts | per 15 minutes per IP |
| General API | 120 requests | per minute per session |
| File uploads | 20 files | per minute per session |
Security Best Practices
Server Configuration
- Always use HTTPS — never HTTP in production
- Keep PHP, MySQL, and OS updated
- Disable PHP error display in production (
display_errors=Off) - Enable PHP error logging to a private file
- Use a web application firewall (WAF)
- Set
expose_php = Offin php.ini
Application Security
- Delete
/install/immediately after setup - Use strong, unique passwords for admin accounts
- Never share admin credentials between users — create separate accounts
- Regularly review the Audit Log for suspicious activity
- Keep regular database backups (Settings → Backup)
- Use the Trash module to review deleted records
Hardening .htaccess
The included .htaccess applies these protections:
# Disable directory listing Options -Indexes # Prevent access to hidden files (.env, .git, etc.) <FilesMatch "^\."> Require all denied </FilesMatch> # Only allow GET/POST (no DELETE, PATCH, OPTIONS from browser) <LimitExcept GET POST> Require all denied </LimitExcept> # Security headers Header always set X-Content-Type-Options nosniff Header always set X-Frame-Options SAMEORIGIN Header always set X-XSS-Protection "1; mode=block" Header always set Referrer-Policy strict-origin-when-cross-origin
Security Audit Checklist
| Item | Status |
|---|---|
/install/ directory deleted | Required |
| HTTPS enabled with valid certificate | Required |
config/ outside web root | Required |
config/secrets.php in .gitignore | Required |
| Admin password changed from default | Required |
PHP display_errors = Off in production | Recommended |
| Regular database backups configured | Recommended |
| Activity log reviewed periodically | Recommended |
| Session save path has correct permissions (700) | Recommended |