User Folder Exposure
The Grav admin dashboard can warn when a harmless test file in a private storage directory is reachable directly over the web:
Your web server is not applying Grav's access rules.
Take a confirmed exposure seriously. Depending on the directory and file types the server exposes, visitors could download account files, configuration, saved form submissions or whole-site backups. The check uses harmless sentinel files; it does not download your actual private data.
Updated Admin2 and API versions test .dat, .txt and .zip files in user/data, backup and tmp, and list the directory/file-type combinations found reachable. Older versions only test a .dat file under user/data; passing that one check does not establish that other file types or directories are protected. Network errors and unsuccessful checks are inconclusive, and an absent warning is not a complete security audit.
Why it happens
Grav relies on the web server to deny direct requests to its private folders. The block stops working when:
- You are on Apache but
.htaccessfiles are ignored, becauseAllowOverrideis set toNonefor your document root. This is the most common cause. - You are on Nginx, Caddy, or another server that does not read
.htaccessat all, and the equivalent rules were never added to the site configuration. - A front proxy serves existing static files itself and forwards only other requests to PHP or Apache. The front server may expose
.txtand.zipeven while.datrequests reach Apache and receive a 403. - A custom or host-provided server config replaced Grav's shipped rules without carrying these blocks across.
How to fix it
The goal is the same on every server: deny direct web access to backup, tmp, logs, user/accounts, user/config, user/env, and user/data (while still allowing public media uploaded under user/data, such as images, to be served).
Apache
First make sure .htaccess is being honored. In your virtual host (or the relevant <Directory> block), set:
<Directory /var/www/grav>
AllowOverride All
Require all granted
</Directory>
Reload Apache (sudo systemctl reload apache2 or sudo apachectl graceful). Compare your root .htaccess with the current Grav version and ensure it includes these rules; older versions may omit tmp:
# Block private storage regardless of file extension
RewriteRule ^(backup|tmp|logs)/(.*) error [F,NC]
# Block all direct access to these sensitive user folders, whatever the file type
RewriteRule ^(user)/(accounts|config|env)/(.*) error [F]
# Block user/data too, but allow public media uploads (e.g. Flex Object images)
RewriteCond %{REQUEST_URI} !\.(jpe?g|png|gif|webp|avif|bmp|ico|mp4|webm|ogg|ogv|mov|mp3|wav|m4a|flac|pdf)$ [NC]
RewriteRule ^(user)/data/(.*) error [F]
If you cannot enable AllowOverride, copy those rules into your virtual host configuration instead.
Note
If you replaced or heavily edited the shipped .htaccess, compare it against the current one in the Grav repository and make sure the Security block is present.
Nginx
Nginx does not read .htaccess. Compare your configuration with the shipped webserver-configs/nginx.conf and add these location blocks before generic static-file handlers. Adjust the paths if Grav is installed in a subdirectory or uses custom storage paths:
# deny private storage regardless of file extension
location ~* ^/(backup|tmp|logs)/ { return 403; }
# deny all direct access to these sensitive user folders, whatever the file type
location ~* /user/(accounts|config|env)/.*$ { return 403; }
# allow public media uploads under user/data to be served directly;
# this must come before the user/data deny so it wins the match
location ~* /user/data/.*\.(jpe?g|png|gif|webp|avif|bmp|ico|mp4|webm|ogg|ogv|mov|mp3|wav|m4a|flac|pdf)$ { try_files $uri =404; }
# deny everything else under user/data
location ~* /user/data/.*$ { return 403; }
Then reload Nginx (sudo nginx -t && sudo systemctl reload nginx). The full, recommended configuration is documented under Nginx.
Caddy
Caddy also ignores .htaccess. Add a matcher that returns a 403 for the private folders, before your php_fastcgi/file_server directives:
@gravBlocked path /backup/* /tmp/* /logs/* /user/accounts/* /user/config/* /user/env/* /user/data/*
respond @gravBlocked 403
If you serve public media from user/data, place a more specific matcher for the allowed image and media extensions ahead of the block so those requests still succeed.
LiteSpeed
LiteSpeed reads .htaccess and is compatible with Grav's Apache rules, so enabling .htaccess (the equivalent of AllowOverride All) is enough. Confirm rewrite rules are turned on for the virtual host.
Behind a proxy, CDN, or managed host
If your site sits behind a reverse proxy or CDN, or runs on a managed/shared host, the rules must be applied on whichever layer actually serves the files. Check with your host if you are unsure which server is in front.
Managed hosts and storage outside the web root
On hosts such as Cloudways, confirm which stack serves static files. Apache rules cannot protect a file served directly by nginx, even when Apache is behind it. Ask the host to apply the deny rules on the front server, ahead of its generic static-file handlers. Blocking access in PHP cannot intercept those requests.
In Grav versions with native .env support, you can relocate backups and temporary downloads using absolute paths in the Grav root's .env:
GRAV_BACKUP_PATH=/srv/private/grav-backups
GRAV_TMP_PATH=/srv/private/grav-tmp
Choose directories outside the document root and any public server aliases, writable by the PHP process. These settings redirect backup:// and tmp://; they do not move or remove old files. Move existing backups out of the old public directory, and remove obsolete temporary files when no installation or update is running. Verify the former URLs no longer return their contents. Relocating these directories does not protect user/accounts, user/config or saved form submissions; those still need access rules.
The Form plugin's save action defaults to .txt. On a host that serves .txt directly but blocks .dat, configure extension: dat as an interim measure and verify the saved-file URL is denied. A file extension alone is not access control: an nginx-only stack without deny rules may serve both. Extensions such as yaml, yml, json and md may be refused by security.uploads_dangerous_extensions; do not disable that protection to change the submission format.
Confirming it is fixed
Visit the dashboard again after reloading your server. The warning checks live, by trying to download the test file the same way a visitor would, so once direct access is blocked the banner disappears on the next load.
You can also test by hand using only harmless files. Write a short random marker into fresh .dat, .txt and .zip files in each directory being checked, request those URLs without authentication, and remove your test files afterward. A response containing the marker proves exposure. A 403 or 404 blocks that particular test; a server error, login page or failed request does not prove protection. Avoid using real backups, account files or submissions as test payloads.
A note on public media under user/data
Grav intentionally allows common image, audio, video, and PDF files under user/data to be served directly, so that media uploaded through Flex Objects and similar fields keeps working. This is by design and is not what the warning is about. The warning fires only when non-media files under your private folders are reachable. For serving private or arbitrary file uploads safely, route them through an application-level proxy rather than exposing the folder.