Security and audit: lock it down, see who did what
This guide is for administrators and operators who answer for the safety of a site. In about 30 minutes you check that a fresh Exponential installation is hardened, hand out access with roles and policies, read the audit trail, switch debug output on for your own address only, and (optionally) add two-factor sign-in. Every step is a command or a click path that exists in 6.0.15. Commands run from the project root; the examples use https://example.com for your site.
Warning
.htaccess slows down every request. When .htaccess files are enabled (AllowOverride anything but None), let alone
used, Apache looks for them and reads and evaluates them on every request, in every directory of the path, and
page loading times go up drastically (measured). The only real fix is to switch the feature off for
the virtual host: AllowOverride None in the HTTPS virtual host, and the rules in the virtual host's own
configuration as mod_rewrite rules. That is far faster in production speed tests and well worth the time it takes
to optimize a production configuration. Switch over right away: copy the rules of the
example HTTPS virtual host into your virtual host (with the static cache, also
staticcache_offload.conf), set AllowOverride None, run apachectl configtest, reload Apache and
test the site. The same rules for other servers: nginx, LiteSpeed,
Caddy, IIS, Velocity (built in); where virtual host rules go in a control
panel: Plesk, cPanel, DirectAdmin, ISPConfig.
On LiteSpeed or OpenLiteSpeed, the server's own page cache can also answer anonymous visitors and crawlers without PHP: Page caching with LiteSpeed and OpenLiteSpeed.
You need: shell access to the installation, an administrator login, and curl.
1. Check the hardening that is already on (5 minutes)
Since September 2026 a default installation is safe without extra work. Verify it instead of trusting it.
Security headers. Every page, the administration included, must carry five headers:
curl -sI https://example.com/ | grep -i -E 'x-content-type|x-frame|referrer|permissions|content-security'Expected output (order may differ):
x-content-type-options: nosniff
x-frame-options: SAMEORIGIN
content-security-policy: frame-ancestors 'self'
referrer-policy: strict-origin-when-cross-origin
permissions-policy: camera=(), microphone=(), payment=(), usb=()Change one with ./console exp:ini set (scope override), for example to allow a trusted editor host to frame the admin. The setting is site.ini [HTTPHeaderSettings] SecurityHeaders[Content-Security-Policy]; read its value first:
./console exp:ini get 'site.ini/HTTPHeaderSettings/SecurityHeaders[Content-Security-Policy]' --allow-root-userTurn on HSTS only when the whole site is HTTPS-only: SecurityHeaders[Strict-Transport-Security]=max-age=31536000.
Files that must not be served. Dot files and PHP files below the document root must answer 404:
for p in .env .git/config extension/expservices/ezinfo.php; do
curl -s -o /dev/null -w "$p %{http_code}\n" https://example.com/$p
doneExpected: 404 three times. If you keep your own copy of .htaccess, merge the rules from .htaccess_root and .htaccess_root_static. ./console exp:setup:htaccess (or --static) shows your copy's difference from the shipped rules and exits 1 while there is one; with nothing of your own in it, --force --apply takes the shipped rules after backing yours up (Setting up .htaccess and config.php from the console).
The session cookie. Sign in to the admin, open the browser developer tools, Application (or Storage), Cookies: the session cookie must show HttpOnly, SameSite=Lax and, on HTTPS, Secure. These are the code defaults (site.ini [Session] keys CookieSecure=auto, CookieHttponly=true, CookieSameSite=Lax; the file only lists them as comments). The is_logged_in cookie for HTTP caches carries the same path, domain, lifetime, Secure and SameSite, not HttpOnly. Behind a load balancer that keeps a user on one server by the session cookie, CookieAlwaysAddToHttpResponse=enabled sends the session cookie with every response of a running session (never to a visitor without one), and such a response says Cache-Control: private, so no shared cache keeps it: check with curl -sI -b <session cookie> <page> that it shows Set-Cookie together with private.
Settings that name it SameSite. An installation whose settings were written for LoveStack (mugoweb/ezpublish-legacy) may set the attribute as site.ini [Session] SameSite. Exponential reads that name too, as long as CookieSameSite is not set (when both are set, CookieSameSite is used), and notes once per request that the name is deprecated (in var/log/deprecated.log, counted in the debug output). Nothing changes for visitors; to silence the note, rename the setting to CookieSameSite in settings/override and the siteaccesses.
Logging out. Logging out ends the session for good: what the session held is deleted, the browser is told to drop the session cookie (it is sent expired, with the same path, domain, Secure, HttpOnly and SameSite it was set with, so the browser removes exactly that cookie), and the session id the browser had no longer signs anyone in, even when it was copied before. A session the site starts later in the same request gets a new id. This holds for every session handler (PHP's own, ezpSessionHandlerDB, a handler of your own, the Symfony one) and under Velocity. Earlier versions gave the session a new id and kept its contents. Nothing has to be switched on. To check: sign in to the admin and note the value of the session cookie, log out, then send a request with the old value, e.g. curl -s -b '<cookie name>=<old value>' https://<site>/admin/content/dashboard: the answer is the login form, not the dashboard. With [UserSettings] RedirectOnLogoutWithLastAccessURI=enabled the logout still leads back to the last page visited.
Files and directories the site writes. Logs, caches and storage are created as open as site.ini [FileSettings] and the code ask (0777/0666 by default). On a live server set the limits in config.php, define( 'EZP_DIR_MODE_MAX', 0750 ); and define( 'EZP_FILE_MODE_MAX', 0640 );, so nothing new is readable or writable by other accounts, and narrow what exists once: limits for the modes of new files and directories. From the console: ./console exp:config:set EZP_DIR_MODE_MAX 0750 --apply and ./console exp:config:set EZP_FILE_MODE_MAX 0640 --apply.
config.php itself. It can hold the cluster database password. Keep it out of version control (add /config.php to your project's .gitignore; the Exponential repository tracks only config.php-RECOMMENDED) and give it mode 0640 with the web server's group. ./console exp:config:list and exp:config:get show the values of names with PASS, SECRET, KEY, TOKEN, CREDENTIAL or PWD as ******** unless you add --show-secrets; exp:config:set warns when it writes a secret into a file every user can read. Every edit leaves a copy of the old file in var/backup/config/<time>/ (mode 0700 directories): protect var/backup/ like config.php and delete old copies (Security).
Uploaded images. An image is accepted by what the file contains, not by its name: PHP's fileinfo reads the type and it must be on image.ini [ImageUploadSettings] AllowedMIMETypes[] (JPEG, PNG, GIF, WebP, AVIF, TIFF, BMP and SVG as shipped; an SVG is stored only sanitized, see SVG images). This applies to the edit form, the upload dialogs, the AJAX uploader and the package wizard's thumbnails, and a refused file is not stored. Check that fileinfo is loaded in the web server's PHP as well as on the command line (php -m | grep fileinfo); without it the older getimagesize() check applies and the log says so once. Read the list with:
./console exp:ini get 'image.ini/ImageUploadSettings/AllowedMIMETypes' --allow-root-userThe first administrator password. When a kickstart file sets no password, a well-known one such as publish (or a simple variant of one), or one shorter than MinPasswordLength, the installer makes a random 20-character password, prints it once and writes it to var/log/initial-admin-password (readable by the owner only). exp:install --random-password makes a 24-character one and shows it once. Sign in, change it, and delete that file. Forgot it later:
php bin/php/resetuserpassword.php --allow-root-user -u admin -g-g generates a random password and prints it. Details: reset a user password.
Depth: security defaults of September 2026, datatype and input hardening, hardening of 6.0.13.
2. Give people only the access they need (10 minutes)
Access is decided by roles made of policies (module, function, limitations), assigned to users or groups. Nobody gets anything that no role gives.
- Sign in to the admin interface and open the Users tab, Roles and policies (
/role/list). - Click New role, name it
Editor of Newsin the editor that opens. - Click New policy, choose module content, function edit, then Grant limited access and pick the section or subtree the person may edit. Save the role with the button at the bottom.
- Open the role, click Assign, and select the user or group.
- Sign in as that user in a private window: only the allowed content can be edited.
Roles can hold dozens of policies; each policy shows its ID, the headings sort the list, and the editor has up and down buttons (they only tidy the list, the order never changes who may do what): role policy order. A role given to many users lists them a page at a time, sorted by name, with a name filter, and shows assignments whose user or group is gone: role assignment paging. Admin links already follow permissions, so a user never sees a link to something that would be refused: admin links follow permissions.
An edit policy opens more than the editor: whoever may edit an object may also open its versions (content/history)
without a read policy for it, and there see every version's number, status, translation, creator and dates, and
compare and copy its published, archived and rejected versions. Someone else's draft or pending version stays closed
without content/versionread. So limit an edit policy as you limit the read policy of the same role: the versions
of a draft.
Use the same rules in templates:
if $current_user|has_role( 'Editor' ) ... /if
if $current_user|has_policy( 'content', 'edit' ) ... /ifAll seven operators are in role and policy checks in templates.
Two rules of thumb: never edit the Administrator role to give a colleague a bit more; make a new role. And keep few administrators, because granting that role raises an alert (step 3).
3. Read the audit trail (5 minutes)
The audit is on by default. It records sign-ins and refusals, content changes, settings writes, cache clears, deploys and orders in a tamper-evident log (every record carries the hash of the one before).
./console exp:audit status --allow-root-userExpected: Audit: enabled, then one line per channel (content, access, system, commerce, read) with files, bytes, records today and Chain intact.
Look at the newest sign-ins and verify nothing was edited:
./console exp:audit tail --channel=access --name='access.session.*' --lines=3 --allow-root-user
./console exp:audit verify --allow-root-userverify prints INTACT per channel and exits with 1 when a chain is broken.
In the browser: sign in as administrator, open the Audit tab (/audit/dashboard), sign out and in again, and open Console: your sign-in is the newest access.session.login. Search by user, IP network or text:
./console exp:audit search --name='content.*' --result=refused --limit=10 --allow-root-user
./console exp:audit search --query=login --limit=5 --allow-root-userDo these once on a new installation:
- Run the cronjob group
frequentevery few minutes (php runcronjobs.php frequent); itsauditpart indexes events, delivers sinks, evaluates alerts and, once a day, rotates, verifies and archives. - Check who gets alert mail:
./console exp:audit alerts recipients --allow-root-user(default: the site'sAdminEmail). - List the alert rules:
./console exp:audit alerts list --allow-root-user. Out of the box they watch brute-force sign-ins, an administrator role being granted, settings written out of hours, mass deletes, the audit being switched off and a broken chain. - Back up
settings/override/audit.ini.append.php: it holds the signing and pseudonym keys. - Decide
OnWriteFailure(settings/audit.ini [AuditSettings], defaultcontinue): withrefuse, security-relevant actions are refused while the log cannot be written, so nothing happens unrecorded.
Who may look: only the Administrator role holds the policies audit/read and audit/manage; give audit/read to an auditor role (optionally limited by channel) in step 2's way. A user without it sees no tab and a typed URL is refused and recorded as access.permission.refused.
To hand records to an auditor, open Export with the console's filter: Download now streams the file at once,
Export in the background writes a large filter to a file you download from the same page later. An extension
with a channel of its own (Exponential Intelligence: the channel ai, every request to a model and every draft it
wrote, never a prompt or an answer) adds its panel to the dashboard and its columns to the CSV.
Location changes: removing a location is recorded by default (Location remove). To see who added a location
or chose another main location (Location add, Main location change), switch those two on in
settings/override/audit.ini.append.php:
[AuditEventSettings]
Enabled[]=content.node.add
Enabled[]=content.node.mainThey then appear in the console like every content event, with the object, the node and its parent; removing the
main location also records which location became the main one. The idea comes from LoveStack
(mugoweb/ezpublish-legacy, commit 9c931192e0), with thanks to Mugo Web; see the
audit reference, "Location changes".
Depth: the audit trail, audit event model, audit reference (bc).
4. Debug output for your address only (5 minutes)
Debug output shows file paths, SQL and settings, so it must never be on for every visitor. Two settings decide who sees it: DebugOutput is the master switch, and with DebugByIP=enabled only the addresses in DebugIPList[] get the report.
Find your address as the server sees it: sign in to the admin, open the Exp Debug bar (it appears once you are allowed to see debug), tab Settings, and run the IP test. The test needs the policy
setup/setup; it namesREMOTE_ADDR, the entry that matched and warnings such aslockout,openorexpired.See where the setting comes from and what is in effect:
./console exp:ini where site.ini/DebugSettings/DebugByIP --allow-root-userWrite your address with an expiry so it cleans itself up, in
settings/override/site.ini.append.php:[DebugSettings] DebugOutput=enabled DebugByIP=enabled DebugIPList[] DebugIPList[]=203.0.113.7/32 ; My laptop ; expires=2026-12-31T18:00(Use your address, or the label and expiry may be left out.) Entries accept IPv4 and IPv6 networks (
10.0.0.0/8,2001:db8::/32) andcommandlinefor the shell.Clear the INI cache and reload the page:
php bin/php/ezcache.php --clear-tag=ini --allow-root-user.
The bar refuses writes that would lock you out (the list would not contain your address) or open debug to everybody (/0, or DebugByIP off with DebugOutput on), and logs and can undo every change; presets (Template work, SQL tuning, Everything, Off) switch a bundle of settings at once. Switch to Off when done.
Depth: the Exp Debug bar, debug bar (bc), readable debug output.
5. Two-factor sign-in (optional, 10 minutes)
The extension sevenx_authentication_2fa adds TOTP (Google Authenticator, Authy and any RFC 6238 app) and e-mail one-time codes to the sign-in.
Install and activate it:
composer require se7enxweb/sevenx_authentication_2fa# settings/override/site.ini.append.php [ExtensionSettings] ActiveExtensions[]=sevenx_authentication_2fa [UserSettings] ExtensionDirectory[]=sevenx_authentication_2fa LoginHandler[user2fa]=sevenxUser2fa LoginHandler[]=standardThe standard handler must come after
sevenxUser2fa; comment aLoginHandler[]=standardline insettings/site.iniif the order does not take effect.Refresh:
php bin/php/ezpgenerateautoloads.php -eandphp bin/php/ezcache.php --clear-all --allow-root-user.Grant the policies in Roles and policies (step 2):
user2fa/verifyto the Anonymous role and to every role that uses 2FA, anduser2fa/setupto the roles that manage their own 2FA.Sign in and open
/user2fa/setup: choose TOTP, scan the code (or type the secret) into your app, enter the six digits. Sign out and in again: after the password you are asked for the code.To make it mandatory, set
[General] Enforce2FA=enabledinsettings/override/sevenxauthentication2fa.ini.append.php; users without a method are sent to the setup page.
Expected: with a wrong code the sign-in is refused and the audit shows the failed attempt. Lost phone: the user can take the e-mail code while AllowEmailFallback is enabled (the default); the extension also has a reset 2FA interface, described on its feature page. Remove expired challenges with php extension/sevenx_authentication_2fa/bin/php/sevenx2facleanup.php (also a cronjob script).
Depth: two-factor and social login (every setting, social login, adding a provider).
6. Check an account on its account page (2 minutes)
My account (/user/edit) is your own account page; /user/edit/<id> is another user's, for an administrator
who may edit that user.
- Open
/user/edit. The top card shows the name, login, e-mail address, groups, roles (linked to the role pages when you may read them), the last sign-in with the number of sign-ins, and when the password was last changed (from the audit index; "Not recorded" when the audit has no such event). A badge says whether the account is Active, Disabled or Locked (too many failed sign-ins). - Read the hints above it, the most pressing first: the account is disabled or locked, failed sign-ins since the last good one, two-step sign-in is off, the password is stored with an old method (changing it stores it with the current one), the password was changed more than a year ago, the account never signed in.
- Go on from a card: Profile (content edit of the user), Password, Account settings (enable,
disable, reset failed sign-ins), Two-step sign-in (
/user2fa/setup, when the extension is active and the user class has its field), API keys, Bookmarks, Notifications and E-mail preferences. Only the cards you may open are shown: bookmarks, notifications and two-step setup are always your own, so they are not on another user's page; there API keys opens/oauthadmin/keys/(user)/<id>and E-mail preferences the administration page of that user. - Cancel goes back to the page you came from.
Expected: an administrator sees every card on their own page; an editor without the user/password or
user/preferences policy sees no Password or Account settings card. For developers: the page is
design/admin4/templates/user/edit.tpl (the same file in design/admin), its style user/exp_style.tpl, and the
overview expUserAccountOverview (tested in tests/tests/kernel/classes/user/expUserAccountOverviewTest.php).
For all accounts at once, Users > Check user accounts (user/hygiene) lists those never signed in, not seen
for N days, disabled, locked, orphaned or sharing an e-mail address, disables, enables or unlocks the ticked ones
and exports the list as CSV: Check user accounts.
7. One account per login (2 minutes)
Signing in looks an account up by its login without regard to case, so two accounts with the same login (or with
logins that differ only in case) cannot both be used, and which one answers is a matter of chance. A new
installation has a unique index on ezuser.login, so the database itself keeps logins apart. An upgraded site keeps
the plain index it had until an administrator adds the unique one; the database update does not, because it would
fail on a site that has duplicates.
List the duplicates (changes nothing):
./console exp:user-login-check --allow-root-userIt shows the database engine, the index on
ezuser.loginand one line per login that more than one account has, with the content object ids of those accounts. Logins are shortened to their first letter and length;--show-loginsshows them in full,--jsongives the same as JSON. A line ending in "differ only in case" is accepted by the index of PostgreSQL, SQLite, Oracle and MongoDB, which compare bytes, but is still the same login to Exponential. On MySQL and MariaDB the column's collation decides; as shipped it ignores case and trailing spaces.Resolve each line by hand: open the accounts by their ids (
/content/view/full/<node>or Users), give one a new login on its edit page, or remove an account nobody uses.Add the unique index once the report is clean:
./console exp:user-login-check --fix-index --allow-root-userIt refuses, and changes nothing, while any duplicate is listed. Exit codes: 0 clean (or index added), 1 duplicates found (or index refused), 2 error.
Expected: "PASS: every login belongs to one account" and, after --fix-index, "Login index ezuser_login (unique)".
What editors and integrations notice: the edit form already refused a login another account has; now an account
draft, a package import, the login|email|hash|type|enabled string of the content API and a first LDAP sign-in
refuse it too, and two requests creating the same account at once end with one account. A first LDAP sign-in that
loses that race signs in with the account the other request created. For developers:
expUserLoginIntegrity::duplicates(), indexStatus(), addUniqueIndex(), loginTakenBy() and storeUser()
(kernel/classes/expuserloginintegrity.php, tested in tests/tests/kernel/classes/expUserLoginIntegrityTest.php).
The idea and the draft handling come from LoveStack (Mugo Web, commit 826e1d4647), with thanks.
Checklist
- To do: Five security headers present, dot files and PHP files answer 404
- To do:
config.phpmode0640, not committed; old backups invar/backup/removed - To do: Initial administrator password changed
- To do: Roles hold only what people need; few administrators
- To do:
exp:audit statusshows every chain intact,frequentcronjob runs, alert recipients are right - To do: Debug output only for your own address, with an expiry
- To do: Two-factor on for administrators
- To do:
exp:user-login-checkreports every login belongs to one account, and the login index is unique
Demo mode in the audit and the logs. While the environment variable EXP_DEMO_MODE=1 is set, every process records once the audit event system.demo.mode (warning, always on) and a demo/mode line in var/log/security.log, and each refused administrator password change or reset is access.user.password.change.failed or access.user.password.reset.failed with the reason demo_mode. On a production site any of these means the variable reached it. Demo mode.
Related pages
- The threats one by one, and request-shield in front: Security hardening; the administrator account: Change the administrator password, The default administrator password.
- Control a site from scripts and apps with tokens: remote services and apps.
.htaccessandconfig.phpfrom the console, with masked secrets and backups: Setting up .htaccess and config.php from the console.- Upgrade impact of the security changes: behaviour changes of 1-2 October 2026, security hardening of August 2026, 6.0.15 changelog.
- How it came about: October 2026 chronicle, September 2026, second half.
- Other guides: Operating a site (logs, backups, repairs), Extensions (two-factor and other extensions), Glossary.
Become an Exponential Partner
Partners share their information, provide funding, contribute back and develop the products with us, and that is how Exponential grows. A partner website and dashboard are planned for mid-2027: please become a partner today to speed up our growth rate and land new features faster.