Introduction
Most Joomla compromises do not come from exotic zero-days. They come from sites running old core versions, abandoned extensions, weak administrator logins, and servers that execute any PHP file an attacker manages to drop into a writable folder. This guide covers each of those, in the order that gives the most protection for the least effort.
The recommendations here were tested on a clean Joomla 6.1.4 install (the current release, 29 September 2026) running on Ubuntu 24.04, Apache 2.4.58, PHP 8.3 and MariaDB. Each one states what was tested and what the result was; the few items that could not be tested are marked as such. Where a common piece of advice turned out to be weak or wrong in testing, that is called out.
Three findings from testing are worth knowing before you start:
- Joomla's shipped
.htaccessblocks some injection patterns, but on a default install the exact core version is still publicly readable at/administrator/manifests/files/joomla.xml. - A root
.htaccessrule that blocks PHP inimages/can be switched off by an attacker who uploads a one-line.htaccess(RewriteEngine On) into that folder. Server-level configuration closes this gap. - Setting
configuration.phpto444does not stop Joomla from rewriting it when the file is owned by the web server user. Ownership matters more than the mode.
1. Stay on a supported, patched version
Update first: no hardening step compensates for a known, published vulnerability. Joomla 6.1.4 and 5.4.9 fixed seven security issues announced between 10 and 16 September 2026, including an MFA bypass through "remember me" cookies and missing ACL checks on Web Services edit tasks (Joomla Security Centre).
|
Series |
Bugfix support ends |
Security-only support ends |
|---|---|---|
|
Joomla 6.x |
17 October 2028 |
16 October 2029 |
|
Joomla 5.x |
13 October 2026 |
12 October 2027 |
Source: Joomla roadmap. Joomla 5 leaves regular bugfix support on 13 October 2026, so plan the move to 6.x now rather than in 2027.
What to do
- Keep core on the latest release of a supported series. Check from the shell with
php cli/joomla.php core:update:check(tested: it reported "You already have the latest Joomla version 6.1.4"). - Check extensions with
php cli/joomla.php update:extensions:check, or in System → Update → Extensions. Treat any extension without updates for 12+ months as a risk to review. - Leave the update channel on Default (
php cli/joomla.php core:update:channelshows it). Never run Testing or custom channels on production. - Subscribe to the Security Centre feed so you learn about fixes on release day, not from a compromise.
If you manage many sites, a central update platform does the same job at scale. The point is speed: the window between a Joomla security release and automated scanning for it is short.
2. Lock down administrator access
The administrator login is the most attacked URL on any Joomla site. Protect it with two independent layers: Joomla's own multi-factor authentication, and a server-level lock in front of /administrator.
Enforce MFA for privileged groups
Joomla has shipped MFA in core since 4.2, with TOTP authenticator apps, WebAuthn/passkeys, YubiKey and email codes. Making it optional is not enough. Enforce it.
- Go to Users → Manage → Options → Multi-factor Authentication.
- In Enforce MFA for User Groups, select Super Users and Administrator (and Manager if they have backend access).
- Keep Maximum MFA tries low (the default is 10; 5 is a sensible value).
Tested: with enforcement on for Super Users, a correct username and password returned a 307 redirect to com_users&view=methods ("Multi-factor Authentication is mandatory for your user account"). A direct request to the User Manager was redirected to the same page. The account cannot reach anything until a second factor is set up.
If you are still on a version older than 5.4.9 / 6.1.4, disable the System - Remember Me plugin until you update. One of the September 2026 fixes was an MFA bypass through remember-me cookies.
Add a server-level lock in front of /administrator
A second, independent lock means a leaked Joomla password alone is not enough, and it hides the login form from bots. Create administrator/.htaccess:
# Second lock on the administrator area (HTTP Basic auth, HTTPS only)
AuthType Basic
AuthName "Restricted"
AuthUserFile /etc/apache2/.htpasswd-joomla
Require valid-user
Create the password file outside the web root with htpasswd -c /etc/apache2/.htpasswd-joomla <username>.
Tested: no credentials → 401, wrong credentials → 401, correct credentials → 200. The front end (200) and the API were unaffected, the default front end makes no requests to /administrator, and the full Joomla login worked through the extra layer. Rewrite rules from the root .htaccess still applied inside /administrator.
If your team has fixed IPs, Require ip 203.0.113.10 is a stronger alternative to Require valid-user. Behind Cloudflare or another proxy, Apache sees the proxy's IP, so use Basic auth instead.
Accounts and passwords
- Do not use
adminas a username. Give each person their own account; never share a Super User login. - Keep Super User to one or two people. Day-to-day editors belong in Editor, Publisher or Manager.
- Users → Options → Password Options: the default minimum length on 6.1.4 is 12 characters with no other requirements. Raising the length to 14+ does more than requiring symbols.
- Keep Allow User Registration off (the default) unless the site needs it.
3. Global Configuration and core plugins
A handful of settings in System → Global Configuration and the System - HTTP Headers plugin carry most of the weight. All values below were applied and checked on the test site.
|
Setting |
Where |
Recommended |
Tested result |
|---|---|---|---|
|
Force HTTPS |
Global Config → Server |
Entire Site |
HTTP request → |
|
Debug System |
Global Config → System |
No |
With debug on, an error page exposed a call stack. Off: no stack or paths. |
|
Error Reporting |
Global Config → Server |
None (production) |
With Default, None and debug off, no server paths leaked on the error page tested. |
|
Behind Load Balancer |
Global Config → Server |
No, unless a real proxy is in front |
See warning below. |
|
Session Lifetime |
Global Config → System |
15 minutes (default) |
Keep short for admin sessions. |
|
HSTS |
System - HTTP Headers plugin |
On, max-age 31536000 |
|
|
X-Frame-Options, Referrer-Policy, COOP |
System - HTTP Headers plugin |
Defaults (on) |
|
|
Extra headers |
System - HTTP Headers → Additional headers |
|
Sent on site and admin when the client is set to Both. |
Warning on "Behind Load Balancer". This setting tells Joomla to trust the X-Forwarded-For header. In testing, with it switched on and no proxy present, a failed login sent with a forged X-Forwarded-For: 198.51.100.23 was logged under 198.51.100.23, not the real client IP. With it off, the real IP was logged regardless of the header. An attacker can use this to dodge IP-based blocking (see section 7) or get innocent IPs banned. Turn it on only when every request really arrives through your proxy.
Enable HSTS only after HTTPS works everywhere on the domain, including subdomains if you tick include subdomains. Browsers remember it for the full max-age.
A Content-Security-Policy is the strongest header but the most likely to break a site. CSP is off by default in the plugin; when you switch it on, it starts in report-only mode. Leave it there until reports show no legitimate violations, then enforce it.
4. Web server hardening
Start from Joomla's htaccess.txt
Rename htaccess.txt to .htaccess. Without it, Apache serves Joomla with no extra protection at all.
Tested: with the shipped file active, index.php?x=base64_encode(abc) and index.php?q=%3Cscript%3E returned 403, and responses gained X-Content-Type-Options: nosniff. But it does not hide version or distribution files. These were all still readable (200):
/administrator/manifests/files/joomla.xml(contains<version>6.1.4</version>)/README.txt,/LICENSE.txt,/htaccess.txt,/web.config.txt/language/en-GB/langmetadata.xml/cli/joomla.php
Add these rules to the end of .htaccess
## ---- Additional hardening (tested on Joomla 6.1.4 / Apache 2.4.58) ----
# 1. Hide version-revealing and distribution files
<FilesMatch "(?i)^(README|LICENSE|htaccess|web\.config|configuration\.php-dist|robots\.txt\.dist)(\.txt)?$">
Require all denied
</FilesMatch>
<IfModule mod_rewrite.c>
RewriteRule ^administrator/manifests/ - [F,L]
RewriteRule ^(cli|installation)/ - [F,L]
RewriteRule ^(administrator/)?language/.+\.xml$ - [F,L]
</IfModule>
# 2. Never execute scripts from upload, cache, tmp and log folders
<IfModule mod_rewrite.c>
RewriteRule ^(images|media|files|tmp|cache|administrator/cache|administrator/logs)/.*\.(php\d?|phtml|phar|pht|phps|cgi|pl|py|sh|asp|aspx|jsp)$ - [NC,F,L]
</IfModule>
# 3. Only if you do NOT use the Joomla Web Services API
<IfModule mod_rewrite.c>
RewriteRule ^api(/|$) - [F,L]
</IfModule>
Tested: every file in the list above returned 403, including lowercase variants such as /readme.txt. Test PHP files placed in all seven folders returned 403, as did .PhAr, shell.jpg.php and files several subfolders deep. The home page, /administrator/, CSS/JS under /media, images and robots.txt all still returned 200. Joomla's own cache files under administrator/cache are included by PHP, never requested over HTTP, so the admin kept working.
The .phar rule matters on Ubuntu: its default PHP handler executes .phar and .phtml as well as .php.
The gap: a planted .htaccess turns rule 2 off
Rewrite rules are not inherited by a folder whose own .htaccess turns the rewrite engine on. In testing, an images/.htaccess containing only RewriteEngine On made images/zz_test.php execute again. An attacker who can write one file can usually write two, so rule 2 is defence in depth, not a fix.
If you control the server config, close the gap there. AllowOverride is ignored inside <DirectoryMatch> (tested: the planted .htaccess still worked), so each folder needs a plain <Directory> block:
# /etc/apache2/conf-available/joomla-noexec.conf (adjust /var/www/html)
<Directory "/var/www/html/images">
AllowOverride None
</Directory>
# ...repeat for files, media, tmp, cache, administrator/cache, administrator/logs
<DirectoryMatch "^/var/www/html/(images|files|media|tmp|cache|administrator/cache|administrator/logs)(/|$)">
<FilesMatch "(?i)\.(php\d?|phtml|phar|pht|phps|cgi|pl|py|sh)$">
Require all denied
</FilesMatch>
RemoveHandler .php .phtml .phar
# mod_php only; remove the next line for PHP-FPM
php_admin_flag engine off
</DirectoryMatch>
Enable it with a2enconf joomla-noexec && apachectl configtest && systemctl reload apache2.
Tested: with this in place, a planted images/.htaccess that set RewriteEngine On and mapped .jpg to PHP was ignored: .php files returned 403 and evil.jpg was served as plain text instead of running. The same held for a planted .htaccess several folders deep.
PHP settings
These go in the PHP configuration for the web server (for example /etc/php/8.3/apache2/conf.d/99-joomla.ini or the PHP-FPM pool):
expose_php = Off
display_errors = Off
log_errors = On
allow_url_include = Off
disable_functions = exec,passthru,shell_exec,system,proc_open,popen,pcntl_exec
open_basedir = /var/www/html:/var/joomla-private:/tmp
session.use_strict_mode = 1
Tested: with all of these active, the front end, admin login, Control Panel, System Information, Global Configuration, Media, Extensions → Install and Joomla Update screens all loaded with no errors. A test script confirmed system() was disabled and reading /etc/passwd was blocked by open_basedir. The CLI uses a separate php.ini, so cli/joomla.php is unaffected.
Check your own extensions before using disable_functions. Some backup and image tools call exec(); test on staging first.
Nginx
Joomla does not ship an Nginx file. The same intent translates to location blocks that deny all the paths in rule 1 and return 403 for script extensions under the writable folders. These Nginx rules were not part of this test run.
5. File system: ownership, configuration.php and private paths
Ownership beats permissions
The common advice is "set configuration.php to 444". Testing showed why that is not enough. With the file at 444 but owned by www-data, config:set sitename=... running as www-data still rewrote the file: Joomla changes the mode on a file it owns, writes it, and resets it. The protection only works when the web server user does not own the code.
On a dedicated server or VPS, use this layout:
# Code owned by a deploy user (root here), read-only for PHP
chown -R root:root /var/www/html
find /var/www/html -type d -exec chmod 755 {} +
find /var/www/html -type f -exec chmod 644 {} +
# configuration.php: readable by PHP, not world-readable, not writable by PHP
chown root:www-data /var/www/html/configuration.php
chmod 640 /var/www/html/configuration.php
# Only the folders Joomla must write to
chown -R www-data:www-data /var/www/html/{images,files,tmp,cache,administrator/cache,administrator/logs}
Tested: the site and admin login worked; Joomla rebuilt its cache in administrator/cache; failed logins were written to the log; a config:set as www-data failed and left configuration.php unchanged; and PHP could no longer create files in code folders such as components/.
The trade-off is real: with this layout, saving Global Configuration and installing or updating from the admin will fail. Run updates from the shell as the owner (php cli/joomla.php core:update, extension:install), or loosen ownership briefly during maintenance. (Tested: `extension:install --path=` with a test package failed as `www-data` and succeeded as the owner.)
On shared hosting where PHP runs as your own user, this split is not possible. There, 444 on configuration.php still guards against accidental overwrites by extensions, but treat it as a speed bump, not a lock.
Move logs and tmp outside the web root
By default the CLI installer set log_path to /var/www/html/administrator/logs and tmp_path to /var/www/html/tmp. Move both somewhere the web server cannot serve:
mkdir -p /var/joomla-private/{logs,tmp}
chown -R www-data:www-data /var/joomla-private && chmod 750 /var/joomla-private
php cli/joomla.php config:set log_path=/var/joomla-private/logs
php cli/joomla.php config:set tmp_path=/var/joomla-private/tmp
(Or set Path to Log Folder and Path to Temp Folder in Global Configuration → System / Server.) If you use open_basedir, add the new path to it.
Tested: both config:set commands worked, and the next failed login was written to /var/joomla-private/logs/error.php.
Go further: the public folder (Joomla 5.0+)
Joomla can serve the site from a separate public folder that contains only entry points and symlinks to images, media and files. Everything else (configuration.php, libraries, plugins, cli, manifests) sits outside the document root.
php cli/joomla.php site:create-public-folder --public-folder=/var/www/joomla-public
Then point the virtual host's DocumentRoot at /var/www/joomla-public with Options FollowSymLinks.
Tested: the site, admin, media and images all loaded. /configuration.php, /libraries/vendor/autoload.php, /cli/joomla.php, /administrator/manifests/files/joomla.xml and plugin and template XML files all returned 404: they simply do not exist under the web root.
Two things to carry over, both found in testing:
- Your
administrator/.htaccess(Basic auth) lives in the code folder, not the public folder. On the public folder,/administrator/answered200with no extra login until it was added there too. - Apache matches
<Directory>on the path it walks, not the symlink target. A no-exec rule for/var/www/html/imagesdid not protect/var/www/joomla-public/images: a planted.htaccessmade a.jpgexecute (42from<?php echo 6*7;). Add the public paths to the server-level no-exec config (section 4); after that, the same attack returned the file as plain text.
6. Extensions, uploads and the Web Services API
Extensions are where most risk lives
Core Joomla is reviewed by a security team; third-party extensions vary widely. Before installing one:
- Check the Vulnerable Extensions List (VEL) for the extension and the developer. The VEL itself notes it may not be complete, so it is a first check, not a clearance.
- Prefer extensions with recent releases, a public changelog and support for your Joomla major version.
- Install only from the developer or the Joomla Extensions Directory, never from "nulled" copies.
- Uninstall, don't just disable, anything you no longer use. Disabled code is still on disk and may still be reachable.
Keep Media Manager's upload filters on
Content → Media → Options controls uploads. On 6.1.4 the defaults are safe: Restrict Uploads on, Check MIME Types on, and an allow-list of image, audio, video and document extensions with no php, svg or html.
Tested through the Media Manager upload API as a logged-in Super User:
|
File uploaded |
Result |
|---|---|
|
|
Rejected: "This file type is not supported." |
|
|
Rejected: "This file type is not supported." |
|
|
Rejected: "This file type is not supported." |
|
|
Rejected: "Illegal mime type detected" |
|
|
Accepted (allowed type) |
Do not add svg, html, js or archive types to the allow-list unless you must. Joomla's shipped .htaccess sends Content-Security-Policy: script-src 'none' for .svg files, which limits but does not remove the risk of script in SVGs.
Disable or block the API if you don't use it
Joomla's Web Services API (/api/index.php/v1/...) is on by default, with 18 webservice plugins enabled on the test install. Unauthenticated requests returned 401. Still, every enabled endpoint is attack surface, as the September 2026 ACL fix for webservice edit tasks showed.
- Disable the Web Services plugins for content you never expose (System → Plugins, filter type webservices). Tested: with the Web Services - Users plugin disabled,
/api/index.php/v1/userschanged from401to404 Resource not found. - If nothing uses the API, block it at the server with rule 3 in section 4. Tested:
/api/...returned403; the site and admin were unaffected. - Leave API Authentication - Basic Auth disabled (the default). It accepts a username and password on every request.
7. Database, backups and monitoring
Database account
Give each site its own database and its own database user, limited to that one database, connecting from localhost only. Never reuse the MySQL root account. The installer's random table prefix (jt_, dqlv8_ and so on) is fine to keep, but it is not a security control.
Cutting the user down to data-only privileges is often suggested. Testing shows where it breaks:
|
Grants |
Front end |
Admin login |
Smart Search reindex, updates, installs |
|---|---|---|---|
|
|
Works |
Works |
Fails ("DROP command denied") |
|
|
Works |
Works |
Works |
Only use the reduced grants if your maintenance process restores full privileges for updates. For most teams, "all privileges on this one database only" is the right line.
Backups that actually restore
A backup you have never restored is a hope, not a backup. Two tested options for the database:
# Joomla's own exporter (ZIP of all tables)
php cli/joomla.php database:export --folder=/var/backups/joomla --zip
# Or a consistent mysqldump
mysqldump --single-transaction --quick <dbname> | gzip > /var/backups/joomla/db-$(date +%F).sql.gz
Tested: both completed. The mysqldump file was restored into an empty database, giving all 76 tables and the user record back.
Also back up the files (at minimum configuration.php, images/, files/, templates and any custom code). Keep copies off the server and keep several days of history: if a site was compromised on Monday and you only have Tuesday's backup, you restore the compromise. Store backups outside the web root; a .sql.gz in public_html is a data leak.
Watch the logs and block brute force
Joomla writes every failed login to error.php in the log folder, with the client IP:
2026-10-10T17:23:48+00:00 INFO 127.0.0.1 joomlafailure Username and password do not match or you do not have an account yet.
Core Joomla does not lock out an IP after repeated failures, so add fail2ban on the server. Filter /etc/fail2ban/filter.d/joomla-auth.conf:
[Definition]
failregex = ^\S+\s+INFO\s+<HOST>\s+joomlafailure\s+
ignoreregex =
datepattern = ^%%Y-%%m-%%dT%%H:%%M:%%S
Jail in /etc/fail2ban/jail.local:
[joomla-auth]
enabled = true
port = http,https
filter = joomla-auth
logpath = /var/joomla-private/logs/error.php
maxretry = 5
findtime = 10m
bantime = 1h
Tested: fail2ban-regex against the real log matched 3 of 3 failed-login lines, 0 missed. This depends on correct IPs: keep Behind Load Balancer off unless you really sit behind a proxy (section 3).
Also keep System - Action Logs enabled (it is on by default) so you can see who changed what in the admin, and review Users → User Actions Log after any incident.
If a site is compromised
- Take it offline (
php cli/joomla.php site:down) and keep a copy of the hacked state for investigation. - Restore from a backup made before the compromise, or reinstall core and extensions from clean packages.
- Change every password: Joomla users, database, FTP/SSH, hosting panel. Revoke API tokens.
- Find the entry point (out-of-date extension, weak password, writable folder) and fix it before going back online, or it will happen again.
Test environment
All tests ran on 10 October 2026 against a fresh install, made with Joomla's CLI installer, of the official Joomla 6.1.4 Full Package from the joomla-cms GitHub releases. Stack: Ubuntu 24.04.5, Apache 2.4.58 with mod_php, PHP 8.3.6, MariaDB, HTTPS with a self-signed certificate, fail2ban 1.0.2.
Each rule was checked with curl requests that recorded HTTP status codes and response headers before and after the change, plus scripted admin logins (CSRF token included), Media Manager upload attempts, and apachectl configtest/fail2ban-client -t for config syntax. Results will differ on Nginx, LiteSpeed or PHP-FPM; re-run the same requests against your own server after each change.
Not tested here: Nginx rules, Content-Security-Policy enforcement on a real template, and the full core:update flow (no newer version existed on test day).
