Skip to content

Total CMS Security Guide

This guide provides essential security recommendations for protecting your Total CMS installation and data.

The tcms-data folder contains all your CMS content, including potentially sensitive information (API keys, user data, collection data). It’s crucial to protect this directory from unauthorized web access.

Automatic Apache Protection: Total CMS automatically creates a .htaccess file in the tcms-data folder to deny direct web access when using Apache. If you’re using Nginx or another web server, you must configure protection manually (see below).

The most secure approach is to relocate the tcms-data folder outside your web server’s document root:

  1. Move the folder to a location outside your public web directory

    Terminal window
    # Example: Move from /var/www/html/tcms-data to /var/www/tcms-data
    mv /var/www/html/tcms-data /var/www/tcms-data
  2. Update the configuration in the Admin Dashboard:

    • Navigate to Admin → Settings
    • Update the “Data Directory” field to the new location
    • Save the settings
  3. Ensure proper permissions:

    Terminal window
    chmod -R 755 /var/www/tcms-data
    chown -R www-data:www-data /var/www/tcms-data # Adjust user/group as needed

Alternative: Restrict Access Within Document Root

Section titled “Alternative: Restrict Access Within Document Root”

If you must keep tcms-data within the document root, configure your web server to block access.

Automatic Protection: Total CMS automatically creates a .htaccess file inside the tcms-data folder when it’s first initialized. This file denies all direct web access to the folder contents.

The auto-generated .htaccess file contains:

# Deny direct access to all files and folders in tcms-data
# This protects sensitive data including API keys, collections, and user data
<IfModule mod_authz_core.c>
Require all denied
</IfModule>
<IfModule !mod_authz_core.c>
Order deny,allow
Deny from all
</IfModule>

Alternative Approach: You can also add this to your root .htaccess file:

# Define 404 page
ErrorDocument 404 /404/
# Block access to tcms-data directory
RedirectMatch 404 ^/tcms-data/

Required for Nginx Users: Unlike Apache, Nginx does not process .htaccess files. You must add this protection to your server block configuration manually.

Add this to your Nginx server block configuration:

# Block access to tcms-data directory
location ~ ^/tcms-data/ {
deny all;
return 404;
}
# More comprehensive blocking (optional)
location ~ ^/tcms-data/.*\.(json|md|txt|log)$ {
deny all;
return 404;
}

Required for Caddy Users: Caddy does not process .htaccess files. You must add this protection to your Caddyfile manually.

Add this to your Caddyfile:

# Block access to tcms-data directory
@blocked path /tcms-data/*
respond @blocked 404
# Or use a more explicit approach
handle /tcms-data/* {
respond 404
}
  • Enforce minimum password length (8+ characters recommended)
  • Require a mix of uppercase, lowercase, numbers, and special characters
  • Implement password history to prevent reuse
  • Consider implementing two-factor authentication (2FA)

Total CMS implements several session security measures:

  • Session regeneration on login
  • Secure session cookies (when using HTTPS)
  • CSRF protection on all state-changing operations

Total CMS keeps session files in tcms-data/.system/sessions, not PHP’s default location. PHP’s default is a single directory shared by every site on the server, and its cleanup runs on whichever site happens to trigger it, using that site’s session lifetime — often 24 minutes. A neighbouring site’s cleanup would delete your session files no matter what Total CMS configured, which is a common cause of “it keeps logging me out” on shared hosting.

If that directory cannot be created or written to, Total CMS falls back to PHP’s default rather than failing: a session directory it cannot write to would mean nobody can log in at all.

To put sessions somewhere else — a data directory on network storage is the usual reason — set the path explicitly in tcms.php:

$settings['session']['save_path'] = '/var/lib/tcms-sessions';

By default a login applies to one hostname. The session cookie is set for the exact host, so signing in at admin.example.com does not sign you in at shop.example.com, even when both are Total CMS installs.

Two things have to line up to share a login across subdomains of the same domain:

  1. The installs must share their session storage, which happens automatically when they share one tcms-data folder, or can be set explicitly with session.save_path on both.

  2. The cookie must be widened to the parent domain on both installs:

    $settings['session']['cookie_domain'] = '.example.com';

“Keep me signed in” follows the same rule — its cookie inherits the session cookie’s domain — so a persistent login is shared too.

If the installs share one tcms-data folder, also set:

$settings['cache']['domainScoped'] = false;

so they share one cache namespace instead of keeping separate ones.

Only do this if you control every subdomain. Widening the cookie means every host under example.com receives it on every request — including any subdomain running software you did not write, and any subdomain someone else controls. HttpOnly stops JavaScript from reading the cookie; it does nothing about the server on the other end, which sees it in the request headers. A forgotten test.example.com or a customer-controlled blog.example.com is enough to turn this into a way to collect admin sessions.

This works for subdomains of one domain only. example.com and example.net cannot share a cookie, and no configuration changes that — sharing a login across genuinely different domains needs a redirect-based handoff, which Total CMS does not currently provide.

  • Limit login attempts to prevent brute force attacks
  • Implement account lockout after failed attempts
  • Log authentication events for monitoring
  • Regularly review user accounts and remove inactive ones

Total CMS sanitizes HTML content by default to prevent XSS attacks. This is especially important for user-generated content.

⚠️ Warning: Disabling HTML Sanitization

While it’s possible to disable HTML sanitization for certain fields, this significantly increases security risks:

{
"htmlclean" : false
}

Risks of disabling sanitization:

  • Cross-Site Scripting (XSS): Malicious scripts can steal user sessions, redirect users, or modify page content
  • HTML Injection: Attackers can inject malicious HTML that breaks page layouts or functionality
  • Data Theft: Scripts can access and transmit sensitive data to external servers
  • Phishing: Malicious content can mimic legitimate forms to steal credentials

If you must allow raw HTML:

  1. Only enable it for trusted administrator accounts
  2. Never allow it for public-facing content
  3. Implement Content Security Policy headers
  4. Regularly audit content for suspicious code
  5. Consider using a more restrictive whitelist approach

SVG files can contain JavaScript and other potentially dangerous content. Total CMS automatically sanitizes SVG uploads to remove:

  • <script> tags
  • Event handlers (onclick, onload, etc.)
  • External references
  • JavaScript in URLs
{
"svgclean" : false
}

Twig autoescaping is OFF globally in Total CMS. This is deliberate: content fields hold author-authored HTML and Markdown that must render as markup, not as escaped text ({{ post.content }}, {{ body | markdown }}, etc.). With autoescape on, every content field would render as visible HTML entities.

The trade-off is that Twig does not escape output for you. The write-path HTML sanitizer (above) scrubs rich-text content saved through the admin, which covers the primary content vector — but it does not protect everything a template can print:

  • Output rendered by an extension’s own templates (especially anything anonymous/agent-supplied)
  • Free-form page.data.* values on Site Builder pages
  • Query-string / request values echoed into a page
  • Fields where htmlclean has been disabled

For any value that is not trusted author content, escape it explicitly:

{# Escape untrusted text #}
{{ user_supplied | e }}
{# Escape inside an HTML attribute #}
<a title="{{ user_supplied | e('html_attr') }}"></a>
{# Or turn autoescaping on for a whole block #}
{% autoescape 'html' %}
{{ user_supplied }}
{% endautoescape %}

This is the responsibility of template and extension authors: a handler that prints {{ input }} has no automatic XSS protection. When in doubt, escape.

  1. File Type Restrictions: Only allow necessary file types
  2. File Size Limits: Set appropriate maximum file sizes
  3. Filename Sanitization: Special characters are automatically removed
  4. MIME Type Verification: Files are checked beyond just extensions
  5. Upload Directory: Ensure upload directories are not executable

Configure upload restrictions in your collection schemas:

{
"rules" : {
"size" : {"min":0,"max":300},
"filetype" : ["image/jpeg", "image/png", "application/pdf"],
"filename" : ["image.jpg"]
}
}

Rate limiting, webhook throttling and the OAuth registration log all need to know which address a request came from. When Total CMS sits behind a reverse proxy or a CDN, the connecting address is the proxy’s, and the real visitor is in a header the proxy adds — CF-Connecting-IP for Cloudflare, or X-Forwarded-For.

The catch is that a visitor can send those headers too, and nothing about a header says who set it. If Total CMS believed them unconditionally, anyone could send a different value on each request, land in a fresh rate-limit bucket every time, and never be limited at all.

trustProxyHeaders in tcms.php decides when those headers are believed:

$settings['trustProxyHeaders'] = 'auto'; // 'auto' | 'always' | 'never'
ValueBehaviour
auto (default)Believe the headers only when the request arrives from a private or loopback address — a reverse proxy on the same machine or LAN.
alwaysBelieve the headers on every request.
neverIgnore the headers; always use the connecting address.

auto is right for nginx or Apache in front of PHP, which is the usual setup, and it needs no configuration. It is also safe on a server exposed directly to the internet, because a request arriving from a public address is not coming from your proxy.

Nothing to configure. Total CMS ships Cloudflare’s published edge ranges and recognises requests coming from them, so auto believes CF-Connecting-IP when it genuinely came from Cloudflare and ignores it when someone else sends it. The ranges are refreshed from cloudflare.com/ips when each release is built.

Settings → Server Info shows what your install is doing, including when the bundled ranges were last verified. If Cloudflare ever adds a range that your version predates, that panel says so directly rather than leaving you to work it out from rate limits misbehaving — Cloudflare stamps every forwarded request with a CF-Ray header, so a request carrying one from an address outside the known ranges is a reliable sign the list needs updating. Updating Total CMS fixes it.

Whatever the setting, restricting your origin so it can only be reached through Cloudflare remains worth doing — see protecting your origin server.

For a proxy that connects from a public address and is not Cloudflare — Fastly, Akamai, a load balancer on another host — auto has no way to tell it from a visitor, so it will ignore its headers. Two options:

  1. Resolve the address in your web server. Apache’s mod_remoteip and nginx’s real_ip module rewrite the connecting address from a header, but only for requests that genuinely came from the proxy’s IP ranges. REMOTE_ADDR then holds the real visitor before PHP sees it, auto is correct, and your access logs get the right address too.

    set_real_ip_from 10.0.0.0/8; # your proxy's addresses
    real_ip_header X-Forwarded-For;
  2. Set trustProxyHeaders to always. Simpler, and appropriate when you cannot configure the web server. It believes the headers no matter who sent them, so it is only safe once your origin cannot be reached directly. If someone can reach your origin by IP, this setting is an open door.

  1. Obtain an SSL certificate (Let’s Encrypt provides free certificates)
  2. Configure your web server to use HTTPS
  3. Implement HTTP to HTTPS redirects
  4. Enable HSTS (HTTP Strict Transport Security)
# Redirect HTTP to HTTPS
RewriteEngine On
RewriteCond %{HTTPS} off
RewriteRule ^(.*)$ https://%{HTTP_HOST}/$1 [R=301,L]
# Enable HSTS
Header always set Strict-Transport-Security "max-age=31536000; includeSubDomains"
# Redirect HTTP to HTTPS
server {
listen 80;
server_name example.com;
return 301 https://$server_name$request_uri;
}
# HTTPS server block
server {
listen 443 ssl http2;
server_name example.com;
# Enable HSTS
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
}
# Caddy automatically handles HTTPS, but you can be explicit
example.com {
header Strict-Transport-Security "max-age=31536000; includeSubDomains"
}

Total CMS sets security headers on every admin, login, and setup page automatically — no server configuration needed:

HeaderValue
Content-Security-Policyframe-ancestors 'self' (clickjacking protection)
X-Frame-OptionsSAMEORIGIN
X-Content-Type-Optionsnosniff
Referrer-Policystrict-origin-when-cross-origin

Your public pages are a different story: whether they may be framed, what referrer they send, and any Content Security Policy are decisions for your site, not the CMS. Set those at the server level if you want them:

# Security headers
Header set X-Content-Type-Options "nosniff"
Header set X-Frame-Options "SAMEORIGIN"
Header set X-XSS-Protection "1; mode=block"
Header set Referrer-Policy "strict-origin-when-cross-origin"
Header set Permissions-Policy "geolocation=(), microphone=(), camera=()"
# Security headers
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-XSS-Protection "1; mode=block" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "geolocation=(), microphone=(), camera=()" always;
header {
X-Content-Type-Options nosniff
X-Frame-Options SAMEORIGIN
X-XSS-Protection "1; mode=block"
Referrer-Policy strict-origin-when-cross-origin
Permissions-Policy "geolocation=(), microphone=(), camera=()"
}

Total CMS can report application errors to the developer so bugs get discovered and fixed quickly — often before you notice anything went wrong, and without you having to file a report. You choose during the setup wizard, and you can change your mind at any time in Settings → General (“Share Application Errors with Developer”).

What a report contains: the exception, its stack trace, the request URL and method, and the Total CMS version.

What is never sent: the reporting client is explicitly configured to never attach request bodies, cookies, session data, IP addresses, or user information. Your content, credentials, and CMS data stay on your server.

Air-gapped deployments: installing an offline license disables error monitoring automatically at the configuration layer — a network-isolated install never attempts an outbound call, regardless of the settings toggle.

When disabled, errors are still logged locally on your server — see the log files in your data directory.

  1. Total CMS: Regularly check for and install updates
  2. PHP: Keep PHP version current (8.2+ recommended)
  3. Web Server: Update Apache/Nginx/Caddy regularly
  4. Dependencies: Run composer update regularly (test first!)
  1. Access Logs: Regularly review web server access logs
  2. Error Logs: Monitor PHP and application error logs
  3. Failed Logins: Track and investigate failed login attempts
  4. File Changes: Monitor for unexpected file modifications
  1. Regular Backups: Automate daily backups of tcms-data
  2. Offsite Storage: Store backups in a separate location
  3. Test Restores: Regularly verify backup integrity
  4. Version Control: Consider using Git for configuration files
  • tcms-data folder is protected or moved outside document root
  • HTTPS is enabled with valid certificate
  • Strong passwords are enforced
  • File upload restrictions are configured
  • Security headers are properly set
  • Regular backups are configured
  • Software is kept up to date
  • Access logs are monitored
  • HTML sanitization is enabled (unless explicitly required otherwise)

If you suspect a security breach:

  1. Immediate Actions:

    • Change all passwords
    • Review access logs
    • Check for unauthorized file changes
    • Clear the cache: /api/emergency/cache/clear
  2. Investigation:

    • Review user accounts for unauthorized access
    • Check for suspicious files in upload directories
    • Examine database/JSON files for injected content
  3. Recovery:

    • Restore from clean backup if necessary
    • Update all software
    • Implement additional security measures
    • Document the incident for future reference

Remember: Security is an ongoing process, not a one-time setup. Regular reviews and updates are essential for maintaining a secure Total CMS installation.