If you self-host Matomo, two things tend to surface around the same time. The admin login at index.php is reachable by anyone who knows the URL, with only a username and password in front of it. And files that feel like they should be private are downloadable straight from the browser: a stray .sh helper, a README.md buried in node_modules, leftover build artifacts. Neither is a bug. That's the default posture of a fresh install, and closing both gaps is on you, not on Matomo.
Fast path: keep matomo.php, matomo.js, the legacy piwik.* files, js/container_*.js, opt-out assets, and Heatmap configs public. Require authentication for everything else. Then switch archiving to real CLI, force HTTPS, verify the trusted host, enforce 2FA, turn on strong-password-on-change, and rotate stale tokens. The details below keep each exception testable.
A fresh Matomo self-host leaves the admin login exposed, and depending on your web server, leaves private files downloadable too. Matomo auto-protects its private directories on Apache and IIS but never the web root, and on Nginx it protects nothing. Require auth everywhere except an explicit public allow-list (matomo.php, matomo.js, js/container_*.js, opt-out, Heatmap configs), then handle archiving, force_ssl, trusted hosts, 2FA, and stale tokens. The one trap to plan for: Basic Auth breaks HTTP cron archiving, so move archiving to CLI with ./console core:archive.
It's worth understanding why the install feels open before you start pasting config. During setup, Matomo writes protective files for private directories on Apache and IIS. Those files are real, but Apache still has to honor overrides, and Nginx ignores .htaccess outright. Matomo also does not lock the web root for you, because the tracker, Tag Manager containers, opt-out iframe, and some plugin endpoints must remain reachable by visitors.
Confirm what's exposed before you change anything
Go to Administration → Diagnostic → System Check. It reports, among other things, whether private directories are reachable over HTTP. If it flags a directory as accessible, that's your starting point: the generated protection either did not write, is being ignored by the server, or has not been translated into Nginx rules. The same report runs from the CLI with ./console diagnostics:run, which is handy for checking several installs at once.
Then test it yourself, because System Check can't see everything your web server exposes. In a browser or with curl, hit paths that should be off-limits:
curl -I https://your-matomo.example.com/node_modules/
curl -I https://your-matomo.example.com/CHANGELOG.md
curl -I https://your-matomo.example.com/config/config.ini.phpYou want a 403, 404, or 401, not a directory listing or file contents. If a private file downloads, fix the server layer first. Where that fix lives depends on the stack: on Apache or IIS, run ./console core:create-security-files from the Matomo directory if the generated security files are missing; on Nginx, start from the maintained matomo-org/matomo-nginx configuration rather than relying on .htaccess; behind Docker or a proxy, track down which container or proxy actually serves Matomo before touching anything. Only once the private paths are sealed should you add admin auth with the public exceptions below.
Restrict the admin, keep the tracker public
The goal is to require authentication for everything by default, then re-open the handful of endpoints that genuinely need to be public. Matomo's own security guidance calls out this public family:
| Path | Why it stays public | Expected result |
|---|---|---|
/matomo.php, /matomo.js, /piwik.php, /piwik.js | JavaScript tracker and legacy tracker URLs | Reachable without Basic Auth |
/js/container_*.js | Matomo Tag Manager containers and preview containers | 200, no auth prompt |
/index.php?module=CoreAdminHome&action=optOut | Opt-out iframe | 200, no auth prompt |
/plugins/CoreAdminHome/javascripts/optOut.js | Opt-out JavaScript | 200, no auth prompt |
/plugins/HeatmapSessionRecording/configs.php | Heatmaps and Session Recording config bootstrap | Public if that plugin is enabled |
/favicon.ico | Browser favicon request | Public |
/node_modules/, /config/config.ini.php, /tmp/ | Private application files | 403, 404, or auth prompt |
Run this matrix after every server edit. Replace the Tag Manager container name with a real generated filename from your install:
curl -I https://your-matomo.example.com/matomo.js
curl -I https://your-matomo.example.com/matomo.php
curl -I https://your-matomo.example.com/js/container_ABC123.js
curl -I "https://your-matomo.example.com/index.php?module=CoreAdminHome&action=optOut"
curl -I https://your-matomo.example.com/plugins/CoreAdminHome/javascripts/optOut.js
curl -I https://your-matomo.example.com/plugins/HeatmapSessionRecording/configs.php
curl -I https://your-matomo.example.com/node_modules/Apache
On Apache 2.4, the web-root .htaccess approach only works if Apache is allowed to read it. Confirm the vhost has AllowOverride All, or at least the override classes needed for auth and access control, and that the auth modules are loaded: mod_auth_basic, mod_authn_file, and mod_authz_core. Create the password file outside the public web root, for example with htpasswd -c /secure/path/.htpasswd admin. Then reload Apache and test one protected URL and one public tracker URL before you change any other hardening setting.
Use modern Require directives. Allow, Deny, and Order are deprecated in Apache 2.4 and depend on mod_access_compat; they are not the right basis for new configs.
# Lock down everything by default
<Files "*">
AuthType Basic
AuthName "Matomo"
AuthBasicProvider file
AuthUserFile "/secure/path/.htpasswd"
Require valid-user
</Files>
# Re-open tracker, legacy tracker, Tag Manager, opt-out JS, and common public files
<FilesMatch "^(matomo|piwik)\.(php|js)$|^container_.*\.js$|^optOut\.js$|^favicon\.ico$|^robots\.txt$">
Require all granted
</FilesMatch>
# Re-open the Heatmap and Session Recording config endpoint only
<Files "configs.php">
<If "%{REQUEST_URI} =~ m#^/plugins/HeatmapSessionRecording/configs\.php$#">
Require all granted
</If>
</Files>
# Re-open the opt-out iframe, and nothing else under index.php
<Files "index.php">
<If "%{QUERY_STRING} =~ /^module=CoreAdminHome&action=optOut(?!.*module=)(?!.*action=)/">
Require all granted
</If>
</Files>If Matomo lives under a sub-path, update the REQUEST_URI expression for that path and re-run the curl checks. The opt-out block deserves the extra caution. The official endpoint is index.php?module=CoreAdminHome&action=optOut, and you have to keep it public so the iframe loads for visitors. The two negative lookaheads stop someone appending a second module= or action= to the query string to smuggle a different controller past your auth. Test that exact bypass on staging:
curl -I "https://your-matomo.example.com/index.php?module=CoreAdminHome&action=optOut"
curl -I "https://your-matomo.example.com/index.php?module=CoreAdminHome&action=optOut&module=UsersManager"The first should load without auth. The second should not.
If you'd rather gate the UI by network than by password, use Require ip 203.0.113.5 for the protected side instead of Require valid-user. That's the easier path when admins all sit behind a VPN or a known office IP.
Nginx
On Nginx, do not translate the Apache block mentally. Start from the maintained matomo-org/matomo-nginx vhost, then add Basic Auth at the server or / level and turn it off only for the public endpoint family above. Nginx location and regex order decide which rule wins, so the curl matrix is part of the change.
One workable pattern is to put a map in the http context, outside the server block, and feed that value into auth_basic:
map "$uri?$args" $matomo_basic_auth {
default "Matomo";
~^/(matomo|piwik)\.(php|js)\?$ off;
~^/js/container_.*\.js\?$ off;
~^/plugins/CoreAdminHome/javascripts/optOut\.js\?$ off;
~^/plugins/HeatmapSessionRecording/configs\.php\?$ off;
~^/favicon\.ico\?$ off;
~^/index\.php\?module=CoreAdminHome&action=optOut$ off;
}
server {
server_name your-matomo.example.com;
root /var/www/matomo;
auth_basic $matomo_basic_auth;
auth_basic_user_file /etc/nginx/.htpasswd;
location ~ ^/(index|matomo|piwik|js/index|plugins/HeatmapSessionRecording/configs)\.php$ {
include snippets/fastcgi-php.conf;
try_files $fastcgi_script_name =404;
fastcgi_param HTTP_PROXY "";
fastcgi_pass unix:/var/run/php/php8.3-fpm.sock;
}
location ~* ^.+\.php$ {
deny all;
return 403;
}
location / {
try_files $uri $uri/ =404;
}
location = /plugins/CoreAdminHome/javascripts/optOut.js {
try_files $uri =404;
}
location ~ \.(gif|ico|jpg|png|svg|js|css|htm|html|ttf|eot|woff|woff2)$ {
try_files $uri =404;
}
location ~ ^/(config|tmp|core|lang|libs|vendor|plugins|misc|node_modules) {
deny all;
return 403;
}
}This is a skeleton, not a replacement for the full Matomo Nginx vhost. Keep the FastCGI socket, TLS, headers, cache rules, and deny rules from your real config. If your opt-out URL has extra benign query parameters, add that exact shape to the map; do not make every index.php request public. If your Matomo sits behind a reverse proxy, the headers that matter for auth matter for the proxy too. We covered that wiring in why a reverse-proxied Matomo throws 502s.
Keep an SSH session open while you test. If you lock yourself out on Apache, move the web-root .htaccess aside and leave Matomo's private-directory security files alone. On Nginx, comment the new auth_basic lines, run nginx -t, and reload.
Don't lock out your own archiving
The first thing Basic Auth tends to break is HTTP-triggered cron archiving, which starts returning 401s the moment you protect the root. The clean fix is to stop archiving over HTTP and run it through the CLI:
./console core:archive --url=https://your-matomo.example.comRun that from the Matomo host as the web-server user or the dedicated cron user. core:archive normally avoids web-server auth when Matomo can launch CLI child processes. That nuance matters: if the OS or PHP runtime can't support CLI child processes, Matomo falls back to curl-based HTTP requests, and then Basic Auth can still bite. Watch the archiver output and server logs after the first protected run. If you see 401s, check whether CLI child processes are supported; if Matomo has fallen back to HTTP, fix CLI support or allow the archiver path with credentials over HTTPS.
If you're still wiring archiving up, setting up Matomo cron archiving walks through the whole job. If you genuinely must keep HTTP archiving, pass credentials only over HTTPS and prefer POSTing tokens where Matomo supports it. CLI is what we'd reach for every time.
Force HTTPS and verify the host header
Two lines in config/config.ini.php, under [General], close off a surprising amount of surface:
[General]
force_ssl = 1
trusted_hosts[] = "matomo.example.com"force_ssl = 1 redirects every http:// request to its https:// equivalent. Confirm HTTPS actually works first, or you'll lock yourself out. The trusted host check is enabled by default in current Matomo and the install hostname is set during setup, so treat this as a verification step: make sure every real public hostname appears as its own trusted_hosts[] line and remove stale ones. This protects against untrusted hostnames, including host-header injection risks, but it does not restrict visitors by IP or network. Use server-level rules for that.
Lock down the accounts
Web-server hardening protects the perimeter. The accounts behind it are the other half.
Start with two-factor authentication. Set up 2FA on your own Super User account first, under Administration → Personal → Security, because Matomo won't let you enforce it org-wide until you have. Then, in the Two-Factor Authentication settings under General Settings, tick "Require two-factor authentication for everyone". One caveat that matters here: 2FA isn't checked on API requests authenticated by token. So enforcing it hardens the interactive login and does nothing for a leaked token, which is exactly why the audit step below isn't redundant.
Force strong passwords next. Under Administration → System → General Settings → Login, enable "Force strong passwords to be used". It requires at least 12 characters with mixed case, numbers, and symbols when a password is set or updated. It is not retroactive: existing weak passwords continue to work until users or a Super User change them. If compliance requires immediate rotation, reset those accounts rather than assuming the checkbox forced it.
Leave brute-force protection on. Matomo 5 ships it enabled by default: repeated failed logins temporarily block the offending IP. Check the Login settings and your config for the actual duration on your install instead of assuming a universal block window. The allowlist for IPs that should never be blocked lives in that same General Settings → Login panel, and you can review or clear active blocks under Administration → Diagnostic → Brute Force Log. Worth checking the allowlist before a flaky office connection locks you out.
Audit users and tokens
The UI does not give a Super User a full token and password-age audit view. On self-hosted Matomo 5, query the database directly. Replace the matomo_ prefix if your tables use a different one:
SELECT
login,
email,
superuser_access,
ts_password_modified,
ts_last_seen
FROM matomo_user
ORDER BY login;
SELECT
login,
description,
date_created,
last_used,
date_expired,
secure_only,
system_token,
hash_algo
FROM matomo_user_token_auth
ORDER BY login, date_created;user_token_auth stores token hashes, not the plain token value. In current Matomo 5 code those hashes are salted SHA-512 values in the password column. The useful audit fields are the owner, description, creation date, last-used date, expiry, whether it is a system token, and secure_only. A secure_only token must be supplied through a secure mechanism such as POST or an auth header; do not rely on URL parameters for those tokens. Rotate or expire stale tokens, and prefer short-lived or POST-only tokens where the workflow allows it.
The user.ts_password_modified field records when a password was last changed. The UI may not expose that as a global report, but the data is there for self-hosted installs. The community UsersPasswordModified plugin adds a Users Manager column if you need that view in the interface. If a query against these tables errors out instead of returning rows, our notes on fixing common Matomo database errors cover the usual causes.
If creating or deleting an API token hangs after submission, inspect application logs and mail logs before blaming Basic Auth. If the one-time token display was interrupted, delete and recreate that token; you cannot read the plain token value back from the database.
On Matomo Cloud, the web-server and directory hardening is handled for you and you have no database access, so the audit becomes account-layer only: enforce 2FA, force strong passwords, rotate tokens on a schedule, and ask support for a data dump if you need a formal one.
What we'd actually do
If you run one install, the thirty minutes that matter most go like this: lock the web root with the Apache or Nginx pattern above, paste the public allow-list verbatim, switch archiving to CLI, set force_ssl, verify trusted_hosts, and enforce 2FA plus strong-password-on-change. Then prove four things: tracker returns without auth, Tag Manager container loads, opt-out iframe loads, and ./console core:archive runs without 401s. Save the config diff.
When something does break after you turn on Basic Auth, it's almost always one of four things, and the cause is usually obvious once you curl the right URL:
| What broke | Likely cause | Fix |
|---|---|---|
| Tracker or Tag Manager stops recording | A public tracker endpoint is still behind auth | curl -I matomo.js, matomo.php, and the real js/container_*.js URL from outside the admin network; add only that endpoint to the allow-list and reload |
| Opt-out iframe prompts for a password | index.php was protected without the narrow CoreAdminHome opt-out exception | Allow only module=CoreAdminHome&action=optOut and reject duplicate module/action parameters |
| Heatmaps or session recordings stop loading | plugins/HeatmapSessionRecording/configs.php is blocked | Add the Heatmap config endpoint to the allow-list when that plugin is enabled |
Cron archiving starts returning 401 | The job still uses an HTTP path, or core:archive fell back to curl-based HTTP | Run core:archive through CLI child processes, or configure the fallback path deliberately |
If you run several installs, put System Check (./console diagnostics:run) into a scheduled job and re-run it after every upgrade. Upgrades occasionally regenerate protected paths, and a check that runs itself is the only kind that survives the third busy week. Keep tracking on its own public endpoint while everything else stays gated. It's the same separation we lean on when we run the tracker server-side through the HTTP Tracking API.
Whichever route you take, save the evidence: the System Check result, a 403 or 404 for /node_modules/ and /config/config.ini.php, successful public responses for matomo.php, matomo.js, the Tag Manager container, opt-out, and Heatmap configs if enabled, and the first clean core:archive run after Basic Auth. A short hardening note now is what you'll thank yourself for the next time an upgrade quietly re-opens a path.
Martez connects Matomo with Meta Ads and Google Ads so ROAS, CLV, and attribution sit next to your web analytics instead of in a separate spreadsheet. It's in private beta. Join the waitlist if that's relevant to you.
Self-hosting means you own the data and you own the lock. Finish by proving the public endpoints still work and the private paths do not.