If you arrived here by asking our server for /.env, /.git/config or /wp-config.php.bak, hello. There is nothing to find: no keys, no passwords, not even a stray database dump. Since you have made the trip, you may as well see how we made sure of that.
For everyone else: this is the page our server shows to scanners hunting for leaked secrets. Every public website receives a steady stream of these requests, from scripts that work through the same short list of paths against every domain they can find. Most of the time they find nothing. Occasionally they find a live database password sitting in plain text. This post is about making sure your site is firmly in the first group, and that it says so politely.
What a probe response tells the scanner
The probe itself is harmless. The danger is what your server answers with, because every response is information.
- 200 OK with file contents is the bad day. The scanner now has your database password, your SMTP credentials or your API keys, and will usually start using them within minutes.
- 403 Forbidden confirms the path exists and that you consider it worth guarding. That is worth a second visit from a more patient attacker, and it tells automated tools to flag your domain.
- A default error page from the web server often prints the exact software and version, for example “nginx/1.18.0” or “Apache/2.4.41 (Ubuntu)”. That turns your error page into a shopping list of known vulnerabilities to try.
- 200 OK with a “not found” message is subtler, and it has its own section below.
What you want is a plain 404, rendered by your own site, identical to any other missing page. Nothing to learn, nothing to chase, and no reason for the scanner to come back.
The real fix comes before any of this
None of the configuration in this post matters if the secrets are in the document root to begin with. A .env file uploaded alongside the code, a .git folder left behind by a deploy, a wp-config.php.bak saved during a late-night fix: each of these is one request away from exposure, and every rule below is only a second line of defence.
So the first rule is simple: keep secrets outside the web root. Your .env should sit one level above public/ or public_html/, and your deploy process should never copy .git into the served folder. Backups belong on a different machine, not next to the site they were taken from. If you take one thing from this post, take that. Everything else is what catches the day the first rule slips.
Soft 404s: the quiet failure
There is a wrong answer that looks right. Some themes, page builders and frameworks respond to any unknown URL with a friendly “page not found” design but a 200 OK status. This is called a soft 404, and it causes two separate problems.
For search engines, a 200 status means “this is real content”. Crawlers index thousands of empty not-found pages, Google Search Console starts reporting soft 404s, and crawl budget is spent on URLs that do not exist. On larger sites this measurably slows down how quickly genuine new pages get crawled.
For scanners it is worse. A tool probing for /.env that receives 200 OK records a possible hit. You have told every automated scanner passing through that your site is worth a manual look, when in fact there was never anything there.
The test is easy: request a URL that definitely does not exist and check the status line, not just the page. You want the themed design and a real 404 in the status line.
nginx
Hide the version number first, in the http block of your nginx.conf:
server_tokens off;
Then add a location that catches any path segment starting with a dot, while leaving /.well-known/ alone so Let’s Encrypt renewals keep working. The negative lookahead in the regex does the excluding:
location ~ /.(?!well-known/) {
rewrite ^ /index.php last;
}
A note on how nginx picks a location: prefix locations are checked first for the longest match, but if the URI ends in a regular-expression location match, the regex locations win, and among regex locations nginx uses the first one that matches, in the order they appear in the config. These catch blocks must therefore sit above your PHP handler. A path like /.hidden/shell.php would otherwise be claimed by location ~ .php$ and executed, and a dotfile block placed after it would never be consulted.
For a WordPress site or any PHP front controller, hand the request to the application so it renders its own themed 404:
location ~ /.(?!well-known/) {
rewrite ^ /index.php last;
}
location ~* .(bak|old|orig|save|swp|sql)$ {
rewrite ^ /index.php last;
}
location ~ .php$ {
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
fastcgi_pass unix:/run/php/php8.3-fpm.sock;
}
The second block matters more than it looks. wp-config.php.bak does not end in .php, so the PHP handler ignores it, and without this block nginx would happily serve it as a plain text download, credentials and all. Because the rewrite only changes the internal path and REQUEST_URI still carries the original request, WordPress sees /.env, finds no matching content and returns its normal 404 page. The visitor gets your branding; the scanner gets nothing.
For a static site, answer directly but with your own page:
error_page 404 /404.html;
location = /404.html {
internal;
}
location ~ /.(?!well-known/) {
return 404;
}
location ~* .(bak|old|orig|save|swp|sql)$ {
return 404;
}
The internal marker stops anyone requesting /404.html directly, so the page is only ever shown as a genuine error response.
Apache
Hide the version in the main server config. ServerTokens is not allowed in .htaccess, so this belongs in apache2.conf, httpd.conf or a conf file they include:
ServerTokens Prod
ServerSignature Off
Then catch the probe paths with mod_rewrite. This works in a vhost or in .htaccess, and the (^|/) anchor handles the fact that rules in .htaccess see the URL path without its leading slash:
RewriteEngine On
RewriteRule "(^|/).(?!well-known/)" - [R=404,L]
RewriteRule ".(bak|old|orig|save|swp|sql)$" - [R=404,L]
ErrorDocument 404 /index.php
R=404 looks odd but is correct: with a status above 400, mod_rewrite returns that status immediately rather than issuing a redirect, and the ErrorDocument for 404 still renders your themed page.
If you would rather use mod_alias, RedirectMatch 404 "/.(?!well-known/)" does the same job in a vhost, where the matched path carries its leading slash. Do not copy that pattern into a .htaccess file: there the leading slash is stripped before matching, so the pattern never matches and the block silently does nothing. In .htaccess, stick to mod_rewrite.
Point ErrorDocument at your front controller for a themed page, or at a static /404.html.
One more line worth having on Apache is Options -Indexes. Directory listing on, and a single folder of backups or exports becomes a browsable catalogue. nginx has directory listing off by default.
Rules in the vhost are read once at start-up and are slightly faster. Rules in .htaccess are read on every request but survive on shared hosting where you cannot touch the server config. Either is fine for this job.
WordPress and plain PHP
WordPress already renders your theme’s 404.php for unknown URLs, so once the server hands probes over, you get a branded page for free. Three checks are still worth doing:
- Confirm the status code is a real 404. This is where soft 404s usually creep in: themes and page builders that show the right design with the wrong status. Test it with curl, shown near the end of this post.
- Keep
wp-config.phpbackups out of the web root. Better still, do not keep them on the server at all. - Turn off directory listing if it is not already off.
While you are in wp-config.php, check where the debug log goes. By default WP_DEBUG_LOG writes to wp-content/debug.log, which is publicly reachable and collects every warning, notice and failed query your site produces. Since WordPress 5.1 you can point it anywhere readable by PHP:
define( 'WP_DEBUG_LOG', '/var/log/wordpress/example-debug.log' );
Create the directory first and give PHP write access to it; the log then sits entirely outside anything the web server will serve.
Python: Django and Flask
With nginx in front of gunicorn, the cleanest approach is to let probe paths fall through to the application rather than answering them in nginx, so they get the same template as every other missing page. As long as nginx is only proxying and not also serving files straight out of your project directory, there is nothing on disk for it to leak.
Then make sure the application is in production mode. In Django:
DEBUG = False
ALLOWED_HOSTS = ["example.com", "www.example.com"]
With DEBUG = True, a 404 page lists every URL pattern in your project, which is a gift to anyone mapping your site for a deeper attack. With DEBUG = False, Django renders templates/404.html automatically, or you can point it at a custom view in your root urls.py:
handler404 = "core.views.page_not_found"
Whichever route you take, return a real 404 status from the view. Django’s default handler already does; hand-written wrappers sometimes forget.
In Flask, register an error handler and remember to return the status code alongside the template. Forget the 404 in the tuple and you have built a soft 404 generator:
@app.errorhandler(404)
def not_found(error):
return render_template("404.html"), 404
Going further: ban repeat offenders
A scanner that tries /.env, then /.git/config, then /.aws/credentials within a minute is not a lost visitor. fail2ban can block it at the firewall. A filter at /etc/fail2ban/filter.d/nginx-probe.conf:
[Definition]
failregex = ^<HOST> -[^"]*"(?:GET|POST|HEAD) /(?:.env|.git/|.aws/|wp-config.php.(?:bak|old|save))
ignoreregex =
This matches the standard nginx combined access log format, where the request line sits in quotes after the host, the identity field and the timestamp. And a jail in /etc/fail2ban/jail.local:
[nginx-probe]
enabled = true
port = http,https
filter = nginx-probe
logpath = /var/log/nginx/access.log
maxretry = 3
findtime = 600
bantime = 86400
That bans an address for a day after three probe attempts in ten minutes.
One important caveat. If your site sits behind Cloudflare, CloudFront or another CDN, your access log records the CDN’s edge addresses, not the scanner’s. Banning what you see means banning your own CDN, which blocks every visitor at once. You have two honest options: restore the real client address in your logs (Cloudflare’s CF-Connecting-IP header with nginx’s real IP module, or CloudFront’s header logging) so fail2ban sees the true source, or skip origin-side banning entirely and write a WAF rule at the edge instead. For CDN-fronted sites, the edge rule is nearly always the right answer: the request should be stopped before it ever reaches your server.
Check your own site
To see what your server currently hands out, ask it:
curl -sI https://example.com/.env
You want 404 on the first line, your usual set of headers, and no version number in the Server header. To sweep several paths at once:
for p in .env .env/ .git/config .aws/credentials wp-config.php.bak .test/; do
printf '%-22s ' "$p"; curl -s -o /dev/null -w '%{http_code}n' "https://example.com/$p"
done
Every line should read 404. Anything else deserves a closer look: a 403 means the file is probably there, a 200 means it may be readable, and a 301 or 302 redirects to somewhere worth following.
Two more checks worth running once. First, confirm the fix did not break certificate renewals: /.well-known/acme-challenge/ must still be reachable, and a forced renewal against your certificate authority’s staging endpoint proves it. Second, request a URL that never existed on your site and confirm you get your themed page with a real 404 status, not a soft one.
Quick checklist
- Secrets live outside the document root, and deploys never ship
.git. - Dot-paths return 404, with
/.well-known/still reachable. - Backup extensions such as
.bak,.oldand.sqlreturn 404. - The 404 is your own themed page, with a real 404 status.
- Server version is hidden:
server_tokens offorServerTokens Prod. - Directory listing is off.
- Debug modes are off in production, and debug logs live outside the web root.
- Repeat probers are blocked: fail2ban at the origin, WAF rules at the edge if you are behind a CDN.
Want a second pair of eyes?
If you are not sure what your server would hand over to a determined bot, we can check. DRS Web audits and hardens hosting for businesses of every size, from a single WordPress site to a fleet of servers: secrets out of the web root, probe paths answered properly, logging and banning set up sensibly. Get in touch and we will take a look.



![Python vs JavaScript: 57.9% vs 66% Usage Gap [2026]](https://drs-web.co.uk/wp-content/uploads/2026/09/promo-8.png)
