13. Security hardening for production
A default Exponential 6.0 installation is built to be safe without extra work: the rewrite rules hand out only listed
files, security headers go out on every page, the session cookie is HttpOnly and SameSite, debug output is off,
forms carry a token against cross-site request forgery and an audit log records who did what. This chapter walks
through each of those layers, says which setting controls it and how to check it, and lists what an administrator
should still decide before a site goes live: secrets in settings/override, the administration siteaccess, password
rules, HTTPS-only cookies, file permissions, mail consent and keeping the installation up to date. It is written as a
checklist you can work through from top to bottom; a one-page summary is at the end.
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.
For the attacker's view, threat by threat, with the request-shield in front and what to watch, read Security hardening in the help; for the administrator account, Change the administrator password and The default administrator password.
Contents · Previous: 12. Troubleshooting · Next: 14. Migrating from the 4.x line
Contents of this chapter
- The layers at a glance
- What the web server must never hand out
- Secrets in settings/override
- Debug output and error display
- The administration siteaccess
- Passwords and sign-in
- Sessions and cookies
- Form tokens against cross-site request forgery
- Security headers and HTTPS
- Behind a proxy: trusted headers
- File permissions and ownership
- Velocity-specific hardening
- Mail: consent and suppression
- The audit log
- Keeping up to date
- Go-live checklist
- References
Settings are changed in overrides, never in the shipped files: settings/override/<file>.ini.append.php for the whole
installation, settings/siteaccess/<siteaccess>/<file>.ini.append.php for one siteaccess. Clear the INI cache after a
change (php bin/php/ezcache.php --clear-tag=ini), and under Velocity restart the server or run
exp:velocity deploy (chapter 8).
13.1 The layers at a glance
visitor
|
[ TLS, HSTS ] Velocity [HTTPSSettings] / Apache mod_ssl / nginx ssl 13.9
|
[ what may be served as a file ] .htaccess_root / Velocity's list / derived nginx block 13.2
|
[ security headers ] site.ini [HTTPHeaderSettings] SecurityHeaders[], 13.9
| velocity.ini ResponseHeaders[]
[ request rules, siteaccess login ] requestrules.ini, [SiteAccessSettings] RequireUserLogin 13.5
|
[ session cookie, form token ] site.ini [Session], ezformtoken 13.7, 13.8
|
[ roles and policies ] Roles and policies in the administration 13.5
|
[ audit ] audit.ini, exp:audit 13.14
|
files on disk: owner, group, modes [FileSettings] *Permissions, chown/chmod 13.11| Layer | Default | What you decide |
|---|---|---|
| Rewrite rules | strict list, PHP below the root refused | keep them; merge new rules after upgrades |
| Secrets | generated into settings/override | file modes, backups, never in version control |
| Debug output | off | keep it off, or limit it by IP |
| Admin siteaccess | sign-in required | own host name, network restriction, request rules |
| Passwords | 10 characters, bcrypt | character rules, lockout after failed sign-ins |
| Session cookie | HttpOnly, SameSite=Lax, Secure over HTTPS | CookieSecure=true on an HTTPS-only site |
| Form tokens | active (setup wizard) | keep ezformtoken active; adapt custom AJAX |
| Forwarded headers | believed only from 127.0.0.1 and ::1 | list your proxies in TrustedProxies[], and nothing more |
| Security headers | five headers on every page | HSTS once HTTPS-only; frame-ancestors for editors |
| Audit | on | cron, alert recipients, key backup |
13.2 What the web server must never hand out
The document root of Exponential is the installation root, so settings/, var/, vendor/, the kernel sources and
the SQLite database lie inside it. Safety comes from the routing, not from the directory layout: only a fixed list
of asset paths is served as files; everything else is passed to index.php. The list is .htaccess_root, and
Velocity's three engines implement the same list (chapter 8, section 8.2).
What the shipped rules guarantee (.htaccess_root and doc/eng-us/specifications/6.0/security-defaults-2026-09.md,
section 4):
| Request | Answer | Rule |
|---|---|---|
a dot file or directory anywhere: /.git/config, /.env, /extension/x/.htaccess | 404 | RewriteRule (^|/)\.(?!well-known(/|$)) - [R=404,L] |
/.well-known/... | reachable (ACME challenges) | the same rule's exception |
an existing .php, .phtml, .phar below the root: /extension/x/foo.php, /var/storage/packages/.../x.php | 404 | RewriteRule ^.+/[^/]+\.(php[0-9]?|phtml|phar)$ - [R=404,L] |
/settings/site.ini, /var/site/storage/original/application/x.pdf, /composer.json | handled by index.php (a page or 404), never the file | not on the list |
package store var/storage/packages/ | only preview images (png, jpg, gif, webp; not SVG, which can carry script) | ^var/storage/packages/.+\.(png|jpe?g|gif|webp)$ |
| stored originals | only image files below storage/original/image; other uploads only through content/download, which checks permissions | ^var/([^/]+/)?storage/original/image/.+\.(png|jpe?g|gif|webp|svg)$ |
What you do:
- Apache:
./console exp:setup:htaccess --apply(orcp .htaccess_root .htaccess) and leave the security rules in it. Do not delete rules to make something work; find the cause instead. If you keep your own.htaccessor virtual host rules, compare them with.htaccess_rootafter every upgrade and merge new rules:./console exp:setup:htaccessshows the diff between your.htaccessand the template and exits 1 when they differ, so a monitoring job or CI can catch drift (Setting up .htaccess and config.php from the console). - nginx: use the derived server block of chapter 8, section 8.6 or an equivalent that keeps the first two rules (dot files, scripts below the root) ahead of every static location.
- Velocity: nothing to do;
FollowSymlinks=disabled(the default) also refuses files reached through a symbolic link that leads out of the document root. - Never serve the installation with a generic configuration such as nginx's "serve the file if it exists, else
index.php" or Caddy'sphp_server: those hand out every file that exists, includingsettings/*.iniand the SQLite database.
Check from outside:
for p in /.git/config /.env /settings/site.ini /composer.json /kernel/classes/ezini.php /var/log/error.log; do
printf '%-32s %s\n' "$p" "$(curl -s -o /dev/null -w '%{http_code}' https://example.com$p)"
doneEach line must show 404 (or 200 with Exponential's page, never the file's content). For the .ini path, look at the
body once: it must be an HTML page, not INI text.
13.3 Secrets in settings/override
Several files in settings/override/ hold secrets. They are written by the installer or generated on first use:
| File | Secret | Consequence if lost or leaked |
|---|---|---|
site.ini.append.php | database user and password ([DatabaseSettings]) | database access |
audit.ini.append.php | audit signing key and pseudonym key | archives signed with the key can no longer be verified; pseudonyms can be reversed |
mailpreferences.ini.append.php | the site secret ([SecretSettings] TokenSecret, generated on first use) | every unsubscribe and preference link stops working, every suppression entry stops matching, and every signed-in user is signed out once, because the password stamp of the sessions (section 13.6) is keyed with it |
velocity.ini.append.php | [DashboardSettings] Token when set | remote access to the server's figures and /Q/phpinfo |
config.env.php in the root | machine settings such as the environment name | (git-ignored) |
var/log/initial-admin-password | the generated first administrator password of a kickstart or console install | the administrator account |
What you do:
- Change the generated administrator password and delete
var/log/initial-admin-passwordafterwards; the console installer says so when it prints the password. - Never commit
settings/override/to version control and never copy it into a public place. Keep the files readable by the site's user only where nothing else needs them (chmod 640, owner the site's user or root, group the site's group). - Back them up, as secrets, together with the database: without the audit and mail preference files, old archives and links stop working.
- Copy them with every move of the installation to another machine. A copy started without
mailpreferences.ini.append.phpgenerates a new site secret on its first mail or sign-in, and from then on the old links and suppression entries no longer match; copying the file in afterwards does not help for what was sent in between. - The administration and
exp:inimask secret values when they show settings; still, givesetup/setup(settings views) only to administrators.
13.4 Debug output and error display
Debug output shows SQL, template names, file paths and timing: useful in development, a gift to an attacker in production.
settings/site.ini [DebugSettings] as shipped:
[DebugSettings]
DebugOutput=disabled # master switch: no debug report on pages
AlwaysLog[]=error # errors are still written to var/<site>/log/error.log
ScriptDebugOutput=disabled # command-line scripts
DebugByIP=disabled # when enabled: only the addresses in DebugIPList[] get debug
DebugIPList[]
DebugByUser=disabled # when enabled: only the users in DebugUserIDList[]
DebugRedirection=disabled
DisplayDebugWarnings=disabledand [TemplateSettings] Debug=disabled (no template names in the HTML).
For production keep DebugOutput=disabled. If you must look at a live problem, restrict debug to your own address
and remove the entry again:
# settings/override/site.ini.append.php
[DebugSettings]
DebugOutput=enabled
DebugByIP=enabled
DebugIPList[]
DebugIPList[]=203.0.113.10
DebugIPList[]=2001:db8::/48Entries may be single addresses or CIDR ranges of both families; a plain IPv6 address means exactly that address
(see the debug bar, which also documents labels and expiry dates on entries). The debug bar's
Settings tab can change these values; it needs the setup/setup policy.
DebugByIP trusts the address Exponential determines for the visitor. Since 6.0.15 that address comes from a
forwarded header only when the connection comes from a proxy in TrustedProxies[], and the header is read from the
right, so a visitor can no longer put an address of your list in front of the proxy's entry (section 13.10). Two
things still defeat it: a TrustedProxies[] range wider than your proxies (a whole provider network, 0.0.0.0/0),
and an application server that can be reached around the proxy. Keep both tight, and remember that behind a proxy
whose REMOTE_ADDR is the proxy itself, a list entry for the proxy's address would give debug output to everyone.
PHP itself: in the php.ini of PHP-FPM set display_errors=Off, log_errors=On and expose_php=Off.
FrankenPHP reads no machine php.ini; Velocity ships display_errors=Off and log_errors=On for it in
[FrankenPHPSettings] IniOptions[]. The qbix engine runs command-line PHP with the machine's CLI php.ini plus
[PHPSettings] IniOptions[]; add the same there if your CLI php.ini displays errors:
# settings/override/velocity.ini.append.php
[PHPSettings]
IniOptions[]=display_errors=Off
IniOptions[]=log_errors=On(Without an empty IniOptions[] line first, these are added to the shipped list.) The engine's own error pages show
an uncaught error's file, line and trace only with its debug option, which is off by default.
13.5 The administration siteaccess
The administration is a siteaccess of its own (usually admin, or the name chosen at install time). The installer
writes [SiteAccessSettings] RequireUserLogin=true into its site.ini.append.php, so every page of it asks for a
sign-in, and access to each function is decided by the role system (setup/*, content/*, audit/* and so on).
Hardening beyond that, from the strongest to the simplest:
Give it its own host name (for example
admin.example.com, matched by[SiteAccessSettings] HostMatchType) rather than a path below the public site, so it can be restricted as a unit.Restrict the network. With Apache, on the administration's virtual host:
<Location /> Require ip 203.0.113.0/24 2001:db8::/48 </Location>or HTTP basic authentication in front of it (
AuthType Basicwithmod_auth_basic). With nginx:allowanddeny. Velocity has no HTTP authentication of its own; restrict the administration host with the firewall, a VPN or a reverse proxy in front, or keep it on a separate Velocity bound to an internal address.Request rules (
settings/requestrules.ini) decide before a module view runs: keep anonymous visitors off system URLs such as/content/view/full/123, require a sign-in for a section, refuse requests from a network. They are tied to role policies, so exemptions are made in the role editor. See Request rules.Least privilege. Give editors the roles they need, not Administrator. Policies that matter for security:
setup/setup(settings),setup/managecache,audit/readandaudit/manage,mailpreferences/administrateandmailpreferences/export,contentfinder/searchandcontentfinder/delete(the content finder: find content by id or path, and trash or delete it from there),user/loginper siteaccess.The setup wizard must stay closed. After installation
settings/override/site.ini.append.phpcarries[SiteAccessSettings] CheckValidity=false. While it istrue(the shipped default), every request is sent to the setup wizard. Never set it back on a live site.
13.6 Passwords and sign-in
Length and character rules
settings/site.ini:
[UserSettings]
MinPasswordLength=10 # bytes; 3 when the setting is missing
GeneratePasswordLength=16
WeakPasswordCheck=enabled # refuse well-known and common passwords (since 6.0.15)
WeakPasswordFile= # more words, one per line; or WeakPasswordList[]=...
ForgotPasswordKeyLifetime=3600 # seconds a "Forgot your password?" link works; 0: until used
HashType=php_default # bcrypt today; never plaintext or the md5_* types
UpdateHash=true # re-hash older hashes at the next sign-in
[PasswordSettings] # every rule off by default
RequireLowercase=disabled
RequireUppercase=disabled
RequireDigit=disabled
RequireSymbol=disabled
MinCharacterClasses=0 # at least this many of the four kinds above
ForbidLogin=disabled # must not contain the login name
ForbidCurrentPassword=disabled
EndOtherSessions=enabled # after a change, sign the user's other sessions out
ChangeNotificationMail=enabled
StrengthMeter=enabled
GenerateButton=enabledA reasonable production policy, in line with current guidance that favours length over composition rules:
# settings/override/site.ini.append.php
[UserSettings]
MinPasswordLength=15
[PasswordSettings]
ForbidLogin=enabled
ForbidCurrentPassword=enabledWhy these values: NIST SP 800-63B-4 (2025) asks for at least 15 characters where a password is the only factor and
says verifiers should not impose composition rules, because rules such as "one digit, one symbol" lead to predictable
passwords (Summer2026!) rather than strong ones; the strength meter and the "Generate" button
(GeneratePasswordLength=16) help users reach the length instead. The Require* and MinCharacterClasses rules
exist for organisations whose own policy prescribes them. MinPasswordLength counts bytes, so a password in a script
with multi-byte characters reaches it with fewer characters. Raising the minimum does not lock anyone out: existing
passwords keep working and the rule applies when a password is next set. The same goes for the well-known password
check (WeakPasswordCheck), which follows the same guidance: verifiers should compare new passwords against a list
of commonly used and compromised values. Add your organization's own words with WeakPasswordList[] or WeakPasswordFile.
The rules apply to every form that sets a password (user/password, registration, the user account in content/edit
and the administration, bin/php/resetuserpassword.php), checked by expPasswordPolicy
(kernel/classes/exppasswordpolicy.php).
EndOtherSessions=enabled is what makes a password change useful after an account was taken over. How it works:
- At every sign-in the session receives a password stamp (
eZUserPasswordStamp): a keyed hash (HMAC-SHA256) of the user id and the stored password hash, keyed with a key derived from the site secret of section 13.3. It holds neither the password nor its hash. - On every request
eZUser::currentUser()compares the session's stamp with the stamp of the user's current hash. A password change changes the hash, so every other session of that user (other browsers, other devices, a session an attacker copied) no longer matches and is signed out on its next request. This works with every session handler, PHP's own file sessions included, because it is checked when the session is used. - The session in which the password was changed stays signed in but gets a new session id, so whoever knew the old id does not keep it. With the database session handler the other sessions are also deleted at once.
- Not checked: sessions that started before the upgrade to 6.0.15 (they carry no stamp yet; they are checked from their next sign-in), and accounts without a password of their own (LDAP, text-file and single-sign-on users).
- What can surprise you: a new site secret (a copy of the installation without
settings/override/mailpreferences.ini.append.php) changes every stamp, so everyone is signed out once.EndOtherSessions=disabledswitches off the stamp and the check together.
Keep it enabled. Details: Changing your password; every way to change or reset an administrator password, and what to do when nobody can sign in: Change the administrator password.
ChangeNotificationMail=enabled mails "your password was changed" to the account (category Account security,
which nobody can switch off; the mail never contains the password). It is how a user learns of a change they did not
make.
Demo mode must be off
The environment variable EXP_DEMO_MODE=1 marks an installation as a public demo (since 6.0.15): administrator
passwords can no longer be changed or reset, and the installers accept a well-known administrator password. On a
production site that is a known administrator password nobody can change, so:
- Make sure no process of the site has it:
grep -r EXP_DEMO_MODEover the PHP-FPM pools, the virtual hosts, the service units (Velocity's included), the crontabs and the container definitions must find nothing. - The administration shows a Demo mode banner on every page while it is on, and every process that starts with
it writes a
demo/modeline tovar/log/security.logand the audit eventsystem.demo.mode. Alert on either. - It cannot come from settings: there is no INI setting or request parameter for it, and only the exact value
1counts. Details and the risks: Demo mode.
Failed sign-ins
[UserSettings]
MaxNumberOfFailedLogin=0 # 0: no lockout
TrustedIPList[] # addresses never locked out
ShowMessageIfExceeded=falseSet MaxNumberOfFailedLogin (for example 10) to lock an account after that many failed attempts until an
administrator unlocks it. Weigh this: a lockout also lets anyone lock a known account. The audit's alert rules
brute_force (20 failures from one network in 5 minutes) and brute_force_user (10 for one account in 15 minutes)
report such attacks either way (section 13.14).
The sign-in itself does not reveal which accounts exist: an unknown name costs the same hash computation as a wrong password, and "forgot password" gives the same answer for an unknown address as for a known one.
13.7 Sessions and cookies
settings/site.ini [Session]; the three cookie keys are commented out in the shipped file and their defaults apply
while they are unset. SessionTimeout and RememberMeTimeout are set in the shipped file:
| Key | Default | Meaning |
|---|---|---|
CookieSecure | auto | Secure whenever the request itself came over HTTPS; true forces it |
CookieHttponly | true | scripts cannot read the session cookie |
CookieSameSite | Lax | Lax, Strict, None (only with Secure) or empty |
SessionTimeout | 31104000 | seconds a session lasts after its last use (360 days; until October 2026 259200, three days) |
RememberMeTimeout | 31104000 | seconds the session cookie lasts when Remember me is checked (360 days); the box is checked on the login forms by default. Empty or 0 switches Remember me off, and the cookie then ends with the browser |
SessionNameHandler, SessionNamePerSiteAccess | default, enabled | custom gives every siteaccess its own session cookie, so a sign-in on the public site is not a sign-in in the administration |
For a site that is served only over HTTPS:
# settings/override/site.ini.append.php
[Session]
CookieSecure=true
CookieHttponly=true
CookieSameSite=Lax
SessionTimeout=28800
RememberMeTimeout=28800CookieSecure=true makes sure the cookie is never sent over plain HTTP, even on a request that wrongly arrives
without TLS. Use Strict only if no other site links into signed-in pages. A shorter SessionTimeout limits how long
a stolen session stays useful. Shorten RememberMeTimeout with it (or leave it empty to switch Remember me off):
the shipped file asks to keep SessionTimeout at least as long as RememberMeTimeout, because a remembered cookie
cannot bring back a session the server has already removed.
Separate session cookies per siteaccess:
[Session]
SessionNameHandler=custom
SessionNamePrefix=eZSESSID
SessionNamePerSiteAccess=enabledWith custom, Velocity's response cache derives its "personal, do not cache" cookie from SessionNamePrefix
automatically.
In PHP's own configuration, session.use_strict_mode=1 makes PHP refuse session ids it did not create.
What can go wrong:
- Signed-in users are signed out on every page, or cannot sign in at all, behind an HTTPS proxy with
CookieSecure=truewhen the kernel thinks the request is plain HTTP and buildshttp://redirects: the proxy is not trusted or does not sendX-Forwarded-Proto(section 13.10). WithCookieSecure=autothe same mistake gives a cookie withoutSecureinstead, which is quieter and worse. CookieSameSite=NonewithoutSecureis refused by browsers; the kernel only honoursNonetogether withSecure. You needNoneonly if the site is used inside a frame or a cross-site form on another domain.Strictdrops the session on the first click from an e-mail or another site into a signed-in page, which users see as being signed out.
13.8 Form tokens against cross-site request forgery
The ezformtoken extension adds a per-session token to every HTML form and checks it on every POST
(extension/ezformtoken, event listeners request/input, response/output and session/regenerate). A forged form
on another site cannot know the token. The setup wizard activates it on every new installation.
- Keep it in
[ExtensionSettings] ActiveExtensions[]. Check:php bin/php/console exp:ini where site.ini/ExtensionSettings/ActiveExtensions(every file that sets it, and the value in effect) or Setup > Extensions. - Custom AJAX code that sends POST requests must send the token too; the kernel's own scripts and ezjscore do.
The extension's
README.rstshows how. - A refused token answers 403 with the "form expired" page, and no token is written to the log (Form expired page).
- A page carrying a token is sent
Cache-Control: private, so no shared cache stores a signed-in page with its token. A reverse proxy must not override that (section 13.10).
13.9 Security headers and HTTPS
Headers on every page
settings/site.ini [HTTPHeaderSettings]:
| Header | Default |
|---|---|
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=() |
Strict-Transport-Security | empty (off; only ever sent over HTTPS) |
An empty value in an override drops a header; header names and values that could split the response are ignored. To let a trusted editor host frame the administration:
[HTTPHeaderSettings]
SecurityHeaders[Content-Security-Policy]=frame-ancestors 'self' https://edit.example.comPages answered from the kernel's HTTP cache carry the same headers.
Velocity sends four of them (all but the content security policy) on what it answers itself (static files, resized images, its own error pages,
redirects, 304 answers) through [ServerSettings] ResponseHeaders[], and adds them to script answers that lack them
(ResponseHeadersOnScripts=enabled). Apache and nginx send nothing extra on static files by themselves; add them
there if you want them (Apache mod_headers: Header always set X-Content-Type-Options "nosniff"; nginx:
add_header X-Content-Type-Options "nosniff" always;).
A full content security policy (script-src, style-src) is sent from site.ini [ContentSecurityPolicy]:
report-only on the administration by default, off on the sites, whose designs and extensions may use inline scripts.
Watch the reports (browser console, ReportURI) before enforcing it, see
Content Security Policy.
HTTPS everywhere
Serve every name of the site over HTTPS: Velocity
[HTTPSSettings]or the engine's ACME support, Apachemod_ssl, nginxssl(chapter 8).Redirect plain HTTP to HTTPS (Apache and nginx: section 8.5 and 8.6). For individual subtrees Exponential has its own
[SSLZoneSettings].Set
[Session] CookieSecure=true(section 13.7).Turn HSTS on, starting short and raising it once everything works:
- Velocity:
[HTTPSSettings] HSTSMaxAge(300 shipped),HSTSIncludeSubDomains,HSTSPreload; - Apache or nginx:
SecurityHeaders[Strict-Transport-Security]=max-age=31536000insite.ini, sent by the kernel only over HTTPS.
A browser remembers HSTS for the whole period and refuses plain HTTP to that host, even after you lower the value. Raise it to a year only when every name is served over HTTPS for good.
- Velocity:
Keep certificates renewed and watched (
exp:velocity ssl show --json,daysLeft; or your ACME client's timer).
Check:
curl -sI https://example.com/ | grep -i -E 'strict-transport|x-content-type|x-frame|referrer|permissions|content-security'
curl -sI https://example.com/user/login | grep -i set-cookie # Secure; HttpOnly; SameSite=Lax13.10 Behind a proxy: trusted headers
A proxy, load balancer or CDN in front of the site tells the application what the visitor asked for in request
headers: X-Forwarded-Proto (was it HTTPS?), X-Forwarded-Host (which name?) and X-Forwarded-For (from which
address?). The trouble is that these are ordinary request headers: any visitor can send them too. Believed blindly,
they let a visitor make a plain HTTP request look like HTTPS, put another host name into every absolute URL the site
builds, and choose the address that the sign-in lockout, DebugByIP, request rules, the audit log and the consent log
record. The one thing the server knows for certain is the address of the peer that opened the connection,
REMOTE_ADDR, so that is what decides.
The rule
Since 6.0.15 the kernel believes forwarded headers only when REMOTE_ADDR is in site.ini [HTTPHeaderSettings] TrustedProxies[] (lib/ezutils/classes/eztrustedproxy.php, used by eZSys and by the HTTP cache). The default
trusts the loopback addresses, which nothing outside the machine can use:
[HTTPHeaderSettings]
TrustedProxies[]
TrustedProxies[]=127.0.0.1
TrustedProxies[]=::1- Entries are addresses or ranges, IPv4 and IPv6 (
192.0.2.10,10.0.0.0/8,2001:db8::/32); an IPv4 peer that a dual-stack socket reports as::ffff:10.0.0.5matches its IPv4 entry. Host names are not resolved, and an entry that is not an address is ignored, so a typo trusts nothing rather than something unintended. - An override that begins with
TrustedProxies[]on a line of its own replaces the list; that line alone trusts no proxy at all. - Put comments on lines of their own:
TrustedProxies[]=10.0.0.0/8 # LBis not an address and is ignored.
What each header then does, and only from a trusted proxy:
| Header | Effect | Without a trusted proxy |
|---|---|---|
X-Forwarded-Proto: https (else X-Forwarded-Port equal to [SiteSettings] SSLPort, else X-Forwarded-Server equal to SSLProxyServerName) | eZSys::isSSLNow() answers true: absolute URLs, redirects and CookieSecure=auto use HTTPS | ignored; $_SERVER['HTTPS'] and the port still count, they are the web server's own answer |
X-Forwarded-Host | replaces Host in generated URLs | ignored; Host, then SiteURL |
the header named in ClientIpByCustomHTTPHeader (default false, usually X-Forwarded-For) | the visitor's address, read from the right | REMOTE_ADDR |
Reading from the right, with an example. A visitor at 198.51.100.7 sends a forged header
X-Forwarded-For: 203.0.113.10 through a CDN (173.245.48.20) to your load balancer (10.0.0.5), which connects to
PHP. PHP receives REMOTE_ADDR=10.0.0.5 and X-Forwarded-For: 203.0.113.10, 198.51.100.7, 173.245.48.20. With
10.0.0.0/16 and 173.245.48.0/20 trusted, the kernel starts at the right: 10.0.0.5 (the peer) is trusted,
173.245.48.20 is trusted, 198.51.100.7 is not, so that is the visitor. The forged 203.0.113.10 further left is
never looked at. Before 6.0.15 the left-most entry won, which was exactly the forged one. An entry that is not an
address ends the walk at the last trusted proxy. Headers that proxies append to (a X-Forwarded-Proto of
http, https) are read the same way: the value the outermost trusted proxy wrote is used.
Which setup needs what
| Your setup | What to do |
|---|---|
| No proxy: Apache or nginx with PHP-FPM, or Velocity facing visitors | Nothing. Forwarded headers a visitor sends are ignored now, which is the point. |
| A proxy on the same machine that sets the headers (nginx or Apache in front of Velocity or PHP-FPM, Varnish, a TLS terminator) | Nothing; the default covers 127.0.0.1 and ::1. Make the proxy set X-Forwarded-Proto itself (Apache RequestHeader set X-Forwarded-Proto "https", nginx proxy_set_header X-Forwarded-Proto $scheme;). A local proxy that passes on the visitor's own header would carry it through: if you cannot change it, set TrustedProxies[] empty. |
| A proxy, load balancer or CDN on another address | List its addresses or ranges, and only those, in settings/override/site.ini.append.php. Behind a CDN and a load balancer, list both. Make sure the application server cannot be reached around them (firewall, security group, Velocity Host=127.0.0.1). |
A front end that rewrites REMOTE_ADDR itself (Apache mod_remoteip, nginx realip, as in Plesk's nginx in front of Apache) | Nothing in Exponential: the visitor already is the peer, so ClientIpByCustomHTTPHeader stays false. |
| You want the visitor's real address behind a proxy | ClientIpByCustomHTTPHeader=X-Forwarded-For (or a one-address header such as X-Real-IP or CF-Connecting-IP), together with the proxy in TrustedProxies[]. Behind several proxies, list all of them. |
A load balancer example:
# settings/override/site.ini.append.php
[HTTPHeaderSettings]
TrustedProxies[]
# the load balancer's private subnet
TrustedProxies[]=10.0.0.0/16
ClientIpByCustomHTTPHeader=X-Forwarded-ForUnder Velocity
Velocity keeps its own list of trusted proxies, Q.webserver.proxy.trusted (127.0.0.1 and ::1 by default). Set
it in a snippet of your own under /etc/vc/conf-available/, for example reverse-proxy.conf:
{ "Q": { "webserver": { "proxy": {
"trusted": ["127.0.0.1", "::1", "192.0.2.10"],
"headers": { "ip": "X-Forwarded-For", "proto": "X-Forwarded-Proto", "host": "X-Forwarded-Host" }
} } } }and switch it on with exp:velocity conf enable reverse-proxy (a link in conf-enabled/, as a2enconf does), then
restart Velocity. From a
peer in that list it works out the visitor's address from X-Forwarded-For (from the right as well) and passes it to
the application as REMOTE_ADDR, so ClientIpByCustomHTTPHeader is not needed. Since the engine change of
5 October 2026 (commit 380a64d, released in 0.0.4.44), Velocity also sets $_SERVER['HTTPS'] and REQUEST_SCHEME from
X-Forwarded-Proto (or the header named in Q.webserver.proxy.headers.proto), CloudFront-Forwarded-Proto or
Cloudflare's CF-Visitor only when the connection comes from that list; a TLS connection to Velocity is HTTPS from
any client. Earlier engines read the protocol header from any client wherever HTTPS was configured. Configure the
proxies in front of Velocity in its list, not in TrustedProxies[]; the kernel sees the visitor as the peer and the
engine's HTTPS as the server's own answer. Let the proxy pass the original Host (ProxyPreserveHost On,
proxy_set_header Host $host): X-Forwarded-Host reaches the kernel from the visitor's address and is not used.
What goes wrong, and how it shows
| Symptom | Cause |
|---|---|
Absolute URLs and redirects say http:// behind an HTTPS proxy; a redirect loop on the sign-in page | the proxy is not in TrustedProxies[] (or not in Velocity's list), or it does not send X-Forwarded-Proto |
| Every visitor has the same address: the proxy's (audit, lockout, request rules) | ClientIpByCustomHTTPHeader not set, or the proxy not trusted |
| One visitor's failed sign-ins lock out everyone | the same; with MaxNumberOfFailedLogin set, fix the address first |
| A visitor can still choose his address or the scheme | a trusted range far wider than your proxies, or the application server reachable directly |
Check it
# directly, no proxy: the header must change nothing (the redirect stays http)
curl -s -o /dev/null -w '%{redirect_url}\n' -H 'X-Forwarded-Proto: https' http://example.com/user/logoutBehind a proxy, the debug bar's "Is my address listed?" shows REMOTE_ADDR, the address the kernel uses and whether
the proxy header was read. Details and more examples (Apache mod_proxy, nginx, cloud load balancers, Cloudflare):
Forwarded headers are trusted only from configured proxies.
Caching proxies
Never let a proxy cache responses that set a cookie or carry Cache-Control: private (signed-in pages, pages with a
form token). Purge tags are not public by default (httpcache.ini [HttpCacheSettings] TagHeader=disabled); name the
one header your purging proxy reads only when you have one.
13.11 File permissions and ownership
Why it matters: the most common way from a bug in a PHP file to a lasting compromise is a file the attacker can write
and the web server will later run or serve. If the PHP process can change only var/ and settings/, and the web
server refuses to run anything below the root (section 13.2), a flaw in an extension cannot plant code.
Principles (the procedure is in chapter 8, section 8.7):
One user writes. PHP-FPM, Velocity workers, cronjobs and console scripts run as the site's user (or share one group). Never let workers run as root (
[ServerSettings] AllowRootWorkers=disabled, the default).Only what must be written is writable:
var/andsettings/(and, while packages are installed from the administration,design/andextension/). The code (kernel/,lib/,vendor/,index.php) can belong to another user and be read-only for the site's user, so a compromised PHP process cannot change it.No world-writable files. The shipped
[FileSettings]values are0777and0666; set the hardened values thatsite.inirecommends:# settings/override/site.ini.append.php [FileSettings] StorageDirPermissions=0770 StorageFilePermissions=0660 TemporaryPermissions=0770 LogFilePermissions=0660and fix existing files once:
find var -type d -exec chmod 2770 {} +andfind var -type f -exec chmod 0660 {} +(with the site's group on everything).Secrets in
settings/override(section 13.3):0640or stricter. Since 6.0.15 a save from the administration, the debug bar orexp:inikeeps the file's mode, owner and group, and creates a new settings file0640(EZP_INI_SAVE_FILE_PERMISSIONinconfig.php); before, every save made the file0666. Check once that no settings file is left world-writable:find settings extension/*/settings -perm -o+wmust print nothing (upgrade note). INI cache files are0644(EZP_INI_FILE_PERMISSION).Logs name visitors: Velocity writes its own with
0640in a0750directory ([LogSettings] FileMode,DirMode), the response cache with the same modes.Symbolic links out of the document root are refused by Velocity (
FollowSymlinks=disabled); with Apache useOptions SymLinksIfOwnerMatchinstead ofFollowSymLinksif users other than the site's could place links.TemporaryDir(/tmp/shipped) is shared with every user of the machine; consider a directory of the site's own, for example belowvar/.
13.12 Velocity-specific hardening
| Topic | Setting or action |
|---|---|
| Bind address | [ServerSettings] Host=127.0.0.1 unless Velocity faces visitors directly; the server has no HTTP authentication of its own |
| Proxies in front | only the addresses in Q.webserver.proxy.trusted may set the visitor's address and protocol (section 13.10); keep the list to your own proxies |
| Worker user | User, Group set to the site's user; never root |
| Symbolic links | FollowSymlinks=disabled |
| Dashboard and figures | [DashboardSettings] Token empty and Remote=disabled keep /Q/dashboard, /Q/stats, /Q/metrics local; if you set a token, generate a long random one (openssl rand -hex 24) and keep it secret |
/Q/phpinfo | local only; shows the process environment |
Control panel /Q/panel | local only, password protected; set the password at once on a machine reachable from the internet; its store must not be writable by group or others |
| HTTPS | a CA certificate, HSTS raised only when everything is HTTPS (section 13.9) |
| Configuration files | /etc/vc/sites-available/<site>.conf is 0600 (certificate paths, token); keep it so |
| Response cache | personal pages are never cached; if you rename the session cookie, check [CacheSettings] SkipCookies stays derived or lists it |
| Updates | engine releases fix security issues too (section 13.15); exp:velocity deploy after an update |
13.13 Mail: consent and suppression
Since 6.0.15 every mail passes the mail gate (expMailGate) before it reaches the transport:
- Essential categories (
security,orders,legal,admin) always go out. - Optional categories go only to recipients who switched them on, with a footer, a one-click unsubscribe link and
List-Unsubscribeheaders; one mail per recipient, so nobody sees other addresses. - Mail without a category is sent and counted in
var/<vardir>/log/mailgate.jsonl, so you can find code that should declare one.
What to set up for production (administrator's guide):
The tables exist:
php bin/php/console exp:mail:statusreports five timesok.The footer names the sender:
mailpreferences.ini [FooterSettings] OrganisationNameandOrganisationAddress. Without them optional mail goes out without a postal address, which many jurisdictions require.The site secret in
settings/override/mailpreferences.ini.append.phpis backed up (section 13.3).The suppression list blocks optional mail to hard bounces, complaints, "stop all" requests and legal requests. Only a salted hash of each address is stored, so the list cannot be read. Manage it in Setup > E-mail preferences or on the console:
php bin/php/console exp:mail:suppression check --email=<address> php bin/php/console exp:mail:suppression add --email=<address> --reason=legal --note="<no personal data>" php bin/php/console exp:mail:suppression lift --email=<address>Configure the bounce reader (
[BounceSettings]) so hard bounces are added automatically.The consent log records every change of a person's preferences with source and wording. It holds addresses and IP addresses: give
mailpreferences/exportto few people, delete exported CSV files when the request they answer is done, and let themailpreferencescronjob part apply the retention ([ConsentSettings] RetentionDays, 1095 days).Data protection requests:
exp:mail:preferences export --email=<address>(written with mode0600) andexp:mail:preferences erase --email=<address> --yes.
13.14 The audit log
The audit records sign-ins and failed sign-ins, content moved, hidden or removed, role and setting changes, prices,
commands and cronjobs, and what the audit itself did. It is on in every installation by default. Each record is a
line of JSON in a file per channel and day (var/<site>/log/audit/<channel>-<YYYY-MM-DD>.jsonl), and each line carries
the hash of the line before it, so a changed, removed or inserted line shows.
php bin/php/console exp:audit status # enabled, directory, key, each channel intact or broken
php bin/php/console exp:audit verify # checks the hash chains
php bin/php/console exp:audit tail --channel=access --lines=20For production:
- Run the cronjob part. It is in the
frequentgroup:php runcronjobs.php frequentevery few minutes, as the site's user. The audit dashboard warns when it has not run for an hour. - Decide who gets alert mail: by default the site's
AdminEmail; check withphp bin/php/console exp:audit alerts recipients. The shipped rules includebrute_force,brute_force_userandadmin_role_granted. - Back up
settings/override/audit.ini.append.phpas a secret (signing and pseudonym keys). - Give
audit/readonly to the roles that need it; only the Administrator role holdsaudit/readandaudit/manageby default. - Privacy.
[AuditPrivacySettings]decides per field what is kept: addresses truncated to /24 and /48, the session hashed, e-mail addresses hashed, passwords and tokens never recorded, personal fields pseudonymised afterPseudonymiseAfterDays(90). Adjust them to your data protection requirements; section 4.12 and 6.4 of the audit guide describe a strict profile. - Forward it to a central log or SIEM with a sink (
[AuditSink_syslog],[AuditSink_webhook]), so a compromised machine cannot silently rewrite its own history. Runexp:audit verifyregularly.
Full guide: Audit.
13.15 Keeping up to date
- Follow the releases of Exponential and of every extension you use: they are Composer packages, so
composer outdatedlists what is newer. Read the changelog and the upgrade notes before updating (chapter 11). - Update in a copy first, then on the live site:
composer update <package>, the database updates the release names,php bin/php/console exp:velocity deploy(or autoloads, cache clear and a PHP-FPM reload by hand). - Check file consistency after an update: Setup > Upgrade check ("Check file consistency" compares the files with
the release's
share/filelist.md5; "Check database consistency" the schema). A modified kernel file you did not change deserves a look. - PHP, the database server, the operating system, Velocity's engine and FrankenPHP's binary need updates as well.
FrankenPHP's version and checksums are pinned in
[FrankenPHPSettings] VersionandSha256[]; change them together. - Review your own extensions for the patterns fixed in the kernel (SQL built from request data without
escapeString()or integer casts, shell commands withoutescapeshellarg(), output without escaping); see the 6.0.13 hardening notes. Extensions generated by the RAD wizards before their fix should be regenerated (RAD tools: security). - Report a vulnerability to
[email protected], not to the public issue tracker (SECURITY.md).
13.16 Go-live checklist
- To do: Rewrite rules:
.htaccessis a current copy of.htaccess_root(or the derived nginx block, or Velocity);/.git/config,/settings/site.ini,/composer.jsondo not return the file../console exp:setup:statusnames the template. - To do:
config.phpnot in version control; mode0640with the web server's group when it holds a password (exp:config:setwarns when it does not);var/backup/(earlier copies ofconfig.php) protected and cleaned up. - To do:
settings/override/not in version control, backed up as a secret, mode0640or stricter. - To do: The generated administrator password changed;
var/log/initial-admin-passworddeleted. - To do: A personal administrator account per person;
adminretired as a sign-in; a tested break-glass procedure (Account hygiene). - To do:
[DebugSettings] DebugOutput=disabled; PHPdisplay_errors=Off. - To do:
[SiteAccessSettings] CheckValidity=false; the administration on its own host, restricted by network or a second authentication; least-privilege roles. - To do: Password policy decided (
MinPasswordLength,[PasswordSettings]);EndOtherSessions=enabled. - To do: Demo mode off:
EXP_DEMO_MODEset for no process of the site; no Demo mode banner, nodemo/modeline insecurity.log(Demo mode must be off). - To do:
[Session] CookieSecure=trueon an HTTPS-only site;HttpOnlyandSameSiteleft on. - To do:
ezformtokenactive. - To do: HTTPS on every name; HTTP redirected; HSTS raised once stable; certificate renewal watched.
- To do: Security headers present on pages (and on static files if you want them there).
- To do: Behind a proxy:
TrustedProxies[]lists exactly your proxies (Velocity:Q.webserver.proxy.trusted), the proxy setsX-Forwarded-Protoitself, the application server cannot be reached around it; thecurlcheck of 13.10 shows that a visitor's ownX-Forwarded-Protochanges nothing. - To do: One user (or one group) writes
var/andsettings/; no world-writable files; workers never root. - To do: Velocity:
Hostas intended, dashboard token and panel password set or the views kept local. - To do: Mail:
exp:mail:statusok, footer filled in, bounce reader configured. - To do: Audit:
exp:audit verifyintact,frequentcronjob group running, alert recipients checked, keys backed up. - To do: An update routine: who watches releases, how updates are tested, who receives security mail.
13.17 References
In this repository:
- Security defaults of September 2026: headers, session cookie, sign-in, served files, caching of personal pages
- Security hardening of 6.0.13, Datatype and input hardening, RAD tools: security
- Request rules
- Forwarded headers are trusted only from configured proxies; code in
lib/ezutils/classes/eztrustedproxy.phpandlib/ezutils/classes/ezsys.php - Changing your password, user password change notes, Form expired page
- The Exp Debug bar
- Audit
- E-mail preferences: administrator's guide, upgrade notes
- HTTP cache
- Velocity: HTTP/2 and security, HTTPS and certificates, control panel, engines
- Installing in one command (the generated administrator password)
- The settings:
settings/site.ini,settings/velocity.ini,settings/audit.ini,settings/requestrules.ini; the rules:.htaccess_root - Chapter 8: Serving the site
External:
- OWASP: Top Ten, Session Management Cheat Sheet, Cross-Site Request Forgery Prevention, Authentication Cheat Sheet, Password Storage Cheat Sheet, HTTP Security Response Headers, Content Security Policy, Secure Headers Project
- NIST SP 800-63B-4, Digital Identity Guidelines: Authentication and Authenticator Management (revision 4, which supersedes revision 3)
- PHP: Security, session configuration, display_errors, password_hash
- MDN: Set-Cookie, Strict-Transport-Security, Content-Security-Policy
- HSTS: RFC 6797; cookies: RFC 6265
- Apache: Security tips, mod_headers, mod_authz_host (Require ip)
- nginx: add_header, access module (allow, deny)
- Let's Encrypt: documentation
- Exponential Velocity engine: https://github.com/se7enxweb/exponential-velocity (its
docs/security.md,docs/https.md,docs/panel.md)
Contents · Previous: 12. Troubleshooting · Next: 14. Migrating from the 4.x line
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.