Security Best Practices
Security Best Practices
Security Overview
Flatboard 5 includes multiple security layers:
- CSRF Protection - Prevents cross-site request forgery
- Rate Limiting - Prevents abuse and brute force attacks
- Input Validation - Sanitizes all user input
- Permission System - Granular access control
- Secure Sessions - Encrypted session management with fingerprinting and group-change invalidation
- File Upload Security - Validates actual file content (MIME), not the client-supplied type
- Content Security Policy - Strict CSP with per-request nonces (no
unsafe-inline, nounsafe-eval) - Trusted Proxies - Only honour
X-Forwarded-For/X-Real-IP/CF-Connecting-IPfrom configured proxy IPs - Email Validation - Optional MX record check at registration to reject fake domains
CSRF Protection
What is CSRF?
Cross-Site Request Forgery (CSRF) attacks trick users into performing actions they didn't intend.
How Flatboard 5 Protects
- CSRF Tokens - All forms include CSRF tokens
- Token Validation - Tokens are validated on submission
- Automatic Handling - Protection is automatic, no configuration needed
- State-changing API routes (since 5.6.9) -
POST/PUT/DELETEon/api/*(discussions, posts, reactions, notifications, presence, typing) require a valid CSRF token. The sharedwindow.apiRequest()helper injects it as anX-CSRF-Tokenheader automatically, so existing frontend callers keep working. The HMAC-signed/api/webhooksand the idempotent/api/markdown/parseare intentionally exempt.
Best Practices
- Never Disable CSRF - Always keep CSRF protection enabled
- Use HTTPS - Encrypt connections to protect tokens
- Validate Tokens - Always validate in custom code
Rate Limiting
Purpose
Rate limiting prevents:
- Brute force attacks
- Spam and abuse
- Resource exhaustion
- DDoS attacks
Configuration
Access: Admin Panel > Settings > Security
Configure limits for:
- Login Attempts - Max attempts per IP (default: 5)
- Registration Attempts - Max registrations per IP (default: 3)
- Password Reset - Max requests per hour (default: 3)
- Post Creation - Max posts per time period
- API Requests - Max API calls per key
Rate Limit Headers
Flatboard 5 sends rate limit headers:
X-RateLimit-Limit: 100
X-RateLimit-Remaining: 95
X-RateLimit-Reset: 1640995200Input Validation
Sanitization
All user input is sanitized:
- HTML Sanitization - Removes dangerous HTML
- SQL Injection Prevention - Parameterized queries (SQLite)
- XSS Prevention - Escapes output
- Path Traversal Prevention - Validates file paths
Validation Rules
- Email - Valid email format
- URL - Valid URL format
- Username - Alphanumeric and allowed characters
- Password - Meets strength requirements
- File Uploads - Valid file types and sizes
Permission System
Principle of Least Privilege
Grant minimum necessary permissions:
- Default Deny - Deny by default
- Explicit Allow - Explicitly grant permissions
- Regular Review - Review permissions regularly
- Document Changes - Keep permission change log
Permission Levels
- Guest - View only
- Member - Post and interact
- Moderator - Moderate content
- Admin - Full system access
Best Practices
- Separate Roles - Use different accounts for admin/moderator
- Limit Admin Count - Keep admin accounts minimal
- Review Regularly - Audit permissions periodically
- Use Groups - Manage via groups, not individual users
Password Security
Requirements (strengthened in 5.7.1)
Flatboard enforces a strong password policy on both the installer's admin account and member registration:
- Minimum length - 10 characters (raised from 8 in 5.7.1). Controlled by
security.password_min_lengthinconfig.json, which the installer now writes as10. - Complexity - the password must contain at least one lowercase letter, one uppercase letter, one digit and one special character. This is mandatory, not a recommendation.
The rule is enforced server-side (the validator used by RegisterController and the installer) and mirrored client-side through the input pattern attribute, so a weak password is rejected before the form is even submitted.
A live password strength meter â a coloured bar plus a requirements checklist that ticks off as you type â is shown on the installer's admin-account step and on the member registration form. It is a shared, dependency-free component (themes/assets/js/shared/password-strength.{css,js}) that auto-initialises on any <input data-password-strength> and reads its labels from data-* attributes, so it stays translatable per context.
Password Hashing
Flatboard 5 uses:
- bcrypt - Secure password hashing
- Salt - Unique salt per password
- Cost Factor - Configurable bcrypt cost
Two-Factor Authentication (2FA)
Enable 2FA for additional security:
- Enable in Settings - Admin Panel > Settings > Security
- User Setup - Users configure in profile
- Backup Codes - Generate backup codes
- Recovery - Account recovery options
Password Reset Token TTL (since 5.3.7)
The expiry of the link sent by Forgot password is controlled by the security.password_reset_token_ttl config key (in seconds). Default: 3600 (1 hour) â significantly shorter than the previous hardcoded 24 hours, which was unnecessarily long for a one-shot reset link.
Adjust it in config.json:
{
"security": {
"password_reset_token_ttl": 1800
}
}A shorter TTL reduces the window in which a compromised inbox can be used to take over an account. Anything between 900 (15 min) and 7200 (2 h) is reasonable.
Authentication & Password Reset Hardening (since 5.6.8 â 5.6.9)
A hardening sweep across the login and password-reset flows:
- Logout is POST-only â the
GET /logoutroute was removed. AGETlogout could be triggered cross-site (<img src="âŠ/logout">) to silently sign visitors out. All themes already render a POST form with a CSRF token, so there is no UI change. - Login enumeration closed â when the submitted identifier matches no user, the controller still runs
password_verify()against a fixed dummy hash, equalising response time so an attacker can't tell valid accounts from invalid ones by timing. - Per-account login throttle â a
login_accountbucket (10 attempts / 15 min, keyed by the lowercased identifier) sits on top of the per-IP throttle, so rotating IPs no longer help brute-force a single account. - Reset tokens validated and hashed at rest â submitted tokens must match
^[a-f0-9]{64}$before any storage lookup (blocking path traversal in the JSON backend), and only thesha256hash of a token is persisted. A storage leak no longer exposes usable reset tokens. - Session invalidation after reset â changing
password_hashbumps the user'spermissions_version; any session still open under the old password is destroyed on its next protected request. - "Your password was changed" email â the account owner is notified immediately after a successful reset, with a link to re-secure the account if they didn't initiate it.
- No PII in debug logs â the login controller no longer echoes the submitted identifier or matched username to the debug log.
Installer Hardening (since 5.7.1)
The installer (install.php) carries the admin-credentials form, so it received its own hardening pass:
- Security headers â every setup page sends
X-Frame-Options: DENY,X-Content-Type-Options: nosniff,Referrer-Policy: no-referrerand a restrictive CSP (frame-ancestors 'none',form-action 'self',base-uri 'self'), so the form can't be framed or have its action hijacked. - Self-deletes after install â once
.install.lockis written, the installer attempts tounlink()itself. The.lock403 guard remains the primary protection if the unlink fails (read-only filesystem); the success screen says whether the file was removed or must be deleted by hand. - Whitelisted inputs â the submitted timezone is validated against
timezone_identifiers_list()and the default language against the locales present on disk, each falling back to a safe default (Europe/Paris/fr) instead of being written verbatim intoconfig.json. - Re-run guard fixed â a path-sanitisation bug previously let the installer run again over a live install. A valid non-empty
config.jsonnow counts as "already installed" and the installer shows a proper "Flatboard is already installed" page (HTTP 403) instead of silently redirecting.
Email Verification & MX DNS Validation (since 5.4.2)
A new optional check, email_mx_validation (enabled by default), verifies at registration time that the email domain has at least one MX record. Addresses whose domain has no mail server (e.g. demo@demo.com, test@nope.invalid) are rejected before any user row is written.
Toggle in Admin â Settings â Email. When disabled, MX validation is skipped â useful in air-gapped or test environments without external DNS.
The check is purely DNS-based (uses checkdnsrr($domain, 'MX')), runs only on registration, and adds at most a few hundred milliseconds. It does not block updates of an existing user's email if MX is temporarily unreachable.
File Upload Security
Restrictions
- File Types - Only allowed MIME types
- File Size - Maximum file size limits
- File Validation - Validates actual file content
- Virus Scanning - Optional virus scanning
Configuration
// Allowed file types
$allowedTypes = ['image/jpeg', 'image/png', 'application/pdf'];
// Maximum file size (2MB)
$maxSize = 2 * 1024 * 1024;
// Upload directory
$uploadDir = 'uploads/attachments/';Best Practices
- Whitelist Types - Only allow necessary file types
- Scan Uploads - Scan for malware
- Isolate Uploads - Store outside web root when possible
- Validate Content - Check actual file content, not just extension
Server-side MIME Validation (hardened in 5.3.1)
UploadController::upload() previously trusted $_FILES['type'], which is supplied by the browser and can be trivially forged. The check now uses finfo_file() (with mime_content_type() as a fallback), and a second guard ensures the file's declared extension matches the detected MIME type. The avatar, image, and attachment endpoints already validated content via UploadService and were unaffected.
Session Security
Configuration
- Secure Cookies - Use HTTPS-only cookies
- HttpOnly - Prevent JavaScript access
- SameSite - CSRF protection
- Session Timeout - Automatic logout after inactivity
Session Timeouts & Fingerprinting
Flatboard 5 enforces layered session protection via App\Core\Session:
| Constant | Value | Effect |
|---|---|---|
REGENERATE_INTERVAL | 1800 s | Session ID is automatically regenerated every 30 minutes |
IDLE_TIMEOUT | 3600 s | Session expires after 1 hour of inactivity |
MAX_LIFETIME | 86400 s | Absolute session maximum regardless of activity (24 hours) |
REMEMBER_ME_LIFETIME | 2592000 s | Cookie and session lifetime when "Remember Me" is checked (30 days) |
Session fingerprinting â on each request, a fingerprint is computed as SHA-256(User-Agent + Accept-Language). If the fingerprint changes mid-session, the session is immediately invalidated to prevent hijacking. IP validation is also performed by default and can be toggled for CDN/mobile environments:
Session::disableIpValidation(); // e.g., for users behind a CDN or mobile network
Session::enableIpValidation(); // default â re-enable IP bindingGroup Change Invalidates Active Sessions (since 5.3.7)
When an administrator changes a user's group from Admin â Users, every active session of that user is invalidated on its next authenticated request.
Mechanics: User::update() stamps permissions_version = time() on group change; LoginController stores this value in the session at login; AuthMiddleware compares the session value against the database on every protected request. A mismatch triggers Session::destroy() and a forced re-login.
This means demoting an admin or moving a user into the banned group takes effect immediately â there's no "session window" where stale permissions linger.
Remember Me â Absolute 30-Day Ceiling (since 5.3.7)
REMEMBER_ME_LIFETIME (2,592,000 s = 30 days) is enforced as an absolute ceiling on remember-me sessions. Previously, the idle-timeout/max-lifetime checks were bypassed entirely for remember-me sessions, making them effectively immortal. They now ignore only the idle timeout while still respecting the 30-day absolute cap.
IP Address Detection â Trusted Proxies (since 5.3.7)
Session::getClientIp() and Request::getIp() used to trust X-Forwarded-For, X-Real-IP, and CF-Connecting-IP unconditionally â letting any client spoof their IP by setting these headers in the request.
A new IpHelper class reads those headers only when REMOTE_ADDR belongs to the configured security.trusted_proxies list. Default is an empty list (safe by default).
Configure in config.json:
{
"security": {
"trusted_proxies": [
"10.0.0.0/8",
"192.168.0.0/16",
"172.16.0.0/12"
]
}
}Supports individual IPs and CIDR ranges, both IPv4 and IPv6. If you put Flatboard behind nginx/Apache as a reverse proxy, behind Cloudflare, or behind any load balancer that adds X-Forwarded-For, you must populate this list â otherwise every visitor will appear to come from your proxy's IP.
For Cloudflare specifically, populate from Cloudflare's published IP ranges.
Core IP Tracking on Users (since 5.6.6)
Two columns are now stored directly in the user record (both JSON and SQLite backends):
registration_ipâ set once at registration, never updatedlast_ipâ updated on each login when the IP has changed
Only valid, non-fallback IPs are stored (0.0.0.0 is skipped). Both fields are available without any plugin dependency. Existing SQLite databases receive the two columns automatically on next startup via the migration system (SCHEMA_VERSION = 18).
Side effect: the Add ban form in admin/ban now auto-fills the last_ip field when an admin selects a member, and a separate IP ban is created automatically when an admin bans by user ID â see the Admin Panel guide for the full flow.
Advanced Session Methods
Available for plugin developers:
// Get and immediately remove a value (useful for one-time tokens)
$value = Session::pull('key', $default);
// Atomically increment/decrement a session counter
Session::increment('login_attempts', 1);
Session::decrement('credits', 1);
// Set a flash value visible only on the NEXT request
Session::flashNext('success', 'Saved!');
$message = Session::getFlash('success');
// Get only user-scoped session data (excludes internal _security, _remember_me, etc.)
$userData = Session::allUser();
// Session metadata for debugging
$meta = Session::metadata();
// Returns: id, started_at, last_activity, fingerprint, age, idle_time
// Migrate session data to a new ID without destroying the old one
Session::migrate();Best Practices
- Use HTTPS - Always use HTTPS in production
- Regenerate IDs - Regenerate session ID on login
- Short Timeout - Set reasonable session timeout
- Secure Storage - Store sessions securely
Server Hardening
File Permissions
Set correct permissions:
# Directories
chmod 755 app/ themes/ plugins/ languages/
chmod 750 stockage/ uploads/
# Files
chmod 644 *.php
chmod 600 stockage/json/config.jsonDirectory Protection
Protect sensitive directories:
# .htaccess in stockage/
<FilesMatch "\.(json|log)$">
Deny from all
</FilesMatch>PHP Configuration
Secure PHP settings:
; Disable dangerous functions
disable_functions = exec,passthru,shell_exec,system
; Hide PHP version
expose_php = Off
; Error reporting (production)
display_errors = Off
log_errors = OnSecurity Headers
Recommended Headers
Add security headers:
# Apache .htaccess
Header set X-Content-Type-Options "nosniff"
Header set X-Frame-Options "SAMEORIGIN"
Header set X-XSS-Protection "0"
Header set Referrer-Policy "strict-origin-when-cross-origin"
# Nginx configuration
add_header X-Content-Type-Options "nosniff";
add_header X-Frame-Options "SAMEORIGIN";
add_header X-XSS-Protection "0";
add_header Referrer-Policy "strict-origin-when-cross-origin";0 (not 1; mode=block). The reflective-XSS filter that header controlled was removed from Chrome/Firefox/Safari, and its block mode caused false-positive page blanking on IE11. Flatboard 5.3.1+ emits X-XSS-Protection: 0 by default.Content Security Policy with Nonces (since 5.3.7)
Flatboard's response sets a strict CSP without 'unsafe-inline' or 'unsafe-eval' in script-src / script-src-elem. Inline scripts are allowed only when they carry the per-request nonce.
How it works:
- A new
CspNonceclass generates a cryptographically random 16-byte nonce per request (random_bytes(16)) - The nonce is embedded in the
script-srcdirective as'nonce-{nonce}' - An output-buffer callback in
App::run()automatically injectsnonce="{nonce}"into every<script>tag â no view or plugin needs to be modified
upgrade-insecure-requests is also added to the CSP, telling browsers to silently rewrite any http:// sub-resource request to https:// before sending it. This eliminates mixed-content warnings without requiring a server-side redirect.
If a plugin or theme emits inline JavaScript via a path that bypasses the output buffer (extremely rare), the script will be blocked by the browser. The fix is either to register it through the normal asset loaders or to add the nonce attribute manually using CspNonce::get().
Regular Security Maintenance
Checklist
- [ ] Update Regularly - Keep Flatboard 5 updated
- [ ] Update Plugins - Update plugins regularly
- [ ] Review Logs - Check error and access logs
- [ ] Monitor Activity - Watch for suspicious activity
- [ ] Backup Regularly - Maintain regular backups
- [ ] Review Permissions - Audit user permissions
- [ ] Test Security - Perform security audits
Security Audits
Regular security audits:
- Review Users - Check for suspicious accounts
- Check Permissions - Verify permission settings
- Review Logs - Analyze error and access logs
- Test Updates - Test updates in staging
- Monitor Performance - Watch for unusual activity
Incident Response
If Compromised
- Isolate - Take site offline if necessary
- Assess - Determine extent of compromise
- Contain - Prevent further damage
- Remediate - Fix vulnerabilities
- Notify - Inform users if data exposed
- Document - Document incident and response
Prevention
- Regular Backups - Maintain backups
- Monitor Logs - Watch for anomalies
- Update Promptly - Apply security updates
- Limit Access - Minimize admin access
- Use HTTPS - Encrypt all connections
Resources
- Configuration Guide - Security settings
- Users and Groups - Permission management
- Troubleshooting - Security issues
- Backup Guide - Backup and recovery
Last updated: May 31, 2026